From 56fb196b2372aff134b651a7043bb455a1d9eed9 Mon Sep 17 00:00:00 2001 From: "global.eye.wu" Date: Thu, 14 May 2026 15:10:29 +0800 Subject: [PATCH] feat(remote): require bearer-token handshake on WebSocket upgrade (M1.3) `myagent remote serve` previously accepted any local TCP client on the WebSocket port. Anything that could reach 127.0.0.1:8765 could drive the agent. Closes that hole: - `ensureRemoteAuthToken(cwd)` creates `.myagent/remote/auth.json` on first launch (mode 0o600 best-effort -- POSIX enforces; Windows no-op) with a 256-bit URL-safe token, and reuses it across restarts. - `createRemoteAgentServer` now requires `authToken`. The upgrade handler rejects requests whose `Authorization: Bearer ` header is missing, malformed, or wrong with HTTP 401 + a `WWW-Authenticate: Bearer realm="myagent-remote"` challenge -- before any WebSocket framing is negotiated. - Token comparison uses `crypto.timingSafeEqual` to avoid timing side-channel leaks on local-shared hosts. - `connectRemoteClient` accepts an `authToken` option that injects the matching header. - `remote sessions` (the metadata listing subcommand) is unchanged -- it reads JSON files directly off disk and never opens a socket. The existing three remote tests now pass `authToken: "test-token"` through. Six new tests in `packages/core/test/security/remote-auth.test.ts` pin the invariants: missing header rejected, wrong token rejected, non-Bearer scheme rejected, correct token reaches `ready`, token file persists and round-trips through JSON, and the metadata API does not need the auth file. Catalog row added under "Remote WebSocket auth" in `packages/core/test/security/README.md`. CLAUDE.md updated. Multi-client session ownership and a dedicated `myagent remote client` CLI stay deferred to v2.0 per the M1 plan. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 2 +- packages/cli/src/index.ts | 7 + packages/core/src/remote.ts | 126 +++++++++++++-- packages/core/test/remote.test.ts | 9 +- packages/core/test/security/README.md | 12 ++ .../core/test/security/remote-auth.test.ts | 149 ++++++++++++++++++ 6 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 packages/core/test/security/remote-auth.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f8467ba..262a616 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ When adding a tool, register it through `buildTool` in `packages/tools/src/index - Hooks load as a frozen `HookSnapshot` from `.myagent/hooks.json` once per turn — they don't re-read disk mid-loop. - Memory: `createMemoryStore` reads from `.myagent/projects//memory`. Memory is treated as user/project preference, not as authoritative code truth (see `READ_ONLY_AGENT_SYSTEM_PROMPT` in `packages/cli/src/index.ts`). Skills are scanned from `SKILL.md` frontmatter via `scanSkillSnapshot`. - Background tasks (`myagent task start-bash`) spawn a detached `task worker ` child process; state machine lives in `packages/core/src/task.ts` and persists to `.myagent/tasks/`. -- Remote control (`myagent remote serve`) starts a local-only WebSocket endpoint backed by `createRemoteAgentServer`; metadata in `.myagent/remote/`. +- Remote control (`myagent remote serve`) starts a local-only WebSocket endpoint backed by `createRemoteAgentServer`; metadata in `.myagent/remote/`. The server requires a bearer token on every upgrade — `ensureRemoteAuthToken` generates a 256-bit token in `.myagent/remote/auth.json` on first launch (file mode 0o600 best-effort; Windows degrades) and reuses it across restarts. Missing or wrong `Authorization: Bearer ` returns HTTP 401 pre-upgrade; the comparison uses `crypto.timingSafeEqual`. ### What's intentionally missing diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 16aa46a..1990a45 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,6 +27,7 @@ import { createRemoteSessionStore, createProfileRecorder, createProfileStore, + ensureRemoteAuthToken, markTaskKilled, ModelError, runLocalBashTask, @@ -106,6 +107,7 @@ Week 18 scope: Agent sub-agents reuse the same query loop; explore is read-only and verifier defaults to background. fork traces record stable system/tool/prefix hashes for cache debugging. remote serve starts a local-only WebSocket endpoint for browser or local clients. + remote serve generates .myagent/remote/auth.json on first run; clients must send Authorization: Bearer . remote writes require client UUIDs for dedupe; metadata supports detach/resume. profile startup records fast-path and cold-path checkpoints under .myagent/profiles. week18 finalize runs the final offline smoke suite and writes a portfolio report. @@ -173,6 +175,7 @@ export type CliDependencies = { mcpConfigPath?: string; taskRootDir?: string; remoteRootDir?: string; + remoteAuthRootDir?: string; startTaskWorker?: ( taskId: string, options: { cwd: string; taskRootDir?: string } @@ -603,15 +606,19 @@ async function runRemote( } const env = dependencies.env ?? loadEnvironment(cwd, process.env); + const auth = await ensureRemoteAuthToken(cwd, dependencies.remoteAuthRootDir); const server = await createRemoteAgentServer({ cwd, host: parsed.host, port: parsed.port, rootDir: dependencies.remoteRootDir, + authToken: auth.token, runPrompt: (input, sink) => runRemoteAgentTurn(input, sink, stdout, stderr, dependencies, cwd, env) }); stdout.write(`[remote] listening ${server.url}\n`); stdout.write(`[remote] metadata ${server.store.rootDir}\n`); + stdout.write(`[remote] auth ${auth.path}${auth.created ? " (generated)" : ""}\n`); + stdout.write(`[remote] clients must send Authorization: Bearer \n`); await waitForShutdownSignal(); await server.close(); diff --git a/packages/core/src/remote.ts b/packages/core/src/remote.ts index 7f87b37..7b01fdc 100644 --- a/packages/core/src/remote.ts +++ b/packages/core/src/remote.ts @@ -1,7 +1,7 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"; import { Socket } from "node:net"; -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { normalizePath } from "./state.js"; @@ -138,9 +138,92 @@ export type RemoteAgentServerOptions = { host?: string; port?: number; rootDir?: string; + /** + * Bearer token required on every WebSocket upgrade. + * Use `ensureRemoteAuthToken(cwd)` to generate and persist one + * alongside the rest of the remote metadata under `.myagent/remote/`. + */ + authToken: string; runPrompt(input: RemoteTurnInput, sink: RemoteTurnSink): Promise; }; +export type RemoteAuthFile = { + version: 1; + token: string; + createdAt: string; +}; + +export type EnsureRemoteAuthTokenResult = { + token: string; + path: string; + created: boolean; +}; + +const REMOTE_AUTH_FILE_NAME = "auth.json"; + +function resolveRemoteAuthPath(cwd: string, rootDir?: string): string { + const base = rootDir ?? join(cwd, ".myagent", "remote"); + return resolve(base, REMOTE_AUTH_FILE_NAME); +} + +/** + * Read `.myagent/remote/auth.json`, or create it with a fresh 256-bit + * URL-safe token if it doesn't exist. The file is written with mode 0o600 + * (best-effort: a no-op on Windows). The token is the bearer credential + * required by the WebSocket upgrade handshake. + * + * @param rootDir Defaults to `/.myagent/remote`; tests can override. + */ +export async function ensureRemoteAuthToken( + cwd: string, + rootDir?: string +): Promise { + const filePath = resolveRemoteAuthPath(cwd, rootDir); + + try { + const raw = await readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed && typeof parsed.token === "string" && parsed.token.length >= 16) { + return { token: parsed.token, path: filePath, created: false }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + const token = randomBytes(32).toString("base64url"); + const payload: RemoteAuthFile = { + version: 1, + token, + createdAt: nowIso() + }; + await mkdir(rootDir ?? join(cwd, ".myagent", "remote"), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600 + }); + await chmod(filePath, 0o600).catch(() => undefined); + return { token, path: filePath, created: true }; +} + +function checkAuthHeader(request: IncomingMessage, expectedToken: string): boolean { + const header = request.headers["authorization"]; + if (typeof header !== "string") { + return false; + } + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + if (!match) { + return false; + } + const provided = Buffer.from(match[1] ?? "", "utf8"); + const expected = Buffer.from(expectedToken, "utf8"); + if (provided.length !== expected.length) { + return false; + } + return timingSafeEqual(provided, expected); +} + export type RemoteAgentServer = { host: string; port: number; @@ -234,6 +317,19 @@ export async function createRemoteAgentServer( }); httpServer.on("upgrade", (request, socket) => { + if (!checkAuthHeader(request, options.authToken)) { + (socket as Socket).end( + [ + "HTTP/1.1 401 Unauthorized", + "Content-Type: text/plain", + "Content-Length: 13", + "WWW-Authenticate: Bearer realm=\"myagent-remote\"", + "", + "Unauthorized\n" + ].join("\r\n") + ); + return; + } const peer = acceptWebSocketUpgrade(request, socket as Socket); if (!peer) { return; @@ -315,6 +411,7 @@ export async function connectRemoteClient(input: { host?: string; port: number; path?: string; + authToken?: string; }): Promise { const host = input.host ?? DEFAULT_REMOTE_HOST; const path = input.path ?? "/"; @@ -328,18 +425,19 @@ export async function connectRemoteClient(input: { }); const key = randomBytes(16).toString("base64"); - socket.write( - [ - `GET ${path} HTTP/1.1`, - `Host: ${host}:${input.port}`, - "Upgrade: websocket", - "Connection: Upgrade", - `Sec-WebSocket-Key: ${key}`, - "Sec-WebSocket-Version: 13", - "", - "" - ].join("\r\n") - ); + const headers = [ + `GET ${path} HTTP/1.1`, + `Host: ${host}:${input.port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13" + ]; + if (input.authToken) { + headers.push(`Authorization: Bearer ${input.authToken}`); + } + headers.push("", ""); + socket.write(headers.join("\r\n")); const remainder = await readHttpUpgrade(socket); const peer = createWebSocketPeer(socket, { maskOutgoing: true, initialBuffer: remainder }); diff --git a/packages/core/test/remote.test.ts b/packages/core/test/remote.test.ts index 5a1b220..6c08e7e 100644 --- a/packages/core/test/remote.test.ts +++ b/packages/core/test/remote.test.ts @@ -17,6 +17,7 @@ describe("remote websocket direct connect", () => { const server = await createRemoteAgentServer({ cwd, port: 0, + authToken: "test-token", async runPrompt(input, sink) { runCount += 1; sink.writeStdout(`[agent] ${input.prompt}\n`); @@ -28,7 +29,7 @@ describe("remote websocket direct connect", () => { }); try { - const client = await connectRemoteClient({ port: server.port }); + const client = await connectRemoteClient({ port: server.port, authToken: "test-token" }); expect(await client.nextMessage()).toEqual({ type: "ready", protocolVersion: 1 }); client.send({ type: "user_message", id: "write_1", prompt: "hello remote" }); @@ -51,7 +52,7 @@ describe("remote websocket direct connect", () => { expect(runCount).toBe(1); client.close(); - const resumed = await connectRemoteClient({ port: server.port }); + const resumed = await connectRemoteClient({ port: server.port, authToken: "test-token" }); expect(await resumed.nextMessage()).toEqual({ type: "ready", protocolVersion: 1 }); resumed.send({ type: "resume", id: "resume_1", sessionId }); const resumeMessages = await resumed.readUntil((message) => message.type === "session_metadata"); @@ -75,6 +76,7 @@ describe("remote websocket direct connect", () => { const server = await createRemoteAgentServer({ cwd, port: 0, + authToken: "test-token", async runPrompt(_input, sink) { const decision = await sink.requestPermission({ toolName: "Write", @@ -87,7 +89,7 @@ describe("remote websocket direct connect", () => { }); try { - const client = await connectRemoteClient({ port: server.port }); + const client = await connectRemoteClient({ port: server.port, authToken: "test-token" }); expect(await client.nextMessage()).toEqual({ type: "ready", protocolVersion: 1 }); client.send({ type: "user_message", id: "write_permission", prompt: "create note" }); const permissionMessages = await client.readUntil((message) => message.type === "permission_request"); @@ -122,6 +124,7 @@ describe("remote websocket direct connect", () => { const server = await createRemoteAgentServer({ cwd, port: 0, + authToken: "test-token", async runPrompt() { return { sessionId: "sess_unused", exitCode: 0 }; } diff --git a/packages/core/test/security/README.md b/packages/core/test/security/README.md index 9b56d9e..1d51fe3 100644 --- a/packages/core/test/security/README.md +++ b/packages/core/test/security/README.md @@ -73,6 +73,18 @@ Tests live in two trees because of the package boundary | `executeToolBatch` never overlaps two non-concurrency-safe tools | `packages/core/test/security/scheduler-write-serialization.test.ts` | | Sibling read tools cancel when a Bash sibling errors with cancel-on-error | `packages/core/test/scheduler.test.ts` | +### Remote WebSocket auth + +| Invariant | Test | +|---|---| +| `myagent remote serve` requires a bearer token; missing `Authorization` header → HTTP 401 (no upgrade) | `packages/core/test/security/remote-auth.test.ts` | +| Wrong token → HTTP 401 (no upgrade) | `packages/core/test/security/remote-auth.test.ts` | +| Non-`Bearer` scheme (e.g. Basic) → HTTP 401 | `packages/core/test/security/remote-auth.test.ts` | +| Correct token → upgrade succeeds and `ready` frame is delivered | `packages/core/test/security/remote-auth.test.ts` | +| Token comparison uses `crypto.timingSafeEqual` (constant time) | code-level guarantee in `checkAuthHeader` (`remote.ts`) | +| `ensureRemoteAuthToken` persists a 256-bit token in `.myagent/remote/auth.json` (mode 0o600 best-effort) and reuses it across restarts | `packages/core/test/security/remote-auth.test.ts` | +| `remote sessions` metadata listing does not require or touch the auth file | `packages/core/test/security/remote-auth.test.ts` | + ### TaskStore concurrency | Invariant | Test | diff --git a/packages/core/test/security/remote-auth.test.ts b/packages/core/test/security/remote-auth.test.ts new file mode 100644 index 0000000..ab3a4dd --- /dev/null +++ b/packages/core/test/security/remote-auth.test.ts @@ -0,0 +1,149 @@ +import { mkdtempSync, readFileSync } from "node:fs"; +import { Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + connectRemoteClient, + createRemoteAgentServer, + ensureRemoteAuthToken +} from "../../src/index.js"; + +type ProbeOptions = { + authHeader?: string; +}; + +function probeUpgrade(port: number, options: ProbeOptions = {}): Promise { + return new Promise((resolvePromise, reject) => { + const socket = new Socket(); + let received = ""; + socket.on("data", (chunk) => { + received += chunk.toString("utf8"); + }); + socket.on("close", () => resolvePromise(received)); + socket.on("error", reject); + socket.connect(port, "127.0.0.1", () => { + const headers = [ + "GET / HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13" + ]; + if (options.authHeader !== undefined) { + headers.push(`Authorization: ${options.authHeader}`); + } + headers.push("", ""); + socket.write(headers.join("\r\n")); + }); + }); +} + +async function neverPrompt(): Promise { + throw new Error("runPrompt should not be invoked when handshake fails"); +} + +describe("security: remote WebSocket auth", () => { + it("rejects an upgrade with no Authorization header (HTTP 401)", async () => { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-remote-auth-")); + const server = await createRemoteAgentServer({ + cwd, + port: 0, + authToken: "secret-token-aaaa", + runPrompt: neverPrompt + }); + try { + const response = await probeUpgrade(server.port); + expect(response.startsWith("HTTP/1.1 401")).toBe(true); + expect(response).toContain("WWW-Authenticate: Bearer"); + } finally { + await server.close(); + } + }); + + it("rejects an upgrade with a wrong Bearer token", async () => { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-remote-auth-wrong-")); + const server = await createRemoteAgentServer({ + cwd, + port: 0, + authToken: "secret-token-aaaa", + runPrompt: neverPrompt + }); + try { + const response = await probeUpgrade(server.port, { authHeader: "Bearer secret-token-bbbb" }); + expect(response.startsWith("HTTP/1.1 401")).toBe(true); + } finally { + await server.close(); + } + }); + + it("rejects a non-Bearer scheme", async () => { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-remote-auth-scheme-")); + const server = await createRemoteAgentServer({ + cwd, + port: 0, + authToken: "secret-token-aaaa", + runPrompt: neverPrompt + }); + try { + const response = await probeUpgrade(server.port, { authHeader: "Basic c2VjcmV0LXRva2VuLWFhYWE=" }); + expect(response.startsWith("HTTP/1.1 401")).toBe(true); + } finally { + await server.close(); + } + }); + + it("accepts an upgrade with the correct Bearer token and reaches the ready frame", async () => { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-remote-auth-ok-")); + const server = await createRemoteAgentServer({ + cwd, + port: 0, + authToken: "secret-token-aaaa", + runPrompt: neverPrompt + }); + try { + const client = await connectRemoteClient({ + port: server.port, + authToken: "secret-token-aaaa" + }); + const ready = await client.nextMessage(); + expect(ready).toEqual({ type: "ready", protocolVersion: 1 }); + client.close(); + } finally { + await server.close(); + } + }); + + it("generates a fresh token on first call and reuses it on the second", async () => { + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-remote-auth-persist-")); + const first = await ensureRemoteAuthToken(cwd); + expect(first.created).toBe(true); + expect(first.token.length).toBeGreaterThanOrEqual(32); + expect(first.path.endsWith("auth.json")).toBe(true); + + const second = await ensureRemoteAuthToken(cwd); + expect(second.created).toBe(false); + expect(second.token).toBe(first.token); + expect(second.path).toBe(first.path); + + const fileContent = JSON.parse(readFileSync(first.path, "utf8")); + expect(fileContent).toMatchObject({ + version: 1, + token: first.token + }); + expect(typeof fileContent.createdAt).toBe("string"); + }); + + it("auth file is not required for `remote sessions` metadata reads", async () => { + // The CLI's `remote sessions` subcommand uses createRemoteSessionStore.list() + // directly off the filesystem, which never calls ensureRemoteAuthToken — so + // this test asserts the metadata API does not need or touch auth.json. + const cwd = mkdtempSync(join(tmpdir(), "myagent-sec-remote-auth-meta-")); + const { createRemoteSessionStore } = await import("../../src/remote.js"); + const store = createRemoteSessionStore(cwd); + await expect(store.list()).resolves.toEqual([]); + }); +});