mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
fix: resolve dependency audit findings precisely (#361)
* fix: harden ACP communication boundaries Harden ACP communication boundaries Remote ACP sessions now cannot widen permission mode through untrusted metadata or client payloads. WebSocket ACP ingress measures payloads by bytes before binary decode, and prompt queue handoff keeps exactly one prompt active while queued prompts are drained FIFO. Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent Constraint: WebSocket payload limits must be byte-based and checked before binary decode Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing Confidence: high Scope-risk: moderate Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build Not-tested: Manual live ACP/RCS session against an external client * fix: restore repository verification gates Keep the full repository test, typecheck, build, and Biome lint gates usable after the ACP fix pass. This commit is intentionally separate from the ACP behavior change: it fixes Windows-safe Langfuse home redaction, removes stale lint suppressions, resolves Biome warning/info diagnostics, and keeps env expansion tests explicit without template-placeholder lint noise. Constraint: The project completion contract requires full typecheck, lint, test, and build evidence Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims Confidence: high Scope-risk: narrow Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build Not-tested: Manual Langfuse export against a real external Langfuse service * fix: harden ACP failure boundaries after review Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter. Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed Confidence: high Scope-risk: moderate Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts Tested: bun test packages/acp-link/src/__tests__/server.test.ts Tested: bunx tsc --noEmit Tested: bunx biome lint src/ packages/acp-link/src/ Tested: bun run test:all Tested: bun run build Not-tested: Manual end-to-end ACP client session over a real editor WebSocket * fix: prevent ACP coverage runs from seeing partial mocks GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module. This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure. Constraint: bun:test mock.module is process-global inside a worker. Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution. Rejected: Keep per-file helper copies | duplication would let restore semantics drift again. Confidence: high Scope-risk: narrow Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests. Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts Tested: bun test --coverage --coverage-reporter=lcov Tested: bunx tsc --noEmit Tested: bun run lint Tested: git diff --check Not-tested: Linux runner directly before push * fix: normalize ACP bypass requests without warning noise The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path. This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order. Constraint: CI output should not contain expected warning noise for covered policy branches. Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch. Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site. Confidence: high Scope-risk: narrow Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass. Tested: bun test packages/acp-link/src/__tests__/server.test.ts Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0 Tested: bun run test:all Tested: bun run lint Tested: bunx tsc --noEmit Tested: git diff --check * fix: harden ACP bypass and CI warning gates ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state. Constraint: CI must stay warning-clean without hiding real ACP permission failures Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path Confidence: high Scope-risk: moderate Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts Tested: bun run test:all Tested: bun run lint Tested: bun run build:vite with zero warning matches Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches Not-tested: GitHub Actions result after this push * fix: remove remaining CI warning noise The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics. Constraint: Do not use shell log filtering to hide warnings after they happen Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find Confidence: high Scope-risk: narrow Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects Tested: bunx tsc --noEmit --pretty false Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts Tested: bun run build:vite with zero warning matches Not-tested: GitHub Actions result after this push * fix: reject unauthorized ACP bypass and harden CI actions ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings. CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token. Constraint: Must not hide warnings with test isolation or log filtering Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope Confidence: high Scope-risk: moderate Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bun run test:all Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0 Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR * fix: resolve dependency audit findings precisely Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified. Constraint: no audit ignores or warning suppression Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution Confidence: high Scope-risk: moderate Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks * fix: keep ACP auth tokens out of URLs Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change. Constraint: token must not be embedded in the URL Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs Confidence: high Scope-risk: moderate Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code Tested: targeted ACP/RCS transport tests Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks * fix: normalize WebFetch request headers Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline. Constraint: preserve header semantics without widening the fetch surface Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites Confidence: high Scope-risk: narrow Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically Tested: targeted WebFetch header tests Not-tested: unrelated fetch backend behavior beyond header normalization * fix: harden ACP remote auth surfaces Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist. Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string. Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data. Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries. Confidence: high Scope-risk: moderate Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>. Tested: bunx tsc --noEmit --pretty false Tested: bun run typecheck in packages/remote-control-server Tested: bun run build in packages/acp-link Tested: bun run lint Tested: bun audit Tested: focused RCS/acp-link/web tests, 160 pass Tested: Edge headless browser WebSocket subprotocol handshake Tested: bun run test:all, 3669 pass Tested: bun run build:vite Tested: bun run build Not-tested: Manual end-to-end relay with a live external ACP agent * fix: resolve CI dependency override lookup The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating. Constraint: Do not add a new root dependency for a transitive smoke test. Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync. Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface. Confidence: high Scope-risk: narrow Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting. Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check --------- Co-authored-by: unraid <local@unraid.local>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"openai": "^6.33.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,17 @@ ARGUMENTS
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
||||
By default, a random token is auto-generated on startup. Connect to the
|
||||
WebSocket endpoint without putting the token in the URL:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to
|
||||
disable (not recommended). Clients that cannot send an `Authorization` header
|
||||
must send the token in a WebSocket subprotocol named
|
||||
`rcs.auth.<base64url-token>`.
|
||||
|
||||
## RCS Upstream
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@hono/node-ws": "^1.0.5",
|
||||
"@stricli/auto-complete": "^1.2.4",
|
||||
"@stricli/core": "^1.2.4",
|
||||
"hono": "^4.7.0",
|
||||
"hono": "^4.12.15",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"selfsigned": "^5.5.0"
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { ServerConfig } from "../server.js";
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import {
|
||||
__testing,
|
||||
decodeClientWsMessage,
|
||||
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
||||
resolveNewSessionPermissionMode,
|
||||
type ServerConfig,
|
||||
} from "../server.js";
|
||||
import {
|
||||
authTokensEqual,
|
||||
decodeWebSocketAuthProtocol,
|
||||
encodeWebSocketAuthProtocol,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../ws-auth.js";
|
||||
import { buildRcsWsUrl } from "../rcs-upstream.js";
|
||||
|
||||
function makeTestWs(sent: unknown[]) {
|
||||
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
|
||||
|
||||
return {
|
||||
readyState: 1,
|
||||
send: mock((message: string) => {
|
||||
sent.push(JSON.parse(message));
|
||||
}),
|
||||
close: mock(() => {}),
|
||||
raw: null,
|
||||
isInner: false,
|
||||
url: "",
|
||||
origin: "",
|
||||
protocol: "",
|
||||
} as unknown as TestWs;
|
||||
}
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
@@ -60,6 +90,188 @@ describe("WebSocket message types", () => {
|
||||
expect(clientMessageTypes).toContain("connect");
|
||||
expect(clientMessageTypes).toContain("cancel");
|
||||
});
|
||||
|
||||
test("decodes supported client message payloads", () => {
|
||||
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" });
|
||||
expect(
|
||||
decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')),
|
||||
).toEqual({ type: "prompt", payload: { content: [] } });
|
||||
expect(
|
||||
decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer),
|
||||
).toEqual({ type: "cancel" });
|
||||
expect(
|
||||
decodeClientWsMessage([
|
||||
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
|
||||
Buffer.from('next"}}'),
|
||||
]),
|
||||
).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } });
|
||||
});
|
||||
|
||||
test("rejects malformed typed client payloads", () => {
|
||||
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
|
||||
"Invalid prompt payload",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
|
||||
).toThrow("Invalid load_session payload");
|
||||
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
|
||||
"Unknown message type",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":123}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":{}}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":null}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
});
|
||||
|
||||
test("rejects oversized client message payloads before decoding", () => {
|
||||
const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1);
|
||||
expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket auth protocol", () => {
|
||||
test("round-trips tokens through a WebSocket subprotocol token", () => {
|
||||
const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols");
|
||||
expect(protocol).toStartWith("rcs.auth.");
|
||||
expect(protocol).not.toContain("secret/token");
|
||||
expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols");
|
||||
});
|
||||
|
||||
test("ignores query-token style inputs", () => {
|
||||
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers Authorization headers and supports protocol auth", () => {
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
authorization: "Bearer header-token",
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("header-token");
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("protocol-token");
|
||||
});
|
||||
|
||||
test("compares auth tokens through the shared constant-time path", () => {
|
||||
expect(authTokensEqual("secret-token", "secret-token")).toBe(true);
|
||||
expect(authTokensEqual("secret-token", "wrong-token")).toBe(false);
|
||||
expect(authTokensEqual(undefined, "secret-token")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RCS upstream URL normalization", () => {
|
||||
test("removes legacy token query params from WebSocket URLs", () => {
|
||||
expect(
|
||||
buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"),
|
||||
).toBe("ws://example.test/acp/ws?x=1");
|
||||
});
|
||||
|
||||
test("adds /acp/ws for base URLs", () => {
|
||||
expect(buildRcsWsUrl("https://example.test/")).toBe(
|
||||
"wss://example.test/acp/ws",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission mode resolution", () => {
|
||||
test("uses client requested non-bypass modes", () => {
|
||||
expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan");
|
||||
});
|
||||
|
||||
test("uses local default when client does not request a mode", () => {
|
||||
expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("rejects client requested bypassPermissions without local default", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypass", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", undefined),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
});
|
||||
|
||||
test("rejects unknown client permission modes before forwarding", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"),
|
||||
).toThrow("Invalid permissionMode: unknown-mode");
|
||||
});
|
||||
|
||||
test("allows bypassPermissions when local default already enables it", () => {
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
test("new_session rejects client bypass before forwarding to the agent", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const ws = makeTestWs(sent);
|
||||
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS;
|
||||
process.env.ACP_LINK_TEST_INTERNALS = "1";
|
||||
let unregisterClient = () => {};
|
||||
let restoreMode = () => {};
|
||||
|
||||
try {
|
||||
const newSession = mock(async () => ({
|
||||
sessionId: "should-not-be-created",
|
||||
}));
|
||||
unregisterClient = __testing.registerClient(ws, {
|
||||
connection: { newSession },
|
||||
});
|
||||
restoreMode = __testing.setDefaultPermissionMode("acceptEdits");
|
||||
|
||||
await __testing.dispatchClientMessage(ws, {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: "/tmp",
|
||||
permissionMode: "bypass",
|
||||
},
|
||||
});
|
||||
|
||||
expect(newSession).not.toHaveBeenCalled();
|
||||
expect(__testing.getClientSessionId(ws)).toBeNull();
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "error",
|
||||
payload: {
|
||||
message: expect.stringContaining(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE",
|
||||
),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
restoreMode();
|
||||
unregisterClient();
|
||||
if (originalTestInternals === undefined) {
|
||||
delete process.env.ACP_LINK_TEST_INTERNALS;
|
||||
} else {
|
||||
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat constants", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createLogger } from "./logger.js";
|
||||
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
|
||||
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
|
||||
|
||||
export interface RcsUpstreamConfig {
|
||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||
@@ -9,6 +11,18 @@ export interface RcsUpstreamConfig {
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
export function buildRcsWsUrl(rcsUrl: string): string {
|
||||
let raw = rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
url.searchParams.delete("token");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||
*
|
||||
@@ -87,17 +101,7 @@ export class RcsUpstreamClient {
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
let raw = this.config.rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
if (this.config.apiToken) {
|
||||
url.searchParams.set("token", this.config.apiToken);
|
||||
}
|
||||
return url.toString();
|
||||
return buildRcsWsUrl(this.config.rcsUrl);
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
@@ -121,7 +125,9 @@ export class RcsUpstreamClient {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws = new WebSocket(wsUrl, [
|
||||
encodeWebSocketAuthProtocol(this.config.apiToken),
|
||||
]);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
@@ -136,8 +142,13 @@ export class RcsUpstreamClient {
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
data = decodeJsonWsMessage(event.data);
|
||||
} catch (err) {
|
||||
if (err instanceof WsPayloadTooLargeError) {
|
||||
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
|
||||
this.ws?.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
@@ -152,11 +163,7 @@ export class RcsUpstreamClient {
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
if (this.sessionId) {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||
} else {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
}
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import type { WebSocket as RawWebSocket } from "ws";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
|
||||
import {
|
||||
decodeJsonWsMessage,
|
||||
WsPayloadTooLargeError,
|
||||
} from "./ws-message.js";
|
||||
import { authTokensEqual, extractWebSocketAuthToken } from "./ws-auth.js";
|
||||
|
||||
export { MAX_CLIENT_WS_PAYLOAD_BYTES } from "./ws-message.js";
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
@@ -251,6 +258,7 @@ async function handleConnect(ws: WSContext): Promise<void> {
|
||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||
cwd: AGENT_CWD,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: buildAgentEnv(),
|
||||
});
|
||||
|
||||
state.process = agentProcess;
|
||||
@@ -334,7 +342,16 @@ async function handleNewSession(
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||
let permissionMode: string | undefined;
|
||||
try {
|
||||
permissionMode = resolveNewSessionPermissionMode(
|
||||
params.permissionMode,
|
||||
DEFAULT_PERMISSION_MODE,
|
||||
);
|
||||
} catch (error) {
|
||||
send(ws, "error", { message: (error as Error).message });
|
||||
return;
|
||||
}
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
@@ -590,9 +607,326 @@ interface ContentBlock {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||
type PermissionResponsePayload = {
|
||||
requestId: string;
|
||||
outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string };
|
||||
};
|
||||
|
||||
type ProxyMessage =
|
||||
| { type: "connect" }
|
||||
| { type: "disconnect" }
|
||||
| { type: "new_session"; payload: { cwd?: string; permissionMode?: string } }
|
||||
| { type: "prompt"; payload: { content: ContentBlock[] } }
|
||||
| { type: "permission_response"; payload: PermissionResponsePayload }
|
||||
| { type: "cancel" }
|
||||
| { type: "set_session_model"; payload: { modelId: string } }
|
||||
| { type: "list_sessions"; payload: { cwd?: string; cursor?: string } }
|
||||
| { type: "load_session"; payload: { sessionId: string; cwd?: string } }
|
||||
| { type: "resume_session"; payload: { sessionId: string; cwd?: string } }
|
||||
| { type: "ping" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
source: string,
|
||||
): string | undefined {
|
||||
if (!Object.hasOwn(payload, key)) return undefined;
|
||||
const value = payload[key];
|
||||
if (typeof value === "string") return value;
|
||||
throw new Error(`Invalid ${source}: expected a string`);
|
||||
}
|
||||
|
||||
function payloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`Invalid ${type} payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalPayloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||
if (value === undefined) return {};
|
||||
return payloadRecord(value, type);
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function decodeContentBlocks(value: unknown): ContentBlock[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every(block => isRecord(block) && typeof block.type === "string")
|
||||
) {
|
||||
throw new Error("Invalid prompt payload");
|
||||
}
|
||||
return value as ContentBlock[];
|
||||
}
|
||||
|
||||
function decodePermissionResponsePayload(value: unknown): PermissionResponsePayload {
|
||||
const payload = payloadRecord(value, "permission_response");
|
||||
if (typeof payload.requestId !== "string" || !isRecord(payload.outcome)) {
|
||||
throw new Error("Invalid permission_response payload");
|
||||
}
|
||||
if (payload.outcome.outcome === "cancelled") {
|
||||
return { requestId: payload.requestId, outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
if (
|
||||
payload.outcome.outcome === "selected" &&
|
||||
typeof payload.outcome.optionId === "string"
|
||||
) {
|
||||
return {
|
||||
requestId: payload.requestId,
|
||||
outcome: { outcome: "selected", optionId: payload.outcome.optionId },
|
||||
};
|
||||
}
|
||||
throw new Error("Invalid permission_response payload");
|
||||
}
|
||||
|
||||
function decodeClientMessage(message: Record<string, unknown>): ProxyMessage {
|
||||
if (typeof message.type !== "string") {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "connect":
|
||||
case "disconnect":
|
||||
case "cancel":
|
||||
case "ping":
|
||||
return { type: message.type };
|
||||
case "new_session": {
|
||||
const payload = optionalPayloadRecord(message.payload, "new_session");
|
||||
return {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: optionalStringField(payload, "cwd", "new_session.cwd"),
|
||||
permissionMode: optionalStringField(
|
||||
payload,
|
||||
"permissionMode",
|
||||
"new_session.permissionMode",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "prompt": {
|
||||
const payload = payloadRecord(message.payload, "prompt");
|
||||
return {
|
||||
type: "prompt",
|
||||
payload: { content: decodeContentBlocks(payload.content) },
|
||||
};
|
||||
}
|
||||
case "permission_response":
|
||||
return {
|
||||
type: "permission_response",
|
||||
payload: decodePermissionResponsePayload(message.payload),
|
||||
};
|
||||
case "set_session_model": {
|
||||
const payload = payloadRecord(message.payload, "set_session_model");
|
||||
if (typeof payload.modelId !== "string") {
|
||||
throw new Error("Invalid set_session_model payload");
|
||||
}
|
||||
return { type: "set_session_model", payload: { modelId: payload.modelId } };
|
||||
}
|
||||
case "list_sessions": {
|
||||
const payload = optionalRecord(message.payload);
|
||||
return {
|
||||
type: "list_sessions",
|
||||
payload: {
|
||||
cwd: optionalString(payload.cwd),
|
||||
cursor: optionalString(payload.cursor),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "load_session":
|
||||
case "resume_session": {
|
||||
const payload = payloadRecord(message.payload, message.type);
|
||||
if (typeof payload.sessionId !== "string") {
|
||||
throw new Error(`Invalid ${message.type} payload`);
|
||||
}
|
||||
return {
|
||||
type: message.type,
|
||||
payload: {
|
||||
sessionId: payload.sessionId,
|
||||
cwd: optionalString(payload.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeClientWsMessage(data: unknown): ProxyMessage {
|
||||
return decodeClientMessage(decodeJsonWsMessage(data));
|
||||
}
|
||||
|
||||
async function dispatchClientMessage(ws: WSContext, data: ProxyMessage): Promise<void> {
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, data.payload);
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload);
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload);
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, data.payload);
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload);
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload);
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
dispatchClientMessage(
|
||||
ws: WSContext,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
assertTestingInternalsEnabled();
|
||||
return dispatchClientMessage(ws, data as ProxyMessage);
|
||||
},
|
||||
registerClient(
|
||||
ws: WSContext,
|
||||
state: {
|
||||
connection?: unknown;
|
||||
process?: ChildProcess | null;
|
||||
sessionId?: string | null;
|
||||
},
|
||||
): () => void {
|
||||
assertTestingInternalsEnabled();
|
||||
clients.set(ws, {
|
||||
process: state.process ?? null,
|
||||
connection: (state.connection ?? null) as acp.ClientSideConnection | null,
|
||||
sessionId: state.sessionId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
});
|
||||
return () => {
|
||||
clients.delete(ws);
|
||||
};
|
||||
},
|
||||
getClientSessionId(ws: WSContext): string | null | undefined {
|
||||
assertTestingInternalsEnabled();
|
||||
return clients.get(ws)?.sessionId;
|
||||
},
|
||||
setDefaultPermissionMode(mode: string | undefined): () => void {
|
||||
assertTestingInternalsEnabled();
|
||||
const previous = DEFAULT_PERMISSION_MODE;
|
||||
DEFAULT_PERMISSION_MODE = mode;
|
||||
return () => {
|
||||
DEFAULT_PERMISSION_MODE = previous;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function assertTestingInternalsEnabled(): void {
|
||||
if (process.env.ACP_LINK_TEST_INTERNALS === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"acp-link test internals are disabled outside test execution.",
|
||||
);
|
||||
}
|
||||
|
||||
const ACP_LINK_PERMISSION_MODE_ALIASES = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
plan: "plan",
|
||||
bypasspermissions: "bypassPermissions",
|
||||
bypass: "bypassPermissions",
|
||||
} as const;
|
||||
|
||||
type AcpLinkPermissionMode =
|
||||
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES];
|
||||
|
||||
export function resolveNewSessionPermissionMode(
|
||||
requestedMode: string | undefined,
|
||||
defaultMode: string | undefined,
|
||||
): string | undefined {
|
||||
const requested = resolveAcpLinkPermissionMode(requestedMode);
|
||||
const localDefault = resolveAcpLinkPermissionMode(defaultMode);
|
||||
|
||||
if (!requested) {
|
||||
return localDefault;
|
||||
}
|
||||
|
||||
if (requested !== "bypassPermissions") {
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (localDefault === "bypassPermissions") {
|
||||
return "bypassPermissions";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAcpLinkPermissionMode(
|
||||
mode: string | undefined,
|
||||
): AcpLinkPermissionMode | undefined {
|
||||
if (mode === undefined) return undefined;
|
||||
|
||||
const normalized = mode?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid permissionMode: expected a non-empty string.");
|
||||
}
|
||||
|
||||
const resolved =
|
||||
ACP_LINK_PERMISSION_MODE_ALIASES[
|
||||
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
|
||||
];
|
||||
if (!resolved) {
|
||||
throw new Error(`Invalid permissionMode: ${mode}.`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildAgentEnv(): NodeJS.ProcessEnv {
|
||||
if (!DEFAULT_PERMISSION_MODE) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startServer(config: ServerConfig): Promise<void> {
|
||||
@@ -638,44 +972,9 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
|
||||
rcsUpstream.setMessageHandler(async (msg) => {
|
||||
try {
|
||||
logRelay.debug({ type: msg.type }, "processing");
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
await handleConnect(relayWs);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(relayWs);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(relayWs);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(relayWs, "pong");
|
||||
break;
|
||||
default:
|
||||
logRelay.warn({ type: msg.type }, "unknown message type");
|
||||
}
|
||||
const data = decodeClientMessage(msg);
|
||||
logRelay.debug({ type: data.type }, "processing");
|
||||
await dispatchClientMessage(relayWs, data);
|
||||
} catch (error) {
|
||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||
}
|
||||
@@ -700,9 +999,11 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
"/ws",
|
||||
upgradeWebSocket((c) => {
|
||||
if (AUTH_TOKEN) {
|
||||
const url = new URL(c.req.url);
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken !== AUTH_TOKEN) {
|
||||
const providedToken = extractWebSocketAuthToken({
|
||||
authorization: c.req.header("Authorization"),
|
||||
protocol: c.req.header("Sec-WebSocket-Protocol"),
|
||||
});
|
||||
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
|
||||
logWs.warn("connection rejected: invalid token");
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
@@ -734,63 +1035,31 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
state.isAlive = true;
|
||||
});
|
||||
},
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = JSON.parse(event.data.toString());
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
default:
|
||||
send(ws, "error", { message: `Unknown message type: ${data.type}` });
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = decodeClientWsMessage(event.data);
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
await dispatchClientMessage(ws, data);
|
||||
} catch (error) {
|
||||
if (error instanceof WsPayloadTooLargeError) {
|
||||
logWs.warn({ error: error.message }, "message too large");
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -855,7 +1124,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
console.log(` URL: ${localWsUrl}`);
|
||||
}
|
||||
if (AUTH_TOKEN) {
|
||||
console.log(` Token: ${AUTH_TOKEN}`);
|
||||
console.log(` Token: configured`);
|
||||
}
|
||||
console.log();
|
||||
if (!AUTH_TOKEN) {
|
||||
|
||||
62
packages/acp-link/src/ws-auth.ts
Normal file
62
packages/acp-link/src/ws-auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||
|
||||
function sha256(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||
if (!protocolHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const protocol of protocolHeader.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
return token.length > 0 ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
|
||||
return authorizationHeader?.startsWith("Bearer ")
|
||||
? authorizationHeader.slice("Bearer ".length)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function extractWebSocketAuthToken(headers: {
|
||||
authorization?: string;
|
||||
protocol?: string;
|
||||
}): string | undefined {
|
||||
return extractBearerToken(headers.authorization) ??
|
||||
decodeWebSocketAuthProtocol(headers.protocol);
|
||||
}
|
||||
|
||||
export function authTokensEqual(
|
||||
providedToken: string | undefined,
|
||||
expectedToken: string | undefined,
|
||||
): boolean {
|
||||
if (!providedToken || !expectedToken) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
|
||||
}
|
||||
60
packages/acp-link/src/ws-message.ts
Normal file
60
packages/acp-link/src/ws-message.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export class WsPayloadTooLargeError extends Error {
|
||||
constructor(byteLength: number) {
|
||||
super(`WebSocket message too large: ${byteLength} bytes`);
|
||||
this.name = "WsPayloadTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonWsMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function assertPayloadSize(byteLength: number): void {
|
||||
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
||||
throw new WsPayloadTooLargeError(byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWsText(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
assertPayloadSize(Buffer.byteLength(data, "utf8"));
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(new Uint8Array(data));
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(
|
||||
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
|
||||
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
assertPayloadSize(byteLength);
|
||||
return Buffer.concat(data, byteLength).toString("utf8");
|
||||
}
|
||||
|
||||
throw new Error("Unsupported WebSocket message payload");
|
||||
}
|
||||
|
||||
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
||||
const parsed = JSON.parse(decodeWsText(data)) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("type" in parsed) ||
|
||||
typeof parsed.type !== "string"
|
||||
) {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
return parsed as JsonWsMessage;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
|
||||
type MockAxiosResponse = {
|
||||
data: ArrayBuffer
|
||||
headers: Record<string, unknown>
|
||||
status: number
|
||||
statusText: string
|
||||
}
|
||||
|
||||
type MockAxiosError = Error & {
|
||||
isAxiosError: true
|
||||
response?: {
|
||||
headers: Record<string, unknown>
|
||||
status: number
|
||||
}
|
||||
}
|
||||
|
||||
let getMock: (url: string) => Promise<MockAxiosResponse>
|
||||
|
||||
mock.module('axios', () => {
|
||||
const axiosMock = {
|
||||
get: (url: string) => getMock(url),
|
||||
isAxiosError: (error: unknown): error is MockAxiosError =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { isAxiosError?: unknown }).isAxiosError === true,
|
||||
}
|
||||
|
||||
return { default: axiosMock }
|
||||
})
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/services/api/claude.js', () => ({
|
||||
queryHaiku: async () => ({ message: { content: [] } }),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/http.js', () => ({
|
||||
getWebFetchUserAgent: () => 'TestAgent/1.0',
|
||||
}))
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/utils/mcpOutputStorage.js', () => ({
|
||||
isBinaryContentType: (contentType: string) =>
|
||||
!contentType.toLowerCase().startsWith('text/'),
|
||||
persistBinaryContent: async () => ({
|
||||
filepath: '/tmp/webfetch-test.bin',
|
||||
size: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getInitialSettings: () => ({}),
|
||||
getSettings_DEPRECATED: () => ({ skipWebFetchPreflight: true }),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getMock = async () => ({
|
||||
data: new TextEncoder().encode('hello').buffer,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebFetch response headers', () => {
|
||||
test('reads redirect Location from AxiosHeaders-style get()', async () => {
|
||||
getMock = async () => {
|
||||
const error = new Error('redirect') as MockAxiosError
|
||||
error.isAxiosError = true
|
||||
error.response = {
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === 'location' ? '/next' : undefined,
|
||||
},
|
||||
status: 302,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { getWithPermittedRedirects } = await import('../utils')
|
||||
const result = await getWithPermittedRedirects(
|
||||
'https://example.com/old',
|
||||
new AbortController().signal,
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'redirect',
|
||||
originalUrl: 'https://example.com/old',
|
||||
redirectUrl: 'https://example.com/next',
|
||||
statusCode: 302,
|
||||
})
|
||||
})
|
||||
|
||||
test('reads proxy block markers from normalized headers', async () => {
|
||||
getMock = async () => {
|
||||
const error = new Error('blocked') as MockAxiosError
|
||||
error.isAxiosError = true
|
||||
error.response = {
|
||||
headers: { 'x-proxy-error': 'blocked-by-allowlist' },
|
||||
status: 403,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { getWithPermittedRedirects } = await import('../utils')
|
||||
|
||||
await expect(
|
||||
getWithPermittedRedirects(
|
||||
'https://blocked.example/path',
|
||||
new AbortController().signal,
|
||||
() => false,
|
||||
),
|
||||
).rejects.toThrow('EGRESS_BLOCKED')
|
||||
})
|
||||
|
||||
test('normalizes array content-type before cache and parsing', async () => {
|
||||
getMock = async () => ({
|
||||
data: new TextEncoder().encode('plain body').buffer,
|
||||
headers: { 'content-type': ['text/plain', 'charset=utf-8'] },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const { clearWebFetchCache, getURLMarkdownContent } = await import('../utils')
|
||||
clearWebFetchCache()
|
||||
|
||||
const result = await getURLMarkdownContent(
|
||||
'https://example.com/plain.txt',
|
||||
new AbortController(),
|
||||
)
|
||||
|
||||
expect('type' in result).toBe(false)
|
||||
if ('type' in result) {
|
||||
throw new Error('unexpected redirect result')
|
||||
}
|
||||
expect(result.content).toBe('plain body')
|
||||
expect(result.contentType).toBe('text/plain, charset=utf-8')
|
||||
})
|
||||
})
|
||||
@@ -82,6 +82,34 @@ export function clearWebFetchCache(): void {
|
||||
DOMAIN_CHECK_CACHE.clear()
|
||||
}
|
||||
|
||||
function responseHeaderToString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map(responseHeaderToString)
|
||||
.filter((part): part is string => part !== undefined)
|
||||
return parts.length > 0 ? parts.join(', ') : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getResponseHeader(
|
||||
headers: AxiosResponse<unknown>['headers'],
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const headersWithGet = headers as { get?: (headerName: string) => unknown }
|
||||
if (typeof headersWithGet.get === 'function') {
|
||||
const value = responseHeaderToString(headersWithGet.get(name))
|
||||
if (value !== undefined) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return responseHeaderToString(headers[name.toLowerCase()])
|
||||
}
|
||||
|
||||
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
|
||||
// retained heap) until the first HTML fetch, and reuses one instance across
|
||||
// calls (construction builds 15 rule objects; .turndown() is stateless).
|
||||
@@ -286,7 +314,7 @@ export async function getWithPermittedRedirects(
|
||||
error.response &&
|
||||
[301, 302, 307, 308].includes(error.response.status)
|
||||
) {
|
||||
const redirectLocation = error.response.headers.location
|
||||
const redirectLocation = getResponseHeader(error.response.headers, 'location')
|
||||
if (!redirectLocation) {
|
||||
throw new Error('Redirect missing Location header')
|
||||
}
|
||||
@@ -318,7 +346,8 @@ export async function getWithPermittedRedirects(
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 403 &&
|
||||
error.response.headers['x-proxy-error'] === 'blocked-by-allowlist'
|
||||
getResponseHeader(error.response.headers, 'x-proxy-error') ===
|
||||
'blocked-by-allowlist'
|
||||
) {
|
||||
const hostname = new URL(url).hostname
|
||||
throw new EgressBlockedError(hostname)
|
||||
@@ -430,7 +459,7 @@ export async function getURLMarkdownContent(
|
||||
// This lets GC reclaim up to MAX_HTTP_CONTENT_LENGTH (10MB) before Turndown
|
||||
// builds its DOM tree (which can be 3-5x the HTML size).
|
||||
;(response as { data: unknown }).data = null
|
||||
const contentType = response.headers['content-type'] ?? ''
|
||||
const contentType = getResponseHeader(response.headers, 'content-type') ?? ''
|
||||
|
||||
// Binary content: save raw bytes to disk with a proper extension so Claude
|
||||
// can inspect the file later. We still fall through to the utf-8 decode +
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"ai": "^6.0.168",
|
||||
"hono": "^4.7.0",
|
||||
"hono": "^4.12.15",
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^11.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -51,7 +50,6 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: ["https://dashboard.example"],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
@@ -18,10 +21,23 @@ mock.module("../config", () => ({
|
||||
}));
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { storeReset, storeCreateUser } from "../store";
|
||||
import { apiKeyAuth, sessionIngressAuth, uuidAuth, getUuidFromRequest } from "../auth/middleware";
|
||||
import {
|
||||
apiKeyAuth,
|
||||
encodeWebSocketAuthProtocol,
|
||||
extractWebSocketAuthToken,
|
||||
sessionIngressAuth,
|
||||
uuidAuth,
|
||||
getUuidFromRequest,
|
||||
} from "../auth/middleware";
|
||||
import { issueToken } from "../auth/token";
|
||||
import { generateWorkerJwt } from "../auth/jwt";
|
||||
import {
|
||||
getAllowedWebCorsOrigins,
|
||||
resolveWebCorsOrigin,
|
||||
webCorsOptions,
|
||||
} from "../auth/cors";
|
||||
|
||||
// Helper: create a test app with middleware and a simple handler
|
||||
function createTestApp() {
|
||||
@@ -47,6 +63,10 @@ function createTestApp() {
|
||||
return c.json({ uuid: getUuidFromRequest(c) });
|
||||
});
|
||||
|
||||
app.get("/ws-auth-token", (c) => {
|
||||
return c.json({ token: extractWebSocketAuthToken(c) ?? null });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -103,13 +123,11 @@ describe("Auth Middleware", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("accepts token from query param", async () => {
|
||||
test("rejects session token from query param", async () => {
|
||||
storeCreateUser("dave");
|
||||
const { token } = issueToken("dave");
|
||||
const res = await app.request(`/api-key-test?token=${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("dave");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +147,15 @@ describe("Auth Middleware", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("accepts API key from WebSocket protocol header", async () => {
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: {
|
||||
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("accepts valid JWT with matching session_id", async () => {
|
||||
const jwt = generateWorkerJwt("ses_123", 3600);
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
@@ -161,6 +188,24 @@ describe("Auth Middleware", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractWebSocketAuthToken", () => {
|
||||
test("does not read tokens from query params", async () => {
|
||||
const res = await app.request("/ws-auth-token?token=test-api-key");
|
||||
const body = await res.json();
|
||||
expect(body.token).toBeNull();
|
||||
});
|
||||
|
||||
test("reads tokens from WebSocket protocol header", async () => {
|
||||
const res = await app.request("/ws-auth-token", {
|
||||
headers: {
|
||||
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.token).toBe("test-api-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uuidAuth", () => {
|
||||
test("accepts UUID from query param", async () => {
|
||||
const res = await app.request("/uuid-test?uuid=test-uuid-1");
|
||||
@@ -206,3 +251,45 @@ describe("Auth Middleware", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web CORS", () => {
|
||||
function createCorsApp() {
|
||||
const corsApp = new Hono();
|
||||
corsApp.use("/web/*", cors(webCorsOptions));
|
||||
corsApp.get("/web/ping", (c) => c.text("ok"));
|
||||
return corsApp;
|
||||
}
|
||||
|
||||
test("allows configured origins plus local server origins", () => {
|
||||
expect(getAllowedWebCorsOrigins()).toContain("https://dashboard.example");
|
||||
expect(getAllowedWebCorsOrigins()).toContain("http://localhost:3000");
|
||||
expect(getAllowedWebCorsOrigins()).toContain("http://127.0.0.1:3000");
|
||||
expect(resolveWebCorsOrigin("https://dashboard.example")).toBe(
|
||||
"https://dashboard.example",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unknown origins by default", () => {
|
||||
expect(resolveWebCorsOrigin("https://attacker.example")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not emit CORS allow-origin for unknown web origins", async () => {
|
||||
const res = await createCorsApp().request("/web/ping", {
|
||||
headers: { Origin: "https://attacker.example" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
});
|
||||
|
||||
test("emits CORS allow-origin for configured web origins", async () => {
|
||||
const res = await createCorsApp().request("/web/ping", {
|
||||
headers: { Origin: "https://dashboard.example" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||
"https://dashboard.example",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
@@ -22,12 +25,23 @@ import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSessio
|
||||
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
|
||||
import { issueToken } from "../auth/token";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { encodeWebSocketAuthProtocol } from "../auth/middleware";
|
||||
|
||||
// Import route modules
|
||||
import v1Sessions from "../routes/v1/sessions";
|
||||
import v1Environments from "../routes/v1/environments";
|
||||
import v1EnvironmentsWork from "../routes/v1/environments.work";
|
||||
import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress";
|
||||
import v1SessionIngress, {
|
||||
decodeSessionIngressWsMessage,
|
||||
handleSessionIngressWsPayload,
|
||||
websocket as sessionIngressWebsocket,
|
||||
} from "../routes/v1/session-ingress";
|
||||
import {
|
||||
decodeAcpWsMessageData,
|
||||
hasAcpRelayAuth,
|
||||
handleAcpWsPayload,
|
||||
} from "../routes/acp";
|
||||
import acpRoutes from "../routes/acp";
|
||||
import v2CodeSessions from "../routes/v2/code-sessions";
|
||||
import v2Worker from "../routes/v2/worker";
|
||||
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
|
||||
@@ -51,6 +65,7 @@ function createApp() {
|
||||
app.route("/web", webSessions);
|
||||
app.route("/web", webControl);
|
||||
app.route("/web", webEnvironments);
|
||||
app.route("/acp", acpRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1160,6 +1175,83 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||
expect(events[0]?.type).toBe("assistant");
|
||||
});
|
||||
|
||||
test("GET /v2/session_ingress/ws/:sessionId — accepts small payload into handler", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: {
|
||||
...sessionIngressWebsocket,
|
||||
idleTimeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const event = await new Promise((resolve, reject) => {
|
||||
let ws: WebSocket | undefined;
|
||||
const timeout = setTimeout(() => {
|
||||
ws?.close();
|
||||
reject(new Error("Timed out waiting for inbound WebSocket payload"));
|
||||
}, 2000);
|
||||
const bus = getEventBus(id);
|
||||
const unsub = bus.subscribe((sessionEvent) => {
|
||||
if (sessionEvent.direction === "inbound" && sessionEvent.type === "user") {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
ws?.close();
|
||||
resolve(sessionEvent);
|
||||
}
|
||||
});
|
||||
ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${id}`, [
|
||||
encodeWebSocketAuthProtocol("test-api-key"),
|
||||
]);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "user", message: { role: "user", content: "hello" } }) + "\n");
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
reject(new Error("Session ingress WebSocket connection failed"));
|
||||
};
|
||||
});
|
||||
|
||||
expect((event as { type?: string }).type).toBe("user");
|
||||
} finally {
|
||||
await server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /v2/session_ingress/ws/:sessionId — closes 11MB payload with 1009", () => {
|
||||
const close = mock(() => {});
|
||||
const handled = handleSessionIngressWsPayload(
|
||||
{ close } as any,
|
||||
"session_large",
|
||||
"x".repeat(11 * 1024 * 1024),
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(close).toHaveBeenCalledWith(1009, "message too large");
|
||||
});
|
||||
|
||||
test("session ingress decode rejects unsupported payload types", () => {
|
||||
const close = mock(() => {});
|
||||
const handled = handleSessionIngressWsPayload(
|
||||
{ close } as any,
|
||||
"session_bad",
|
||||
{ data: "bad" },
|
||||
);
|
||||
|
||||
expect(decodeSessionIngressWsMessage({ data: "bad" }).ok).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
expect(close).toHaveBeenCalledWith(1003, "unsupported message payload");
|
||||
});
|
||||
|
||||
test("GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs", async () => {
|
||||
const sessRes = await app.request("/v1/code/sessions", {
|
||||
method: "POST",
|
||||
@@ -1184,7 +1276,9 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||
|
||||
try {
|
||||
const message = await new Promise<string>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}?token=test-api-key`);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}`, [
|
||||
encodeWebSocketAuthProtocol("test-api-key"),
|
||||
]);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for compat WebSocket replay"));
|
||||
@@ -1205,7 +1299,7 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||
});
|
||||
|
||||
expect(message).toContain("\"type\":\"user\"");
|
||||
expect(message).toContain(`\"session_id\":\"${id}\"`);
|
||||
expect(message).toContain(`"session_id":"${id}"`);
|
||||
expect(message).toContain("compat ws replay");
|
||||
} finally {
|
||||
await server.stop(true);
|
||||
@@ -1213,6 +1307,383 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
function createRelayAuthApp() {
|
||||
const authApp = new Hono();
|
||||
authApp.get("/relay-auth", (c) => c.json({ ok: hasAcpRelayAuth(c) }));
|
||||
return authApp;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("GET /acp/agents requires auth", async () => {
|
||||
const res = await app.request("/acp/agents");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/agents rejects UUID-only auth", async () => {
|
||||
const res = await app.request("/acp/agents?uuid=user-1");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/agents accepts API key header", async () => {
|
||||
storeCreateEnvironment({
|
||||
secret: "secret",
|
||||
machineName: "agent-one",
|
||||
workerType: "acp",
|
||||
bridgeId: "group-one",
|
||||
});
|
||||
|
||||
const res = await app.request("/acp/agents", {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].agent_name).toBe("agent-one");
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups requires auth", async () => {
|
||||
const res = await app.request("/acp/channel-groups");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups rejects UUID-only auth", async () => {
|
||||
const res = await app.request("/acp/channel-groups?uuid=user-1");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups accepts API key header", async () => {
|
||||
storeCreateEnvironment({
|
||||
secret: "secret",
|
||||
machineName: "agent-one",
|
||||
workerType: "acp",
|
||||
bridgeId: "group-one",
|
||||
});
|
||||
|
||||
const res = await app.request("/acp/channel-groups", {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].channel_group_id).toBe("group-one");
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id requires auth", async () => {
|
||||
storeCreateEnvironment({
|
||||
secret: "secret",
|
||||
machineName: "agent-one",
|
||||
workerType: "acp",
|
||||
bridgeId: "group-one",
|
||||
});
|
||||
|
||||
const res = await app.request("/acp/channel-groups/group-one");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id rejects query token auth", async () => {
|
||||
storeCreateEnvironment({
|
||||
secret: "secret",
|
||||
machineName: "agent-one",
|
||||
workerType: "acp",
|
||||
bridgeId: "group-one",
|
||||
});
|
||||
|
||||
const res = await app.request("/acp/channel-groups/group-one?token=test-api-key");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id rejects UUID-only auth", async () => {
|
||||
storeCreateEnvironment({
|
||||
secret: "secret",
|
||||
machineName: "agent-one",
|
||||
workerType: "acp",
|
||||
bridgeId: "group-one",
|
||||
});
|
||||
|
||||
const res = await app.request("/acp/channel-groups/group-one?uuid=user-1");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id returns group with API key auth", async () => {
|
||||
storeCreateEnvironment({
|
||||
secret: "secret",
|
||||
machineName: "agent-one",
|
||||
workerType: "acp",
|
||||
bridgeId: "group-one",
|
||||
});
|
||||
|
||||
const res = await app.request("/acp/channel-groups/group-one", {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.channel_group_id).toBe("group-one");
|
||||
expect(body.member_count).toBe(1);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id/events requires auth", async () => {
|
||||
const res = await app.request("/acp/channel-groups/group-one/events");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id/events rejects UUID-only auth", async () => {
|
||||
const res = await app.request("/acp/channel-groups/group-one/events?uuid=user-1");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /acp/channel-groups/:id/events accepts API key header", async () => {
|
||||
const res = await app.request("/acp/channel-groups/group-one/events", {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
|
||||
await res.body?.cancel();
|
||||
});
|
||||
|
||||
test("ACP relay auth rejects UUID-only auth", async () => {
|
||||
const res = await createRelayAuthApp().request("/relay-auth?uuid=user-1");
|
||||
expect(await res.json()).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("ACP relay auth accepts API key header", async () => {
|
||||
const res = await createRelayAuthApp().request("/relay-auth", {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("ACP relay auth accepts WebSocket protocol auth", async () => {
|
||||
const res = await createRelayAuthApp().request("/relay-auth", {
|
||||
headers: {
|
||||
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||
},
|
||||
});
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("ACP WebSocket rejects legacy query-token auth on the real upgrade path", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: {
|
||||
...sessionIngressWebsocket,
|
||||
idleTimeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const close = await new Promise<CloseEvent>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws?token=test-api-key`);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for ACP WebSocket auth rejection"));
|
||||
}, 2000);
|
||||
|
||||
ws.onclose = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("ACP WebSocket query-token test failed before close"));
|
||||
};
|
||||
});
|
||||
|
||||
expect(close.code).toBe(4003);
|
||||
expect(close.reason).toBe("unauthorized");
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP WebSocket accepts subprotocol auth on the real upgrade path", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: {
|
||||
...sessionIngressWebsocket,
|
||||
idleTimeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const message = await new Promise<string>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws`, [
|
||||
encodeWebSocketAuthProtocol("test-api-key"),
|
||||
]);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for ACP WebSocket registration"));
|
||||
}, 2000);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "register", agent_name: "agent-one" }) + "\n");
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
const data = typeof event.data === "string" ? event.data : String(event.data);
|
||||
if (data.includes("\"type\":\"registered\"")) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve(data);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("ACP WebSocket subprotocol auth failed"));
|
||||
};
|
||||
});
|
||||
|
||||
expect(message).toContain("\"agent_id\"");
|
||||
} finally {
|
||||
await server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP relay WebSocket rejects legacy query-token auth on the real upgrade path", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: {
|
||||
...sessionIngressWebsocket,
|
||||
idleTimeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const close = await new Promise<CloseEvent>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/relay/agent_123?token=test-api-key`);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for ACP relay query-token rejection"));
|
||||
}, 2000);
|
||||
|
||||
ws.onclose = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("ACP relay query-token test failed before close"));
|
||||
};
|
||||
});
|
||||
|
||||
expect(close.code).toBe(4003);
|
||||
expect(close.reason).toBe("unauthorized");
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP relay WebSocket accepts subprotocol auth on the real upgrade path", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: {
|
||||
...sessionIngressWebsocket,
|
||||
idleTimeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const close = await new Promise<CloseEvent>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/relay/agent_123`, [
|
||||
encodeWebSocketAuthProtocol("test-api-key"),
|
||||
]);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for ACP relay authenticated close"));
|
||||
}, 2000);
|
||||
|
||||
ws.onclose = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("ACP relay subprotocol auth failed before close"));
|
||||
};
|
||||
});
|
||||
|
||||
expect(close.code).toBe(4004);
|
||||
expect(close.reason).toBe("agent not found");
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("ACP WebSocket payload guards", () => {
|
||||
test("rejects oversized multibyte text by byte size", () => {
|
||||
const close = mock(() => {});
|
||||
const handleMessage = mock(() => {});
|
||||
const payload = "你".repeat(4 * 1024 * 1024);
|
||||
const decoded = decodeAcpWsMessageData(payload);
|
||||
const handled = handleAcpWsPayload(
|
||||
{ close } as any,
|
||||
"[ACP-WS]",
|
||||
"wsId=multibyte",
|
||||
payload,
|
||||
handleMessage,
|
||||
);
|
||||
|
||||
expect(decoded.ok && decoded.size).toBeGreaterThan(10 * 1024 * 1024);
|
||||
expect(handled).toBe(false);
|
||||
expect(handleMessage).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledWith(1009, "message too large");
|
||||
});
|
||||
|
||||
test("rejects oversized binary payload by byte size", () => {
|
||||
const close = mock(() => {});
|
||||
const handleMessage = mock(() => {});
|
||||
const payload = new Uint8Array(11 * 1024 * 1024);
|
||||
const decoded = decodeAcpWsMessageData(payload);
|
||||
const handled = handleAcpWsPayload(
|
||||
{ close } as any,
|
||||
"[ACP-Relay]",
|
||||
"relayWsId=binary",
|
||||
payload,
|
||||
handleMessage,
|
||||
);
|
||||
|
||||
expect(decoded).toEqual({
|
||||
ok: false,
|
||||
reason: "message too large",
|
||||
size: 11 * 1024 * 1024,
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(handleMessage).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledWith(1009, "message too large");
|
||||
});
|
||||
|
||||
test("accepts small payload into ACP handler", () => {
|
||||
const close = mock(() => {});
|
||||
const handleMessage = mock(() => {});
|
||||
const handled = handleAcpWsPayload(
|
||||
{ close } as any,
|
||||
"[ACP-WS]",
|
||||
"wsId=small",
|
||||
'{"type":"keep_alive"}',
|
||||
handleMessage,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(handleMessage).toHaveBeenCalledWith('{"type":"keep_alive"}');
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 Worker Events Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { config } from "../config";
|
||||
|
||||
function sha256(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
/** Validate a raw API key token string */
|
||||
export function validateApiKey(token: string | undefined): boolean {
|
||||
if (!token) return false;
|
||||
return config.apiKeys.includes(token);
|
||||
const tokenHash = sha256(token);
|
||||
return config.apiKeys.some((key) => timingSafeEqual(tokenHash, sha256(key)));
|
||||
}
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
|
||||
34
packages/remote-control-server/src/auth/cors.ts
Normal file
34
packages/remote-control-server/src/auth/cors.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { config } from "../config";
|
||||
|
||||
function originFromUrl(rawUrl: string): string | undefined {
|
||||
try {
|
||||
return new URL(rawUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllowedWebCorsOrigins(): string[] {
|
||||
const origins = new Set<string>(config.webCorsOrigins);
|
||||
|
||||
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined;
|
||||
if (baseOrigin) {
|
||||
origins.add(baseOrigin);
|
||||
}
|
||||
|
||||
origins.add(`http://localhost:${config.port}`);
|
||||
origins.add(`http://127.0.0.1:${config.port}`);
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
export function resolveWebCorsOrigin(origin: string): string | undefined {
|
||||
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined;
|
||||
}
|
||||
|
||||
export const webCorsOptions = {
|
||||
origin: resolveWebCorsOrigin,
|
||||
allowHeaders: ["Authorization", "Content-Type", "X-UUID"],
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
credentials: false,
|
||||
};
|
||||
@@ -3,11 +3,49 @@ import { validateApiKey } from "./api-key";
|
||||
import { verifyWorkerJwt } from "./jwt";
|
||||
import { resolveToken } from "./token";
|
||||
|
||||
/** Extract Bearer token from Authorization header or ?token= query param */
|
||||
function extractBearerToken(c: Context): string | undefined {
|
||||
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||
|
||||
/** Encode a bearer token for WebSocket clients that cannot send auth headers. */
|
||||
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||
if (!protocolHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const protocol of protocolHeader.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
return token.length > 0 ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Extract a Bearer token from the Authorization header only. */
|
||||
export function extractBearerToken(c: Context): string | undefined {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
return authHeader?.replace("Bearer ", "") || queryToken;
|
||||
return authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
|
||||
}
|
||||
|
||||
/** Extract auth for WebSocket upgrades without putting secrets in query strings. */
|
||||
export function extractWebSocketAuthToken(c: Context): string | undefined {
|
||||
return extractBearerToken(c) ?? decodeWebSocketAuthProtocol(c.req.header("Sec-WebSocket-Protocol"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +87,7 @@ export async function apiKeyAuth(c: Context, next: Next) {
|
||||
* downstream handlers to inspect session_id if needed.
|
||||
*/
|
||||
export async function sessionIngressAuth(c: Context, next: Next) {
|
||||
const token = extractBearerToken(c);
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
||||
|
||||
@@ -8,6 +8,10 @@ export const config = {
|
||||
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
||||
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
||||
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
|
||||
webCorsOrigins: (process.env.RCS_WEB_CORS_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean),
|
||||
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
|
||||
* this many seconds of no received data. Must be shorter than any reverse
|
||||
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import acpRoutes from "./routes/acp";
|
||||
import { webCorsOptions } from "./auth/cors";
|
||||
|
||||
// Routes
|
||||
import v1Environments from "./routes/v1/environments";
|
||||
@@ -44,7 +45,7 @@ app.use("*", async (c, next) => {
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.use("/web/*", cors());
|
||||
app.use("/web/*", cors(webCorsOptions));
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Context } from "hono";
|
||||
import type { WSContext, WSMessageReceive } from "hono/ws";
|
||||
import { upgradeWebSocket } from "../../transport/ws-shared";
|
||||
import { apiKeyAuth } from "../../auth/middleware";
|
||||
import {
|
||||
decodeWsPayload,
|
||||
handleSizedWsPayload,
|
||||
} from "../../transport/ws-payload";
|
||||
import {
|
||||
extractBearerToken,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../../auth/middleware";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import {
|
||||
handleAcpWsOpen,
|
||||
@@ -22,8 +32,14 @@ import { log, error as logError } from "../../logger";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** Maximum WebSocket message size: 10 MB */
|
||||
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
||||
type WsMessageEvent = {
|
||||
data: WSMessageReceive;
|
||||
};
|
||||
|
||||
type WsCloseEvent = {
|
||||
code?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/** Response shape for an ACP agent */
|
||||
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||
@@ -39,28 +55,33 @@ function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
|
||||
function hasAcpReadAuth(c: Context): boolean {
|
||||
const token = extractBearerToken(c);
|
||||
return !!token && validateApiKey(token);
|
||||
}
|
||||
|
||||
export function hasAcpRelayAuth(c: Context): boolean {
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
return !!token && validateApiKey(token);
|
||||
}
|
||||
|
||||
function acpReadUnauthorized(c: Context) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||
}
|
||||
|
||||
/** GET /acp/agents — List all registered ACP agents (API key auth) */
|
||||
app.get("/agents", async (c) => {
|
||||
// Require at least UUID auth
|
||||
const uuid = c.req.query("uuid");
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
if (!uuid && !(token && validateApiKey(token))) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
|
||||
});
|
||||
|
||||
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
|
||||
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
|
||||
app.get("/channel-groups", async (c) => {
|
||||
const uuid = c.req.query("uuid");
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
if (!uuid && !(token && validateApiKey(token))) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
const groupMap = new Map<string, typeof agents>();
|
||||
@@ -79,8 +100,12 @@ app.get("/channel-groups", async (c) => {
|
||||
return c.json(groups);
|
||||
});
|
||||
|
||||
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
|
||||
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
|
||||
app.get("/channel-groups/:id", async (c) => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
}
|
||||
|
||||
const groupId = c.req.param("id")!;
|
||||
const members = storeListAcpAgentsByChannelGroup(groupId);
|
||||
if (members.length === 0) {
|
||||
@@ -93,14 +118,18 @@ app.get("/channel-groups/:id", async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
|
||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
|
||||
app.get("/channel-groups/:id/events", async (c) => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
}
|
||||
|
||||
const groupId = c.req.param("id")!;
|
||||
|
||||
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||
const lastEventId = c.req.header("Last-Event-ID");
|
||||
const fromSeq = c.req.query("from_sequence_num");
|
||||
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
||||
const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
|
||||
|
||||
return createAcpSSEStream(c, groupId, fromSeqNum);
|
||||
});
|
||||
@@ -109,46 +138,38 @@ app.get("/channel-groups/:id/events", async (c) => {
|
||||
app.get(
|
||||
"/ws",
|
||||
upgradeWebSocket(async (c) => {
|
||||
// Authenticate via API key in query param or header
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
|
||||
if (!token || !validateApiKey(token)) {
|
||||
log("[ACP-WS] Upgrade rejected: unauthorized");
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate unique wsId for this connection
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
|
||||
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
|
||||
|
||||
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleAcpWsOpen(ws, wsId);
|
||||
},
|
||||
onMessage(evt: any, ws: any) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
if (data.length > MAX_WS_MESSAGE_SIZE) {
|
||||
logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`);
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
handleAcpWsMessage(ws, wsId, data);
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleAcpWsPayload(
|
||||
ws,
|
||||
"[ACP-WS]",
|
||||
`wsId=${wsId}`,
|
||||
evt.data,
|
||||
data => handleAcpWsMessage(ws, wsId, data),
|
||||
);
|
||||
},
|
||||
onClose(evt: any, ws: any) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
|
||||
},
|
||||
onError(evt: any, ws: any) {
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
|
||||
handleAcpWsClose(ws, wsId, 1006, "websocket error");
|
||||
},
|
||||
@@ -160,50 +181,36 @@ app.get(
|
||||
app.get(
|
||||
"/relay/:agentId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
// Authenticate via UUID (web frontend) or API key (legacy)
|
||||
const clientUuid = c.req.query("uuid");
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
|
||||
const hasUuid = !!clientUuid;
|
||||
const hasApiKey = !!token && validateApiKey(token);
|
||||
|
||||
if (!hasUuid && !hasApiKey) {
|
||||
if (!hasAcpRelayAuth(c)) {
|
||||
log("[ACP-Relay] Upgrade rejected: unauthorized");
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const agentId = c.req.param("agentId")!;
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
|
||||
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
|
||||
|
||||
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleRelayOpen(ws, relayWsId, agentId);
|
||||
},
|
||||
onMessage(evt: any, ws: any) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
if (data.length > MAX_WS_MESSAGE_SIZE) {
|
||||
logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`);
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
handleRelayMessage(ws, relayWsId, data);
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleAcpWsPayload(
|
||||
ws,
|
||||
"[ACP-Relay]",
|
||||
`relayWsId=${relayWsId}`,
|
||||
evt.data,
|
||||
data => handleRelayMessage(ws, relayWsId, data),
|
||||
);
|
||||
},
|
||||
onClose(evt: any, ws: any) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
|
||||
},
|
||||
onError(evt: any, ws: any) {
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
|
||||
handleRelayClose(ws, relayWsId, 1006, "websocket error");
|
||||
},
|
||||
@@ -211,4 +218,16 @@ app.get(
|
||||
}),
|
||||
);
|
||||
|
||||
export const decodeAcpWsMessageData = decodeWsPayload;
|
||||
|
||||
export function handleAcpWsPayload(
|
||||
ws: WSContext,
|
||||
logPrefix: string,
|
||||
label: string,
|
||||
payload: unknown,
|
||||
handleMessage: (data: string) => void,
|
||||
): boolean {
|
||||
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage);
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import type { WSContext, WSMessageReceive } from "hono/ws";
|
||||
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
|
||||
import {
|
||||
decodeWsPayload,
|
||||
handleSizedWsPayload,
|
||||
} from "../../transport/ws-payload";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||
import { extractWebSocketAuthToken } from "../../auth/middleware";
|
||||
import {
|
||||
handleWebSocketOpen,
|
||||
handleWebSocketMessage,
|
||||
@@ -13,11 +20,18 @@ import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
||||
function authenticateRequest(c: any, label: string, expectedSessionId?: string): boolean {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
type WsMessageEvent = {
|
||||
data: WSMessageReceive;
|
||||
};
|
||||
|
||||
type WsCloseEvent = {
|
||||
code?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/** Authenticate via API key or worker JWT without accepting URL query secrets. */
|
||||
function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean {
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
|
||||
// Try API key first
|
||||
if (validateApiKey(token)) {
|
||||
@@ -76,7 +90,7 @@ app.get(
|
||||
|
||||
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
@@ -86,7 +100,7 @@ app.get(
|
||||
if (!session) {
|
||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4001, "session not found");
|
||||
},
|
||||
};
|
||||
@@ -94,27 +108,38 @@ app.get(
|
||||
|
||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
handleWebSocketOpen(ws as any, sessionId);
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleWebSocketOpen(ws, sessionId);
|
||||
},
|
||||
onMessage(evt, ws) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
handleWebSocketMessage(ws as any, sessionId, data);
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleSessionIngressWsPayload(ws, sessionId, evt.data);
|
||||
},
|
||||
onClose(evt, ws) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
|
||||
},
|
||||
onError(evt, ws) {
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
||||
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
||||
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export const decodeSessionIngressWsMessage = decodeWsPayload;
|
||||
|
||||
export function handleSessionIngressWsPayload(
|
||||
ws: WSContext,
|
||||
sessionId: string,
|
||||
payload: unknown,
|
||||
): boolean {
|
||||
return handleSizedWsPayload(
|
||||
ws,
|
||||
"[WS]",
|
||||
`session=${sessionId}`,
|
||||
payload,
|
||||
data => handleWebSocketMessage(ws, sessionId, data),
|
||||
);
|
||||
}
|
||||
|
||||
export { websocket };
|
||||
export default app;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import {
|
||||
automationStatesEqual,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -57,7 +57,7 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
|
||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||
getEventBus(sessionId).publish({
|
||||
id: uuid(),
|
||||
id: randomUUID(),
|
||||
sessionId,
|
||||
type: "automation_state",
|
||||
payload: nextAutomationState,
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
storeListSessionsByEnvironment,
|
||||
storeListSessionsByOwnerUuid,
|
||||
} from "../store";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getAllEventBuses, removeEventBus } from "../transport/event-bus";
|
||||
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const CODE_SESSION_PREFIX = "cse_";
|
||||
const WEB_SESSION_PREFIX = "session_";
|
||||
@@ -145,7 +145,7 @@ export function updateSessionStatus(sessionId: string, status: string) {
|
||||
if (!bus) return;
|
||||
|
||||
bus.publish({
|
||||
id: uuid(),
|
||||
id: randomUUID(),
|
||||
sessionId,
|
||||
type: "session_status",
|
||||
payload: { status },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getEventBus } from "../transport/event-bus";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
/**
|
||||
* Extract plain text from various message payload formats.
|
||||
@@ -88,7 +88,7 @@ export function publishSessionEvent(
|
||||
direction: "inbound" | "outbound",
|
||||
) {
|
||||
const bus = getEventBus(sessionId);
|
||||
const eventId = uuid();
|
||||
const eventId = randomUUID();
|
||||
|
||||
const normalized = normalizePayload(type, payload);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
@@ -110,7 +110,7 @@ export function storeCreateEnvironment(req: {
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}): EnvironmentRecord {
|
||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
||||
const id = `env_${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: EnvironmentRecord = {
|
||||
id,
|
||||
@@ -162,7 +162,7 @@ export function storeCreateSession(req: {
|
||||
idPrefix?: string;
|
||||
username?: string | null;
|
||||
}): SessionRecord {
|
||||
const id = `${req.idPrefix || "session_"}${uuid().replace(/-/g, "")}`;
|
||||
const id = `${req.idPrefix || "session_"}${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: SessionRecord = {
|
||||
id,
|
||||
@@ -317,7 +317,7 @@ export function storeCreateWorkItem(req: {
|
||||
sessionId: string;
|
||||
secret: string;
|
||||
}): WorkItemRecord {
|
||||
const id = `work_${uuid().replace(/-/g, "")}`;
|
||||
const id = `work_${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: WorkItemRecord = {
|
||||
id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import {
|
||||
@@ -86,7 +86,7 @@ function handleRegister(wsId: string, msg: Record<string, unknown>): void {
|
||||
|
||||
const agentName = (msg.agent_name as string) || "unknown";
|
||||
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
|
||||
const channelGroupId = (msg.channel_group_id as string) || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
|
||||
const channelGroupId = (msg.channel_group_id as string) || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const acpLinkVersion = (msg.acp_link_version as string) || null;
|
||||
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
|
||||
|
||||
@@ -154,7 +154,7 @@ function handleIdentify(wsId: string, msg: Record<string, unknown>): void {
|
||||
// Update status to active
|
||||
storeMarkAcpAgentOnline(agentId);
|
||||
|
||||
const channelGroupId = record.bridgeId || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
|
||||
const channelGroupId = record.bridgeId || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
||||
entry.agentId = record.id;
|
||||
entry.channelGroupId = channelGroupId;
|
||||
@@ -227,7 +227,7 @@ export function handleAcpWsMessage(ws: WSContext, wsId: string, data: string): v
|
||||
// Pass-through: publish to channel group EventBus as inbound
|
||||
const bus = getAcpEventBus(entry.channelGroupId);
|
||||
bus.publish({
|
||||
id: uuid(),
|
||||
id: randomUUID(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: (msg.type as string) || "acp_message",
|
||||
payload: msg,
|
||||
@@ -259,7 +259,7 @@ export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, rea
|
||||
if (entry.channelGroupId) {
|
||||
const bus = getAcpEventBus(entry.channelGroupId);
|
||||
bus.publish({
|
||||
id: uuid(),
|
||||
id: randomUUID(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: "agent_disconnect",
|
||||
payload: { agentId: entry.agentId },
|
||||
|
||||
64
packages/remote-control-server/src/transport/ws-payload.ts
Normal file
64
packages/remote-control-server/src/transport/ws-payload.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { error as logError } from "../logger";
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
export type DecodedWsMessage =
|
||||
| { ok: true; data: string; size: number }
|
||||
| { ok: false; reason: string; size?: number };
|
||||
|
||||
export function decodeWsPayload(data: unknown): DecodedWsMessage {
|
||||
if (typeof data === "string") {
|
||||
return { ok: true, data, size: Buffer.byteLength(data, "utf8") };
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||
return { ok: false, reason: "message too large", size: data.byteLength };
|
||||
}
|
||||
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||
return { ok: false, reason: "message too large", size: data.byteLength };
|
||||
}
|
||||
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
|
||||
}
|
||||
if (typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer) {
|
||||
const bytes = new Uint8Array(data);
|
||||
if (bytes.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||
return { ok: false, reason: "message too large", size: bytes.byteLength };
|
||||
}
|
||||
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength };
|
||||
}
|
||||
return { ok: false, reason: typeof data };
|
||||
}
|
||||
|
||||
export function handleSizedWsPayload(
|
||||
ws: WSContext,
|
||||
logPrefix: string,
|
||||
label: string,
|
||||
payload: unknown,
|
||||
handleMessage: (data: string) => void,
|
||||
): boolean {
|
||||
const decoded = decodeWsPayload(payload);
|
||||
if (!decoded.ok) {
|
||||
if (decoded.reason === "message too large" && decoded.size !== undefined) {
|
||||
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
|
||||
ws.close(1009, "message too large");
|
||||
return false;
|
||||
}
|
||||
logError(`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`);
|
||||
ws.close(1003, "unsupported message payload");
|
||||
return false;
|
||||
}
|
||||
if (decoded.size > MAX_WS_MESSAGE_SIZE) {
|
||||
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
|
||||
ws.close(1009, "message too large");
|
||||
return false;
|
||||
}
|
||||
handleMessage(decoded.data);
|
||||
return true;
|
||||
}
|
||||
@@ -14,23 +14,25 @@ import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult
|
||||
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
||||
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
||||
|
||||
// Get token from URL query param (for pre-filled URLs from server)
|
||||
// Get token from the URL fragment so it is not sent in HTTP requests.
|
||||
function getTokenFromUrl(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("token") || undefined;
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
return hashParams.get("token") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Infer WebSocket URL from current page URL (for pre-filled links from server)
|
||||
// e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws
|
||||
// e.g., http://localhost:9315/app#token=xxx -> ws://localhost:9315/ws
|
||||
function inferProxyUrlFromPage(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
// Only infer if we have a token param (indicates user came from server-printed URL)
|
||||
if (!url.searchParams.has("token")) {
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
// Only infer if we have a fragment token (indicates user came from server-printed URL)
|
||||
if (!hashParams.has("token")) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -40,6 +42,23 @@ function inferProxyUrlFromPage(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function scrubTokenFromUrl(): void {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
if (!hashParams.has("token")) {
|
||||
return;
|
||||
}
|
||||
|
||||
hashParams.delete("token");
|
||||
const nextHash = hashParams.toString();
|
||||
url.hash = nextHash ? `#${nextHash}` : "";
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get initial settings from defaults, with optional URL overrides
|
||||
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
|
||||
const settings = { ...DEFAULT_SETTINGS };
|
||||
@@ -119,6 +138,12 @@ export function ACPConnect({
|
||||
onError: handleQRError,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (inferFromUrl) {
|
||||
scrubTokenFromUrl();
|
||||
}
|
||||
}, [inferFromUrl]);
|
||||
|
||||
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
|
||||
useLayoutEffect(() => {
|
||||
if (expanded && contentRef.current) {
|
||||
|
||||
@@ -28,6 +28,7 @@ beforeEach(() => {
|
||||
fetchMock.lastOpts = {};
|
||||
fetchMock.response = { ok: true, status: 200, statusText: "OK" };
|
||||
fetchMock.responseData = {};
|
||||
client.setActiveApiToken(null);
|
||||
});
|
||||
|
||||
(globalThis as any).fetch = async (url: string, opts: RequestInit) => {
|
||||
@@ -41,15 +42,11 @@ beforeEach(() => {
|
||||
} as Response;
|
||||
};
|
||||
|
||||
// Mock crypto.randomUUID
|
||||
(globalThis as any).crypto = {
|
||||
randomUUID: () => "test-uuid-12345678",
|
||||
};
|
||||
|
||||
const { getUuid, setUuid } = await import("../api/client");
|
||||
|
||||
// Import api* functions - they depend on getUuid and fetch
|
||||
const client = await import("../api/client");
|
||||
const relayClient = await import("../acp/relay-client");
|
||||
|
||||
// =============================================================================
|
||||
// getUuid()
|
||||
@@ -63,8 +60,10 @@ describe("getUuid", () => {
|
||||
|
||||
test("generates and stores new UUID when none exists", () => {
|
||||
const uuid = getUuid();
|
||||
expect(uuid).toBe("test-uuid-12345678");
|
||||
expect(store["rcs_uuid"]).toBe("test-uuid-12345678");
|
||||
expect(uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(store["rcs_uuid"]).toBe(uuid);
|
||||
});
|
||||
|
||||
test("returns same UUID on subsequent calls", () => {
|
||||
@@ -127,6 +126,21 @@ describe("api functions", () => {
|
||||
expect(fetchMock.lastOpts.headers).toEqual({ "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
test("active API token is sent only in Authorization header", async () => {
|
||||
store["rcs_uuid"] = "browser-uuid";
|
||||
fetchMock.responseData = [];
|
||||
client.setActiveApiToken("secret-token");
|
||||
|
||||
await client.apiFetchSessions();
|
||||
|
||||
expect(fetchMock.lastUrl).toContain("uuid=browser-uuid");
|
||||
expect(fetchMock.lastUrl).not.toContain("secret-token");
|
||||
expect(fetchMock.lastOpts.headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer secret-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error on non-ok response", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.response = { ok: false, status: 401, statusText: "Unauthorized" };
|
||||
@@ -141,3 +155,18 @@ describe("api functions", () => {
|
||||
await expect(client.apiFetchSessions()).rejects.toThrow("Internal Server Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP relay client", () => {
|
||||
test("builds relay URLs without UUID or token query params", () => {
|
||||
(globalThis as any).window = {
|
||||
location: {
|
||||
protocol: "https:",
|
||||
host: "rcs.example.test",
|
||||
},
|
||||
};
|
||||
|
||||
expect(relayClient.buildRelayUrl("agent_123")).toBe(
|
||||
"wss://rcs.example.test/acp/relay/agent_123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { afterEach, describe, test, expect } from "bun:test";
|
||||
|
||||
const {
|
||||
formatTime,
|
||||
@@ -10,6 +10,33 @@ const {
|
||||
isConversationClearedStatus,
|
||||
} = await import("../lib/utils");
|
||||
|
||||
type UuidCrypto = {
|
||||
randomUUID?: () => string;
|
||||
getRandomValues?: (array: Uint8Array) => Uint8Array;
|
||||
};
|
||||
|
||||
const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
|
||||
|
||||
function setCryptoForTest(value: UuidCrypto): void {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreCryptoForTest(): void {
|
||||
if (originalCryptoDescriptor) {
|
||||
Object.defineProperty(globalThis, "crypto", originalCryptoDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "crypto");
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreCryptoForTest();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// formatTime()
|
||||
// =============================================================================
|
||||
@@ -122,10 +149,42 @@ describe("truncate", () => {
|
||||
// =============================================================================
|
||||
|
||||
describe("generateMessageUuid", () => {
|
||||
test("returns a non-empty string", () => {
|
||||
test("returns an RFC 4122 v4 UUID", () => {
|
||||
const uuid = generateMessageUuid();
|
||||
expect(typeof uuid).toBe("string");
|
||||
expect(uuid.length).toBeGreaterThan(0);
|
||||
expect(uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses crypto.randomUUID when available", () => {
|
||||
setCryptoForTest({
|
||||
randomUUID: () => "11111111-1111-4111-8111-111111111111",
|
||||
getRandomValues: () => {
|
||||
throw new Error("getRandomValues should not be called");
|
||||
},
|
||||
});
|
||||
|
||||
expect(generateMessageUuid()).toBe("11111111-1111-4111-8111-111111111111");
|
||||
});
|
||||
|
||||
test("uses crypto.getRandomValues when randomUUID is unavailable", () => {
|
||||
setCryptoForTest({
|
||||
getRandomValues: (array) => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i;
|
||||
}
|
||||
return array;
|
||||
},
|
||||
});
|
||||
|
||||
expect(generateMessageUuid()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
|
||||
});
|
||||
|
||||
test("throws when no secure random source is available", () => {
|
||||
setCryptoForTest({});
|
||||
|
||||
expect(() => generateMessageUuid()).toThrow("crypto.getRandomValues is required");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,19 @@ import type {
|
||||
AvailableCommand,
|
||||
} from "./types";
|
||||
|
||||
function encodeWebSocketAuthProtocol(token: string): string {
|
||||
const bytes = new TextEncoder().encode(token);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
return `rcs.auth.${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when disconnect() is called while a connection is in progress.
|
||||
* Callers can use `instanceof` to distinguish this from real connection errors.
|
||||
@@ -276,14 +289,12 @@ export class ACPClient {
|
||||
this.connectReject = reject;
|
||||
|
||||
try {
|
||||
// Build WebSocket URL with token if provided
|
||||
let wsUrl = this.settings.proxyUrl;
|
||||
if (this.settings.token) {
|
||||
const url = new URL(wsUrl);
|
||||
url.searchParams.set("token", this.settings.token);
|
||||
wsUrl = url.toString();
|
||||
}
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const ws = new WebSocket(
|
||||
this.settings.proxyUrl,
|
||||
this.settings.token
|
||||
? [encodeWebSocketAuthProtocol(this.settings.token)]
|
||||
: undefined,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ACPClient } from "./client";
|
||||
import type { ACPSettings } from "./types";
|
||||
import { getUuid } from "../api/client";
|
||||
import { getActiveApiToken } from "../api/client";
|
||||
|
||||
/**
|
||||
* Build the RCS relay WebSocket URL for a given agent.
|
||||
@@ -8,8 +8,7 @@ import { getUuid } from "../api/client";
|
||||
*/
|
||||
export function buildRelayUrl(agentId: string): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const uuid = getUuid();
|
||||
return `${protocol}//${window.location.host}/acp/relay/${agentId}?uuid=${encodeURIComponent(uuid)}`;
|
||||
return `${protocol}//${window.location.host}/acp/relay/${agentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,6 +18,9 @@ export function buildRelayUrl(agentId: string): string {
|
||||
*/
|
||||
export function createRelayClient(agentId: string): ACPClient {
|
||||
const relayUrl = buildRelayUrl(agentId);
|
||||
const settings: ACPSettings = { proxyUrl: relayUrl };
|
||||
const token = getActiveApiToken();
|
||||
const settings: ACPSettings = token
|
||||
? { proxyUrl: relayUrl, token }
|
||||
: { proxyUrl: relayUrl };
|
||||
return new ACPClient(settings);
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ export interface SessionModelState {
|
||||
// Settings
|
||||
export interface ACPSettings {
|
||||
proxyUrl: string;
|
||||
/** Auth token for remote access (passed as ?token=xxx query param) */
|
||||
/** Auth token for remote access (sent via WebSocket subprotocol) */
|
||||
token?: string;
|
||||
/** Working directory for the agent session */
|
||||
cwd?: string;
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import type { Session, Environment, ControlResponse, SessionEvent } from "../types";
|
||||
import { generateMessageUuid } from "../lib/utils";
|
||||
|
||||
const BASE = "";
|
||||
|
||||
function generateUuid(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||
(Number(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (Number(c) / 4)))).toString(16),
|
||||
);
|
||||
}
|
||||
|
||||
export function getUuid(): string {
|
||||
let uuid = localStorage.getItem("rcs_uuid");
|
||||
if (!uuid) {
|
||||
uuid = generateUuid();
|
||||
uuid = generateMessageUuid();
|
||||
localStorage.setItem("rcs_uuid", uuid);
|
||||
}
|
||||
return uuid;
|
||||
@@ -42,17 +34,9 @@ async function api<T>(method: string, path: string, body?: unknown): Promise<T>
|
||||
headers["Authorization"] = `Bearer ${_activeToken}`;
|
||||
}
|
||||
|
||||
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
|
||||
// Otherwise fall back to UUID auth via query param.
|
||||
let url: string;
|
||||
if (_activeToken) {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
|
||||
} else {
|
||||
const uuid = getUuid();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||
}
|
||||
const uuid = getUuid();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||
const opts: RequestInit = { method, headers };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SetStateAction } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
apiFetchSession,
|
||||
apiFetchSessionHistory,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
apiInterrupt,
|
||||
getUuid,
|
||||
} from "../api/client";
|
||||
import { generateMessageUuid } from "./utils";
|
||||
import type { SessionEvent, EventPayload } from "../types";
|
||||
import type {
|
||||
ThreadEntry,
|
||||
@@ -422,7 +422,7 @@ export class RCSChatAdapter {
|
||||
// Send to backend
|
||||
await apiSendEvent(this.sessionId, {
|
||||
type: "user",
|
||||
uuid: uuidv4(),
|
||||
uuid: generateMessageUuid(),
|
||||
content: text,
|
||||
message: { content: text },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getUuid } from "../api/client";
|
||||
import { generateMessageUuid } from "./utils";
|
||||
import type { SessionEvent, EventPayload } from "../types";
|
||||
|
||||
// ============================================================
|
||||
@@ -113,7 +113,7 @@ export class RCSTransport implements ChatTransport<UIMessage> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "user",
|
||||
uuid: uuidv4(),
|
||||
uuid: generateMessageUuid(),
|
||||
content: text,
|
||||
message: { content: text },
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -42,8 +41,31 @@ export function truncate(str: string | null | undefined, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "..." : s;
|
||||
}
|
||||
|
||||
function formatUuidV4(bytes: Uint8Array): string {
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
export function generateMessageUuid(): string {
|
||||
return uuidv4();
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
if (!cryptoApi || typeof cryptoApi.getRandomValues !== "function") {
|
||||
throw new Error("crypto.getRandomValues is required to generate message UUIDs");
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
return formatUuidV4(bytes);
|
||||
}
|
||||
|
||||
export function extractEventText(payload: Record<string, unknown> | null | undefined): string {
|
||||
|
||||
Reference in New Issue
Block a user