From c54e4d0900ab6bb92ca8a0a44a3fee8b54ce836d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 27 Dec 2025 01:36:24 +0100 Subject: [PATCH] refactor: node tools and canvas host url --- CHANGELOG.md | 5 + apps/macos/Sources/Clawdis/AsyncTimeout.swift | 22 ++++ .../NodeMode/MacNodeBridgePairingClient.swift | 40 +++---- .../NodeMode/MacNodeBridgeSession.swift | 31 ++--- .../NodeMode/MacNodeModeCoordinator.swift | 27 +---- docs/mac/canvas.md | 2 + docs/mac/signing.md | 6 +- docs/nodes.md | 4 + docs/tools.md | 3 +- scripts/codesign-mac-app.sh | 23 +++- src/cli/canvas-cli.coverage.test.ts | 48 ++++++++ src/cli/canvas-cli.ts | 113 +++++++++++++++++- src/cli/nodes-cli.ts | 32 +++++ src/gateway/protocol/index.ts | 5 + src/gateway/protocol/schema.ts | 7 ++ src/gateway/server.ts | 102 ++++++++++------ src/infra/bridge/server.ts | 23 ++-- src/infra/canvas-host-url.ts | 64 ++++++++++ src/infra/node-pairing.ts | 19 +++ 19 files changed, 448 insertions(+), 128 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/AsyncTimeout.swift create mode 100644 src/infra/canvas-host-url.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 404e25c2a..197c4baa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,9 +73,13 @@ - Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned. - Menu hover highlights now span the full width (including submenu arrows). - Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”). +- macOS node timeouts now share a single async timeout helper for consistent behavior. ### Nodes & Canvas - Debug status overlay gated and toggleable on macOS/iOS/Android nodes. +- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls). +- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders. +- `nodes rename` lets you override paired node display names without editing JSON. - Android scaffold asset cleanup; iOS canvas/voice wake adjustments. ### Logging & Observability @@ -87,6 +91,7 @@ ### Build, Dev, Docs - Notarization flow added for macOS release artifacts; packaging scripts updated. +- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback. - Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo). - Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls. - `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed). diff --git a/apps/macos/Sources/Clawdis/AsyncTimeout.swift b/apps/macos/Sources/Clawdis/AsyncTimeout.swift new file mode 100644 index 000000000..7ab4627c4 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AsyncTimeout.swift @@ -0,0 +1,22 @@ +import Foundation + +enum AsyncTimeout { + static func withTimeout( + seconds: Double, + onTimeout: @escaping @Sendable () -> Error, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + let clamped = max(0, seconds) + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) + throw onTimeout() + } + let result = try await group.next() + group.cancelAll() + if let result { return result } + throw onTimeout() + } + } +} diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift index fb53961b6..fcb8fbaae 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift @@ -17,14 +17,22 @@ actor MacNodeBridgePairingClient { let connection = NWConnection(to: endpoint, using: .tcp) let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-client") defer { connection.cancel() } - try await self.withTimeout(seconds: 8, purpose: "connect") { + try await AsyncTimeout.withTimeout(seconds: 8, onTimeout: { + NSError(domain: "Bridge", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "connect timed out", + ]) + }) { try await self.startAndWaitForReady(connection, queue: queue) } onStatus?("Authenticating…") try await self.send(hello, over: connection) - let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in + let first = try await AsyncTimeout.withTimeout(seconds: 10, onTimeout: { + NSError(domain: "Bridge", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "hello timed out", + ]) + }) { () -> ReceivedFrame in guard let frame = try await self.receiveFrame(over: connection) else { throw NSError(domain: "Bridge", code: 0, userInfo: [ NSLocalizedDescriptionKey: "Bridge closed connection during hello", @@ -60,7 +68,11 @@ actor MacNodeBridgePairingClient { over: connection) onStatus?("Waiting for approval…") - let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") { + let ok = try await AsyncTimeout.withTimeout(seconds: 60, onTimeout: { + NSError(domain: "Bridge", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "pairing approval timed out", + ]) + }) { while let next = try await self.receiveFrame(over: connection) { switch next.base.type { case "pair-ok": @@ -172,25 +184,5 @@ actor MacNodeBridgePairingClient { } } - private func withTimeout( - seconds: Double, - purpose: String, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw NSError(domain: "Bridge", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "\(purpose) timed out", - ]) - } - let result = try await group.next() - group.cancelAll() - if let result { return result } - throw NSError(domain: "Bridge", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "\(purpose) timed out", - ]) - } - } + } diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift index 023598252..9725a4f79 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift @@ -48,11 +48,15 @@ actor MacNodeBridgeSession { try await Self.waitForReady(stateStream, timeoutSeconds: 6) - try await Self.withTimeout(seconds: 6) { + try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: { + TimeoutError(message: "operation timed out") + }) { try await self.send(hello) } - guard let line = try await Self.withTimeout(seconds: 6, operation: { + guard let line = try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: { + TimeoutError(message: "operation timed out") + }, operation: { try await self.receiveLine() }), let data = line.data(using: .utf8), @@ -290,7 +294,9 @@ actor MacNodeBridgeSession { _ stream: AsyncStream, timeoutSeconds: Double) async throws { - try await self.withTimeout(seconds: timeoutSeconds) { + try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: { + TimeoutError(message: "operation timed out") + }) { for await state in stream { switch state { case .ready: @@ -311,22 +317,5 @@ actor MacNodeBridgeSession { } } - private static func withTimeout( - seconds: Double, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TimeoutError(message: "operation timed out") - } - let result = try await group.next() - group.cancelAll() - if let result { return result } - throw TimeoutError(message: "operation timed out") - } - } + } diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift index b946a6d84..a4496d973 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift @@ -228,7 +228,11 @@ final class MacNodeModeCoordinator { _ stream: AsyncStream, timeoutSeconds: Double) async throws { - try await self.withTimeout(seconds: timeoutSeconds) { + try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: { + NSError(domain: "Bridge", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "operation timed out", + ]) + }) { for await state in stream { switch state { case .ready: @@ -249,27 +253,6 @@ final class MacNodeModeCoordinator { } } - private static func withTimeout( - seconds: Double, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw NSError(domain: "Bridge", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "operation timed out", - ]) - } - let result = try await group.next() - group.cancelAll() - if let result { return result } - throw NSError(domain: "Bridge", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "operation timed out", - ]) - } - } - private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? { let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode }) if mode == .remote { diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index 08ebd25c1..3b712e444 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -123,6 +123,8 @@ clawdis canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --node Notes: - This does **not** support the A2UI v0.9 examples using `createSurface`. - A2UI **fails** if the Gateway canvas host is unreachable (no local fallback). +- `canvas a2ui push` validates JSONL (line numbers on errors) and rejects v0.9 payloads. +- Quick smoke: `clawdis canvas a2ui push --text "Hello from A2UI"` renders a minimal v0.8 view. ## Triggering agent runs from Canvas (deep links) diff --git a/docs/mac/signing.md b/docs/mac/signing.md index 6837216c0..05c1b4bb5 100644 --- a/docs/mac/signing.md +++ b/docs/mac/signing.md @@ -9,7 +9,7 @@ This app is usually built from `scripts/package-mac-app.sh`, which now: - sets a stable debug bundle identifier: `com.steipete.clawdis.debug` - writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`) -- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). Defaults to ad‑hoc; set `SIGN_IDENTITY="Developer ID Application: …"` to use a real cert. +- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). Requires a valid signing identity. - uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds). - injects build metadata into Info.plist: `ClawdisBuildTimestamp` (UTC) and `ClawdisGitCommit` (short hash) so the About pane can show build, git, and debug/release channel. - reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert; otherwise signing falls back to ad‑hoc. @@ -31,6 +31,10 @@ If you need a different bundle id (e.g. release build): BUNDLE_ID=com.steipete.clawdis scripts/package-mac-app.sh ``` +Signing identity selection: +- If `SIGN_IDENTITY` is unset, the script auto-picks a valid identity (Developer ID → Apple Distribution → Apple Development). +- If no identities exist, the script fails with an error (no ad‑hoc fallback). + ## Build metadata for About `package-mac-app.sh` stamps the bundle with: diff --git a/docs/nodes.md b/docs/nodes.md index 5f6f7c282..ae49bb47d 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -24,8 +24,12 @@ clawdis nodes approve clawdis nodes reject clawdis nodes status clawdis nodes describe --node +clawdis nodes rename --node --name "Kitchen iPad" ``` +Notes: +- `nodes rename` stores a display name override in the gateway pairing store. + ## Invoking commands Low-level (raw RPC): diff --git a/docs/tools.md b/docs/tools.md index 002db02a4..048c0ba27 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -63,7 +63,8 @@ Core actions: Notes: - Uses gateway `node.invoke` under the hood. - If no `node` is provided, the tool picks a default (single connected node or local mac node). -- A2UI is v0.8 only (no `createSurface`). +- A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors. +- Quick smoke: `clawdis canvas a2ui push --text "Hello from A2UI"`. ### `clawdis_nodes` Discover and target paired nodes; send notifications; capture camera/screen. diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 7705e1721..1b2174558 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -26,6 +26,22 @@ select_identity() { return fi + # Next, try Apple Distribution. + preferred="$(security find-identity -p codesigning -v 2>/dev/null \ + | awk -F'\"' '/Apple Distribution/ { print $2; exit }')" + if [ -n "$preferred" ]; then + echo "$preferred" + return + fi + + # Then, try Apple Development. + preferred="$(security find-identity -p codesigning -v 2>/dev/null \ + | awk -F'\"' '/Apple Development/ { print $2; exit }')" + if [ -n "$preferred" ]; then + echo "$preferred" + return + fi + # Fallback to the first valid signing identity. available="$(security find-identity -p codesigning -v 2>/dev/null \ | sed -n 's/.*\"\\(.*\\)\"/\\1/p')" @@ -67,6 +83,9 @@ case "$TIMESTAMP_MODE" in ;; esac +options_args=("--options" "runtime") +timestamp_args=("$timestamp_arg") + cat > "$ENT_TMP_BASE" <<'PLIST' @@ -138,12 +157,12 @@ xattr -cr "$APP_BUNDLE" 2>/dev/null || true sign_item() { local target="$1" local entitlements="$2" - codesign --force --options runtime "$timestamp_arg" --entitlements "$entitlements" --sign "$IDENTITY" "$target" + codesign --force "${options_args[@]}" "${timestamp_args[@]}" --entitlements "$entitlements" --sign "$IDENTITY" "$target" } sign_plain_item() { local target="$1" - codesign --force --options runtime "$timestamp_arg" --sign "$IDENTITY" "$target" + codesign --force "${options_args[@]}" "${timestamp_args[@]}" --sign "$IDENTITY" "$target" } # Sign main binary diff --git a/src/cli/canvas-cli.coverage.test.ts b/src/cli/canvas-cli.coverage.test.ts index f2e1dbb48..3b7d9ce5b 100644 --- a/src/cli/canvas-cli.coverage.test.ts +++ b/src/cli/canvas-cli.coverage.test.ts @@ -107,4 +107,52 @@ describe("canvas-cli coverage", () => { expect(runtimeErrors).toHaveLength(0); expect(runtimeLogs.join("\n")).toContain("ok"); }); + + it("pushes A2UI text payload", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerCanvasCli } = await import("./canvas-cli.js"); + const program = new Command(); + program.exitOverride(); + registerCanvasCli(program); + + await program.parseAsync( + ["canvas", "a2ui", "push", "--node", "mac-1", "--text", "Hello A2UI"], + { from: "user" }, + ); + + const invoke = callGateway.mock.calls.find( + (call) => call[0]?.method === "node.invoke", + )?.[0]; + + expect(invoke?.params?.command).toBe("canvas.a2ui.pushJSONL"); + expect(invoke?.params?.params?.jsonl).toContain("Hello A2UI"); + }); + + it("rejects invalid A2UI JSONL", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + vi.resetModules(); + vi.doMock("node:fs/promises", () => ({ + default: { readFile: vi.fn(async () => "{broken") }, + })); + + const { registerCanvasCli } = await import("./canvas-cli.js"); + const program = new Command(); + program.exitOverride(); + registerCanvasCli(program); + + await expect( + program.parseAsync( + ["canvas", "a2ui", "push", "--node", "mac-1", "--jsonl", "/tmp/a2ui.jsonl"], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.join("\n")).toContain("Invalid A2UI JSONL"); + }); }); diff --git a/src/cli/canvas-cli.ts b/src/cli/canvas-cli.ts index f53c5b30c..fb8dc176a 100644 --- a/src/cli/canvas-cli.ts +++ b/src/cli/canvas-cli.ts @@ -22,6 +22,7 @@ type CanvasOpts = { height?: string; js?: string; jsonl?: string; + text?: string; format?: string; maxWidth?: string; quality?: string; @@ -54,6 +55,16 @@ type PairingList = { paired: PairedNode[]; }; +const A2UI_ACTION_KEYS = [ + "beginRendering", + "surfaceUpdate", + "dataModelUpdate", + "deleteSurface", + "createSurface", +] as const; + +type A2UIVersion = "v0.8" | "v0.9"; + const canvasCallOpts = (cmd: Command) => cmd .option("--url ", "Gateway WebSocket URL", "ws://127.0.0.1:18789") @@ -104,6 +115,86 @@ function normalizeNodeKey(value: string) { .replace(/-+$/, ""); } +function buildA2UITextJsonl(text: string) { + const surfaceId = "main"; + const rootId = "root"; + const textId = "text"; + const payloads = [ + { + surfaceUpdate: { + surfaceId, + components: [ + { + id: rootId, + component: { Column: { children: { explicitList: [textId] } } }, + }, + { + id: textId, + component: { + Text: { text: { literalString: text }, usageHint: "body" }, + }, + }, + ], + }, + }, + { beginRendering: { surfaceId, root: rootId } }, + ]; + return payloads.map((payload) => JSON.stringify(payload)).join("\n"); +} + +function validateA2UIJsonl(jsonl: string) { + const lines = jsonl.split(/\r?\n/); + const errors: string[] = []; + let sawV08 = false; + let sawV09 = false; + let messageCount = 0; + + lines.forEach((line, idx) => { + const trimmed = line.trim(); + if (!trimmed) return; + messageCount += 1; + let obj: unknown; + try { + obj = JSON.parse(trimmed) as unknown; + } catch (err) { + errors.push(`line ${idx + 1}: ${String(err)}`); + return; + } + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + errors.push(`line ${idx + 1}: expected JSON object`); + return; + } + const record = obj as Record; + const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record); + if (actionKeys.length !== 1) { + errors.push( + `line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join( + ", ", + )})`, + ); + return; + } + if (actionKeys[0] === "createSurface") { + sawV09 = true; + } else { + sawV08 = true; + } + }); + + if (messageCount === 0) { + errors.push("no JSONL messages found"); + } + if (sawV08 && sawV09) { + errors.push("mixed A2UI v0.8 and v0.9 messages in one file"); + } + if (errors.length > 0) { + throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`); + } + + const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8"; + return { version, messageCount }; +} + async function loadNodes(opts: CanvasOpts): Promise { try { const res = (await callGatewayCli("node.list", opts, {})) as unknown; @@ -389,14 +480,30 @@ export function registerCanvasCli(program: Command) { .command("push") .description("Push A2UI JSONL to the canvas") .option("--jsonl ", "Path to JSONL payload") + .option("--text ", "Render a quick A2UI text payload") .option("--node ", "Node id, name, or IP") .action(async (opts: CanvasOpts) => { try { - if (!opts.jsonl) throw new Error("missing --jsonl"); - const jsonl = await fs.readFile(String(opts.jsonl), "utf8"); + const hasJsonl = Boolean(opts.jsonl); + const hasText = typeof opts.text === "string"; + if (hasJsonl === hasText) { + throw new Error("provide exactly one of --jsonl or --text"); + } + + const jsonl = hasText + ? buildA2UITextJsonl(String(opts.text ?? "")) + : await fs.readFile(String(opts.jsonl), "utf8"); + const { version, messageCount } = validateA2UIJsonl(jsonl); + if (version === "v0.9") { + throw new Error( + "Detected A2UI v0.9 JSONL (createSurface). Clawdis currently supports v0.8 only.", + ); + } await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl }); if (!opts.json) { - defaultRuntime.log("canvas a2ui push ok"); + defaultRuntime.log( + `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`, + ); } } catch (err) { defaultRuntime.error(`canvas a2ui push failed: ${String(err)}`); diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index 7c8d56150..18f3bc804 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -38,6 +38,7 @@ type NodesRpcOpts = { sound?: string; priority?: string; delivery?: string; + name?: string; facing?: string; format?: string; maxWidth?: string; @@ -478,6 +479,37 @@ export function registerNodesCli(program: Command) { }), ); + nodesCallOpts( + nodes + .command("rename") + .description("Rename a paired node (display name override)") + .requiredOption("--node ", "Node id, name, or IP") + .requiredOption("--name ", "New display name") + .action(async (opts: NodesRpcOpts) => { + try { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const name = String(opts.name ?? "").trim(); + if (!nodeId || !name) { + defaultRuntime.error("--node and --name required"); + defaultRuntime.exit(1); + return; + } + const result = await callGatewayCli("node.rename", opts, { + nodeId, + displayName: name, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`); + } catch (err) { + defaultRuntime.error(`nodes rename failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + nodesCallOpts( nodes .command("invoke") diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 1a0c69fed..725f37cc6 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -60,6 +60,8 @@ import { NodePairRequestParamsSchema, type NodePairVerifyParams, NodePairVerifyParamsSchema, + type NodeRenameParams, + NodeRenameParamsSchema, PROTOCOL_VERSION, type PresenceEntry, PresenceEntrySchema, @@ -135,6 +137,9 @@ export const validateNodePairRejectParams = ajv.compile( export const validateNodePairVerifyParams = ajv.compile( NodePairVerifyParamsSchema, ); +export const validateNodeRenameParams = ajv.compile( + NodeRenameParamsSchema, +); export const validateNodeListParams = ajv.compile(NodeListParamsSchema); export const validateNodeDescribeParams = ajv.compile( diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 00df8da62..7ea3fc23a 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -254,6 +254,11 @@ export const NodePairVerifyParamsSchema = Type.Object( { additionalProperties: false }, ); +export const NodeRenameParamsSchema = Type.Object( + { nodeId: NonEmptyString, displayName: NonEmptyString }, + { additionalProperties: false }, +); + export const NodeListParamsSchema = Type.Object( {}, { additionalProperties: false }, @@ -652,6 +657,7 @@ export const ProtocolSchemas: Record = { NodePairApproveParams: NodePairApproveParamsSchema, NodePairRejectParams: NodePairRejectParamsSchema, NodePairVerifyParams: NodePairVerifyParamsSchema, + NodeRenameParams: NodeRenameParamsSchema, NodeListParams: NodeListParamsSchema, NodeDescribeParams: NodeDescribeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, @@ -707,6 +713,7 @@ export type NodePairListParams = Static; export type NodePairApproveParams = Static; export type NodePairRejectParams = Static; export type NodePairVerifyParams = Static; +export type NodeRenameParams = Static; export type NodeListParams = Static; export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index bbc962d5f..312cde115 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -76,6 +76,7 @@ import { isVerbose } from "../globals.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js"; import { startNodeBridgeServer } from "../infra/bridge/server.js"; +import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { getLastHeartbeatEvent, @@ -90,6 +91,7 @@ import { getMachineDisplayName } from "../infra/machine-name.js"; import { approveNodePairing, listNodePairing, + renamePairedNode, rejectNodePairing, requestNodePairing, verifyNodeToken, @@ -379,6 +381,7 @@ import { validateNodePairRejectParams, validateNodePairRequestParams, validateNodePairVerifyParams, + validateNodeRenameParams, validateProvidersStatusParams, validateRequestFrame, validateSendParams, @@ -485,6 +488,7 @@ const METHODS = [ "node.pair.approve", "node.pair.reject", "node.pair.verify", + "node.rename", "node.list", "node.describe", "node.invoke", @@ -609,39 +613,6 @@ function buildSnapshot(): Snapshot { const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit -function deriveCanvasHostUrl( - req: IncomingMessage | undefined, - canvasPort: number | undefined, - hostOverride?: string, -) { - if (!req || !canvasPort) return undefined; - const hostHeader = req.headers.host?.trim(); - const forwardedProto = - typeof req.headers["x-forwarded-proto"] === "string" - ? req.headers["x-forwarded-proto"] - : Array.isArray(req.headers["x-forwarded-proto"]) - ? req.headers["x-forwarded-proto"][0] - : undefined; - const scheme = forwardedProto === "https" ? "https" : "http"; - - let host = (hostOverride ?? "").trim(); - if (host === "0.0.0.0" || host === "::") host = ""; - if (!host && hostHeader) { - try { - const parsed = new URL(`http://${hostHeader}`); - host = parsed.hostname; - } catch { - host = ""; - } - } - if (!host) { - host = req.socket?.localAddress?.trim() ?? ""; - } - if (!host) return undefined; - - const formattedHost = host.includes(":") ? `[${host}]` : host; - return `${scheme}://${formattedHost}:${canvasPort}`; -} const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits const HANDSHAKE_TIMEOUT_MS = 10_000; const TICK_INTERVAL_MS = 30_000; @@ -3519,11 +3490,13 @@ export async function startGatewayServer( bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::" ? bridgeHost : undefined; - const canvasHostUrl = deriveCanvasHostUrl( - upgradeReq, - canvasHostPortForWs, - canvasHostServer ? canvasHostOverride : undefined, - ); + const canvasHostUrl = resolveCanvasHostUrl({ + canvasPort: canvasHostPortForWs, + hostOverride: canvasHostServer ? canvasHostOverride : undefined, + requestHost: upgradeReq.headers.host, + forwardedProto: upgradeReq.headers["x-forwarded-proto"], + localAddress: upgradeReq.socket?.localAddress, + }); logWs("in", "open", { connId, remoteAddr }); const isWebchatConnect = (params: ConnectParams | null | undefined) => params?.client?.mode === "webchat" || @@ -5438,6 +5411,59 @@ export async function startGatewayServer( } break; } + case "node.rename": { + const params = (req.params ?? {}) as Record; + if (!validateNodeRenameParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.rename params: ${formatValidationErrors(validateNodeRenameParams.errors)}`, + ), + ); + break; + } + const { nodeId, displayName } = params as { + nodeId: string; + displayName: string; + }; + try { + const trimmed = displayName.trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "displayName required", + ), + ); + break; + } + const updated = await renamePairedNode(nodeId, trimmed); + if (!updated) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), + ); + break; + } + respond( + true, + { nodeId: updated.nodeId, displayName: updated.displayName }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } case "node.list": { const params = (req.params ?? {}) as Record; if (!validateNodeListParams(params)) { diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 64c2df76b..6abcdf86a 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import net from "node:net"; import os from "node:os"; +import { resolveCanvasHostUrl } from "../canvas-host-url.js"; import { getPairedNode, listNodePairing, @@ -188,23 +189,13 @@ export async function startNodeBridgeServer( ? opts.serverName.trim() : os.hostname(); - const isLoopbackHost = (host: string) => { - const normalized = host.trim().toLowerCase(); - if (normalized === "localhost") return true; - if (normalized === "::1") return true; - if (normalized === "0.0.0.0" || normalized === "::") return true; - return normalized.startsWith("127."); - }; - const buildCanvasHostUrl = (socket: net.Socket) => { - const port = opts.canvasHostPort; - if (!port) return undefined; - const localHost = socket.localAddress?.trim() ?? ""; - const override = opts.canvasHostHost?.trim() ?? ""; - const host = !localHost || isLoopbackHost(localHost) ? override : localHost; - if (!host) return undefined; - const formatted = host.includes(":") ? `[${host}]` : host; - return `http://${formatted}:${port}`; + return resolveCanvasHostUrl({ + canvasPort: opts.canvasHostPort, + hostOverride: opts.canvasHostHost, + localAddress: socket.localAddress, + scheme: "http", + }); }; type ConnectionState = { diff --git a/src/infra/canvas-host-url.ts b/src/infra/canvas-host-url.ts new file mode 100644 index 000000000..199f0a639 --- /dev/null +++ b/src/infra/canvas-host-url.ts @@ -0,0 +1,64 @@ +type HostSource = string | null | undefined; + +type CanvasHostUrlParams = { + canvasPort?: number; + hostOverride?: HostSource; + requestHost?: HostSource; + forwardedProto?: HostSource | HostSource[]; + localAddress?: HostSource; + scheme?: "http" | "https"; +}; + +const isLoopbackHost = (value: string) => { + const normalized = value.trim().toLowerCase(); + if (!normalized) return false; + if (normalized === "localhost") return true; + if (normalized === "::1") return true; + if (normalized === "0.0.0.0" || normalized === "::") return true; + return normalized.startsWith("127."); +}; + +const normalizeHost = (value: HostSource, rejectLoopback: boolean) => { + if (!value) return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + if (rejectLoopback && isLoopbackHost(trimmed)) return ""; + return trimmed; +}; + +const parseHostHeader = (value: HostSource) => { + if (!value) return ""; + try { + return new URL(`http://${String(value).trim()}`).hostname; + } catch { + return ""; + } +}; + +const parseForwardedProto = (value: HostSource | HostSource[]) => { + if (Array.isArray(value)) return value[0]; + return value; +}; + +export function resolveCanvasHostUrl(params: CanvasHostUrlParams) { + const port = params.canvasPort; + if (!port) return undefined; + + const scheme = + params.scheme ?? + (parseForwardedProto(params.forwardedProto)?.trim() === "https" + ? "https" + : "http"); + + const override = normalizeHost(params.hostOverride, true); + const requestHost = normalizeHost(parseHostHeader(params.requestHost), !!override); + const localAddress = normalizeHost( + params.localAddress, + Boolean(override || requestHost), + ); + + const host = override || requestHost || localAddress; + if (!host) return undefined; + const formatted = host.includes(":") ? `[${host}]` : host; + return `${scheme}://${formatted}:${port}`; +} diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 62325f7dd..6cef7dcf6 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -292,3 +292,22 @@ export async function updatePairedNodeMetadata( await persistState(state, baseDir); }); } + +export async function renamePairedNode( + nodeId: string, + displayName: string, + baseDir?: string, +): Promise { + return await withLock(async () => { + const state = await loadState(baseDir); + const normalized = normalizeNodeId(nodeId); + const existing = state.pairedByNodeId[normalized]; + if (!existing) return null; + const trimmed = displayName.trim(); + if (!trimmed) throw new Error("displayName required"); + const next: NodePairingPairedNode = { ...existing, displayName: trimmed }; + state.pairedByNodeId[normalized] = next; + await persistState(state, baseDir); + return next; + }); +}