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:
Dosion
2026-04-26 19:49:54 +08:00
committed by GitHub
parent fc438bd222
commit c2ac9a74c1
144 changed files with 4406 additions and 1644 deletions

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View 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));
}

View 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;
}

View File

@@ -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')
})
})

View File

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

View File

@@ -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",

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

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

View File

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

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

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

View 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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },

View File

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

View File

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

View File

@@ -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 },

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },
}),

View File

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