refactor: node tools and canvas host url

This commit is contained in:
Peter Steinberger
2025-12-27 01:36:24 +01:00
parent 52ca5c4aa2
commit c54e4d0900
19 changed files with 448 additions and 128 deletions

View File

@@ -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).

View File

@@ -0,0 +1,22 @@
import Foundation
enum AsyncTimeout {
static func withTimeout<T: Sendable>(
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()
}
}
}

View File

@@ -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<T: Sendable>(
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",
])
}
}
}

View File

@@ -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<NWConnection.State>,
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<T: Sendable>(
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")
}
}
}

View File

@@ -228,7 +228,11 @@ final class MacNodeModeCoordinator {
_ stream: AsyncStream<NWConnection.State>,
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<T: Sendable>(
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 {

View File

@@ -123,6 +123,8 @@ clawdis canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --node <id>
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)

View File

@@ -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 adhoc; 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 adhoc.
@@ -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 adhoc fallback).
## Build metadata for About
`package-mac-app.sh` stamps the bundle with:

View File

@@ -24,8 +24,12 @@ clawdis nodes approve <requestId>
clawdis nodes reject <requestId>
clawdis nodes status
clawdis nodes describe --node <idOrNameOrIp>
clawdis nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
```
Notes:
- `nodes rename` stores a display name override in the gateway pairing store.
## Invoking commands
Low-level (raw RPC):

View File

@@ -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.

View File

@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -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

View File

@@ -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");
});
});

View File

@@ -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 <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<string, unknown>;
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<NodeListNode[]> {
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>", "Path to JSONL payload")
.option("--text <text>", "Render a quick A2UI text payload")
.option("--node <idOrNameOrIp>", "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)}`);

View File

@@ -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 <idOrNameOrIp>", "Node id, name, or IP")
.requiredOption("--name <displayName>", "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")

View File

@@ -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<NodePairRejectParams>(
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
NodePairVerifyParamsSchema,
);
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(
NodeRenameParamsSchema,
);
export const validateNodeListParams =
ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(

View File

@@ -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<string, TSchema> = {
NodePairApproveParams: NodePairApproveParamsSchema,
NodePairRejectParams: NodePairRejectParamsSchema,
NodePairVerifyParams: NodePairVerifyParamsSchema,
NodeRenameParams: NodeRenameParamsSchema,
NodeListParams: NodeListParamsSchema,
NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema,
@@ -707,6 +713,7 @@ export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
export type NodeRenameParams = Static<typeof NodeRenameParamsSchema>;
export type NodeListParams = Static<typeof NodeListParamsSchema>;
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;

View File

@@ -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<string, unknown>;
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<string, unknown>;
if (!validateNodeListParams(params)) {

View File

@@ -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 = {

View File

@@ -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}`;
}

View File

@@ -292,3 +292,22 @@ export async function updatePairedNodeMetadata(
await persistState(state, baseDir);
});
}
export async function renamePairedNode(
nodeId: string,
displayName: string,
baseDir?: string,
): Promise<NodePairingPairedNode | null> {
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;
});
}