Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project>/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 <id>` 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 <token>` returns HTTP 401 pre-upgrade; the comparison uses `crypto.timingSafeEqual`.

### What's intentionally missing

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
createRemoteSessionStore,
createProfileRecorder,
createProfileStore,
ensureRemoteAuthToken,
markTaskKilled,
ModelError,
runLocalBashTask,
Expand Down Expand Up @@ -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 <token>.
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.
Expand Down Expand Up @@ -173,6 +175,7 @@ export type CliDependencies = {
mcpConfigPath?: string;
taskRootDir?: string;
remoteRootDir?: string;
remoteAuthRootDir?: string;
startTaskWorker?: (
taskId: string,
options: { cwd: string; taskRootDir?: string }
Expand Down Expand Up @@ -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 <token from auth file>\n`);

await waitForShutdownSignal();
await server.close();
Expand Down
126 changes: 112 additions & 14 deletions packages/core/src/remote.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<RemoteTurnResult>;
};

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 `<cwd>/.myagent/remote`; tests can override.
*/
export async function ensureRemoteAuthToken(
cwd: string,
rootDir?: string
): Promise<EnsureRemoteAuthTokenResult> {
const filePath = resolveRemoteAuthPath(cwd, rootDir);

try {
const raw = await readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as Partial<RemoteAuthFile>;
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -315,6 +411,7 @@ export async function connectRemoteClient(input: {
host?: string;
port: number;
path?: string;
authToken?: string;
}): Promise<RemoteClient> {
const host = input.host ?? DEFAULT_REMOTE_HOST;
const path = input.path ?? "/";
Expand All @@ -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 });
Expand Down
9 changes: 6 additions & 3 deletions packages/core/test/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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" });
Expand All @@ -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");
Expand All @@ -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",
Expand All @@ -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");
Expand Down Expand Up @@ -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 };
}
Expand Down
12 changes: 12 additions & 0 deletions packages/core/test/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading