Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
352 lines
12 KiB
Swift
352 lines
12 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct AppStateRemoteConfigTests {
|
|
@Test
|
|
func `updated remote gateway config sets trimmed token`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: [:],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: "gateway.example",
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "/tmp/id_ed25519",
|
|
remoteToken: " secret-token ",
|
|
remoteTokenDirty: true))
|
|
|
|
#expect(remote["token"] as? String == "secret-token")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config clears token when blank`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: ["token": "old-token"],
|
|
draft: .init(
|
|
transport: .direct,
|
|
remoteUrl: "wss://gateway.example",
|
|
remoteHost: nil,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteToken: " ",
|
|
remoteTokenDirty: true))
|
|
|
|
#expect((remote["token"] as? String) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config pins loopback url for ssh transport`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: ["url": "ws://gateway.example:18789"],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: "gateway.example",
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
|
#expect(remote["transport"] as? String == "ssh")
|
|
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config keeps OpenSSH opt in only for the same target`() {
|
|
let sameTarget = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: [
|
|
"sshHostKeyPolicy": "openssh",
|
|
"sshTarget": "alice@gateway.example",
|
|
],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: nil,
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
let changedTarget = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: [
|
|
"sshHostKeyPolicy": "openssh",
|
|
"sshTarget": "old-gateway-alias",
|
|
],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: nil,
|
|
remoteTarget: "new-gateway-alias",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#expect(sameTarget["sshHostKeyPolicy"] as? String == "openssh")
|
|
#expect(changedTarget["sshHostKeyPolicy"] as? String == "strict")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config preserves custom loopback tunnel port`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: ["url": "ws://localhost.:29876"],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: "gateway.example",
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config preserves custom port when existing host matches ssh target`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: ["url": "ws://gateway.example:19999"],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: nil,
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#expect(remote["url"] as? String == "ws://127.0.0.1:19999")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config drops custom port when existing host does not match ssh target`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: ["url": "ws://other-host.example:19999"],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: "gateway.example",
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config does not preserve port for hostname prefix collision`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: ["url": "ws://example.attacker.tld:19999"],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: nil,
|
|
remoteTarget: "alice@example.com",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
|
}
|
|
|
|
@Test
|
|
func `app state init does not infer loopback host into remote target`() async {
|
|
let configPath = TestIsolation.tempConfigPath()
|
|
await TestIsolation.withIsolatedState(
|
|
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
|
defaults: [remoteTargetKey: nil])
|
|
{
|
|
OpenClawConfigFile.saveDict([
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"url": "ws://127.0.0.1:19999",
|
|
],
|
|
],
|
|
])
|
|
|
|
let state = AppState(preview: true)
|
|
#expect(state.remoteTarget == "")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `app state init preserves existing remote target when remote url is loopback`() async {
|
|
let configPath = TestIsolation.tempConfigPath()
|
|
await TestIsolation.withIsolatedState(
|
|
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
|
defaults: [remoteTargetKey: "alice@gateway.example"])
|
|
{
|
|
OpenClawConfigFile.saveDict([
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"url": "ws://127.0.0.1:19999",
|
|
],
|
|
],
|
|
])
|
|
|
|
let state = AppState(preview: true)
|
|
#expect(state.remoteTarget == "alice@gateway.example")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `app state init preserves legacy SSH tunnel config until transport is explicit`() async {
|
|
let configPath = TestIsolation.tempConfigPath()
|
|
await TestIsolation.withIsolatedState(
|
|
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
|
defaults: [remoteTargetKey: nil])
|
|
{
|
|
OpenClawConfigFile.saveDict([
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"url": "ws://127.0.0.1:18789",
|
|
"sshTarget": "steipete@192.168.0.202",
|
|
],
|
|
],
|
|
])
|
|
|
|
let state = AppState(preview: true)
|
|
#expect(state.remoteTransport == .ssh)
|
|
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `synced gateway root preserves object token across mode and transport changes when untouched`() {
|
|
let initialRoot: [String: Any] = [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"transport": "direct",
|
|
"url": "wss://old-gateway.example",
|
|
"token": [
|
|
"$secretRef": "gateway-token", // pragma: allowlist secret
|
|
],
|
|
],
|
|
],
|
|
]
|
|
|
|
let sshRoot = AppState._testSyncedGatewayRoot(
|
|
currentRoot: initialRoot,
|
|
draft: .init(
|
|
connectionMode: .remote,
|
|
remoteTransport: .ssh,
|
|
remoteTarget: "alice@gateway.example",
|
|
remoteIdentity: "",
|
|
remoteUrl: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
|
|
#expect((sshRemote?["token"] as? [String: String])?["$secretRef"] ==
|
|
"gateway-token") // pragma: allowlist secret
|
|
|
|
let localRoot = AppState._testSyncedGatewayRoot(
|
|
currentRoot: sshRoot,
|
|
draft: .init(
|
|
connectionMode: .local,
|
|
remoteTransport: .ssh,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteUrl: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
let localGateway = localRoot["gateway"] as? [String: Any]
|
|
let localRemote = localGateway?["remote"] as? [String: Any]
|
|
#expect(localGateway?["mode"] as? String == "local")
|
|
#expect((localRemote?["token"] as? [String: String])?["$secretRef"] ==
|
|
"gateway-token") // pragma: allowlist secret
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config replaces object token when user enters plaintext`() {
|
|
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: [
|
|
"token": [
|
|
"$secretRef": "gateway-token", // pragma: allowlist secret
|
|
],
|
|
],
|
|
draft: .init(
|
|
transport: .direct,
|
|
remoteUrl: "wss://gateway.example",
|
|
remoteHost: nil,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteToken: " fresh-token ",
|
|
remoteTokenDirty: true))
|
|
|
|
#expect(remote["token"] as? String == "fresh-token")
|
|
}
|
|
|
|
@Test
|
|
func `updated remote gateway config clears object token only after explicit edit`() {
|
|
let current: [String: Any] = [
|
|
"token": [
|
|
"$secretRef": "gateway-token", // pragma: allowlist secret
|
|
],
|
|
]
|
|
|
|
let preserved = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: current,
|
|
draft: .init(
|
|
transport: .direct,
|
|
remoteUrl: "wss://gateway.example",
|
|
remoteHost: nil,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
#expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
|
|
|
|
let cleared = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: current,
|
|
draft: .init(
|
|
transport: .direct,
|
|
remoteUrl: "wss://gateway.example",
|
|
remoteHost: nil,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteToken: " ",
|
|
remoteTokenDirty: true))
|
|
#expect((cleared["token"] as? String) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `synced gateway root preserves gateway auth across mode changes`() {
|
|
let initialRoot: [String: Any] = [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"auth": [
|
|
"mode": "token",
|
|
"token": "test-token", // pragma: allowlist secret
|
|
],
|
|
"remote": [
|
|
"transport": "direct",
|
|
"url": "wss://old-gateway.example",
|
|
],
|
|
],
|
|
]
|
|
|
|
let localRoot = AppState._testSyncedGatewayRoot(
|
|
currentRoot: initialRoot,
|
|
draft: .init(
|
|
connectionMode: .local,
|
|
remoteTransport: .ssh,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteUrl: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
let localGateway = localRoot["gateway"] as? [String: Any]
|
|
let auth = localGateway?["auth"] as? [String: Any]
|
|
#expect(localGateway?["mode"] as? String == "local")
|
|
#expect(auth?["mode"] as? String == "token")
|
|
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
|
|
}
|
|
}
|