diff --git a/package.json b/package.json index 7173bb6c..58adefed 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli-workspace.test.ts b/src/cli-workspace.test.ts new file mode 100644 index 00000000..56e5fe35 --- /dev/null +++ b/src/cli-workspace.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { resolveCliWorkspaceContext } from "./cli-workspace.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-cli-workspace-test-")); +try { + const repository = join(root, "repository"); + const nested = join(repository, "packages", "app"); + const plainDirectory = join(root, "plain"); + mkdirSync(nested, { recursive: true }); + mkdirSync(plainDirectory); + execFileSync("git", ["init", "--quiet", repository]); + // Git returns canonical paths, while macOS and Windows temp directories may + // be reported through aliases such as /var or an 8.3 short path. + const allowedRoot = realpathSync.native(root); + const repositoryRoot = realpathSync.native(repository); + const nestedRoot = realpathSync.native(nested); + const plainRoot = realpathSync.native(plainDirectory); + + assert.deepEqual(resolveCliWorkspaceContext([plainRoot], {}, nestedRoot), { + workspaceId: undefined, + workspaceRoot: resolve(repositoryRoot), + }); + + assert.deepEqual(resolveCliWorkspaceContext([repositoryRoot], {}, plainRoot), { + workspaceId: undefined, + workspaceRoot: resolve(plainRoot), + }); + + assert.deepEqual(resolveCliWorkspaceContext([plainRoot], { + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot), { + workspaceId: undefined, + workspaceRoot: resolve(repositoryRoot), + }); + + assert.deepEqual(resolveCliWorkspaceContext([allowedRoot], { + DEVSPACE_WORKSPACE_ID: "ws_injected", + DEVSPACE_WORKSPACE_ROOT: nestedRoot, + }, plainRoot), { + workspaceId: "ws_injected", + workspaceRoot: resolve(nestedRoot), + }); + + if (process.platform !== "win32") { + const repositoryAlias = join(root, "repository-alias"); + symlinkSync(repositoryRoot, repositoryAlias, "dir"); + assert.deepEqual(resolveCliWorkspaceContext([repositoryAlias], { + DEVSPACE_WORKSPACE_ID: "ws_injected", + DEVSPACE_WORKSPACE_ROOT: repositoryRoot, + }, plainRoot), { + workspaceId: "ws_injected", + workspaceRoot: resolve(repositoryRoot), + }); + } + + assert.throws( + () => resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ID: "ws_injected", + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot), + /outside allowed roots/, + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/cli-workspace.ts b/src/cli-workspace.ts new file mode 100644 index 00000000..d09cb253 --- /dev/null +++ b/src/cli-workspace.ts @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { assertAllowedPath } from "./roots.js"; + +export interface CliWorkspaceContext { + workspaceId?: string; + workspaceRoot: string; +} + +/** Resolve the project context used by local agent commands. */ +export function resolveCliWorkspaceContext( + allowedRoots: readonly string[], + env: NodeJS.ProcessEnv = process.env, + cwd = process.cwd(), +): CliWorkspaceContext { + const workspaceId = env.DEVSPACE_WORKSPACE_ID?.trim() || undefined; + const injectedRoot = workspaceId ? env.DEVSPACE_WORKSPACE_ROOT?.trim() : undefined; + const candidate = canonicalizePath( + injectedRoot ? resolve(injectedRoot) : findGitRoot(cwd) ?? resolve(cwd), + ); + + if (!workspaceId) return { workspaceId, workspaceRoot: candidate }; + + return { + workspaceId, + workspaceRoot: assertAllowedPath(candidate, allowedRoots.map(canonicalizePath)), + }; +} + +function canonicalizePath(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolve(path); + } +} + +function findGitRoot(cwd: string): string | undefined { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: resolve(cwd), + encoding: "utf8", + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) return undefined; + const root = result.stdout.trim(); + return root ? resolve(root) : undefined; +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 5be30309..dcae2f3c 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { loadConfig } from "./config.js"; import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; @@ -11,6 +13,9 @@ import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js import { LocalAgentStore } from "./local-agent-store.js"; const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const tsxLoader = pathToFileURL(require.resolve("tsx")).href; +const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url)); const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -72,6 +77,7 @@ try { store.close(); const daemonSocket = localAgentDaemonPaths(stateDir).endpoint; + const daemonRequests: Array<{ method: string; params?: Record }> = []; const daemon = createNetServer((socket) => { let buffer = ""; socket.setEncoding("utf8"); @@ -79,11 +85,16 @@ try { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); if (newline === -1) return; - const request = JSON.parse(buffer.slice(0, newline)) as { requestId: string; method: string }; + const request = JSON.parse(buffer.slice(0, newline)) as { + requestId: string; + method: string; + params?: Record; + }; + daemonRequests.push(request); if (request.method === "agent.start") { socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 1, + protocolVersion: 3, ok: false, error: { code: "UNKNOWN_TARGET", @@ -99,7 +110,7 @@ try { : request.method === "hello" ? { state: "ready", - protocolVersion: 1, + protocolVersion: 3, pid: process.pid, endpoint: daemonSocket, startedAt: "now", @@ -110,7 +121,7 @@ try { : null; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 1, + protocolVersion: 3, ok: true, result, })); @@ -141,6 +152,28 @@ try { assert.doesNotMatch(output, /profile reviewer/); assert.doesNotMatch(output, new RegExp(other.id)); + const { stdout: directOutput } = await execFileAsync( + "node", + ["--import", tsxLoader, cliPath, "agents", "ls"], + { + cwd: projectRoot, + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: stateDir, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + DEVSPACE_WORKSPACE_ID: "", + DEVSPACE_WORKSPACE_ROOT: stateDir, + }, + }, + ); + assert.match(directOutput, new RegExp(current.id)); + const directList = [...daemonRequests].reverse().find((request) => request.method === "agent.list"); + assert.deepEqual(directList?.params, { workspaceRoot: realpathSync.native(projectRoot) }); + let commandFailure: unknown; try { await execFileAsync( @@ -175,6 +208,41 @@ try { assert.equal(payload.error.message, "Unknown subagent profile or provider: missing."); assert.equal(payload.error.retryable, false); assert.equal(payload.error.target, "missing"); + + await assert.rejects( + execFileAsync( + "node", + [ + "--import", + "tsx", + "src/cli.ts", + "agents", + "run", + "codex", + "--model", + "--unknown", + "inspect", + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }, + ), + (error: unknown) => { + assert.match((error as { stderr?: string }).stderr ?? "", /Unknown option: --unknown/); + return true; + }, + ); } finally { await new Promise((resolveClose, rejectClose) => { daemon.close((error) => error ? rejectClose(error) : resolveClose()); diff --git a/src/cli.ts b/src/cli.ts index 8e023d43..3458ff99 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; +import { resolveCliWorkspaceContext } from "./cli-workspace.js"; import { formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js"; @@ -313,12 +314,11 @@ function printHelp(): void { async function runAgentsCommand(args: string[]): Promise { const [subcommand, ...rest] = args; - const json = rest.includes("--json"); - const commandArgs = rest.filter((arg) => arg !== "--json"); + const { args: commandArgs, json } = extractJsonOption(rest); switch (subcommand) { case "ls": case "list": - await runAgentsList(json); + await runAgentsList(commandArgs, json); return; case "run": await runAgentsRun(commandArgs, json); @@ -343,10 +343,11 @@ async function runAgentsCommand(args: string[]): Promise { } } -async function runAgentsList(json: boolean): Promise { +async function runAgentsList(args: string[], json: boolean): Promise { + if (args.length > 0) throw new Error("Usage: devspace agents ls [--json]"); const config = loadConfig(); const client = createLocalAgentClient(config); - const result = await client.list(resolveCurrentWorkspaceScope()); + const result = await client.list(resolveCliWorkspaceContext(config.allowedRoots)); const agents = presentAgentResult(result, json); if (!agents) return; @@ -368,7 +369,7 @@ async function runAgentsList(json: boolean): Promise { async function runAgentsRun(args: string[], json: boolean): Promise { const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); - const scope = resolveCurrentWorkspaceScope(); + const scope = resolveCliWorkspaceContext(config.allowedRoots); const client = createLocalAgentClient(config); const result = await client.start({ target: parsed.target, @@ -391,7 +392,7 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { const parsed = parseLocalAgentContinueArgs(args); const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCurrentWorkspaceScope(); + const scope = resolveCliWorkspaceContext(config.allowedRoots); const result = await client.continue(parsed.agentId, parsed.prompt, { model: parsed.model, effort: parsed.effort, @@ -406,12 +407,12 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { } async function runAgentsShow(args: string[], json: boolean): Promise { - const [id] = args; - if (!id) throw new Error("Usage: devspace agents show "); + const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents show [--json]"); const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCurrentWorkspaceScope(); + const scope = resolveCliWorkspaceContext(config.allowedRoots); const initial = await client.get(id, scope); let record = presentAgentResult(initial, json); if (!record) return; @@ -444,7 +445,8 @@ async function runAgentsShow(args: string[], json: boolean): Promise { } async function runAgentsDaemon(args: string[], json: boolean): Promise { - const [subcommand] = args; + const [subcommand, ...extra] = args; + if (extra.length > 0) throw new Error("Usage: devspace agents daemon [--json]"); const config = loadConfig(); const client = createLocalAgentClient(config); switch (subcommand) { @@ -471,19 +473,23 @@ async function runAgentsDaemon(args: string[], json: boolean): Promise { } } -function resolveCurrentWorkspaceRoot(): string { - return resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd()); -} - -function resolveCurrentWorkspaceScope(): { workspaceId: string; workspaceRoot: string } { - const workspaceId = process.env.DEVSPACE_WORKSPACE_ID?.trim(); - if (!workspaceId) { - throw new Error("A DevSpace workspace is required. Run this command from an open_workspace session."); +function extractJsonOption(args: string[]): { args: string[]; json: boolean } { + const commandArgs: string[] = []; + let json = false; + let optionsEnded = false; + for (const argument of args) { + if (!optionsEnded && argument === "--") { + optionsEnded = true; + commandArgs.push(argument); + continue; + } + if (!optionsEnded && argument === "--json") { + json = true; + continue; + } + commandArgs.push(argument); } - return { - workspaceId, - workspaceRoot: resolveCurrentWorkspaceRoot(), - }; + return { args: commandArgs, json }; } function formatAgentLine(agent: Pick< diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 6c0426f8..3b573b91 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 2; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 3; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 36e5e6c4..a6dfe4ca 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -10,7 +10,7 @@ import { const request = decodeLocalAgentDaemonRequest({ requestId: "req_1", - protocolVersion: 2, + protocolVersion: 3, authToken: "test-secret", method: "agent.start", params: { @@ -28,7 +28,7 @@ assert.match(encodeLocalAgentDaemonRequest(request), /"method":"agent.start"/); const whitespaceRequest = decodeLocalAgentDaemonRequest({ requestId: "req_whitespace", - protocolVersion: 2, + protocolVersion: 3, authToken: "test-secret", method: "agent.start", params: { @@ -41,10 +41,24 @@ const whitespaceRequest = decodeLocalAgentDaemonRequest({ if (whitespaceRequest.method !== "agent.start") throw new Error("expected agent.start request"); assert.equal(whitespaceRequest.params.prompt, " keep prompt whitespace \n"); +const directRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_direct", + protocolVersion: 3, + authToken: "test-secret", + method: "agent.start", + params: { + target: "reviewer", + prompt: "Review this", + workspaceRoot: "/tmp/project", + }, +}); +if (directRequest.method !== "agent.start") throw new Error("expected agent.start request"); +assert.equal(directRequest.params.workspaceId, undefined); + assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", - protocolVersion: 2, + protocolVersion: 3, authToken: "test-secret", method: "agent.start", params: { target: "reviewer", prompt: "" }, @@ -66,9 +80,12 @@ const record = decodeAgentRecord({ assert.equal(record.id, "agt_1234"); assert.equal(record.latestResponse, " response whitespace \n"); +const directRecord = decodeAgentRecord({ ...record, workspaceId: undefined }); +assert.equal(directRecord.workspaceId, undefined); + const response = decodeLocalAgentDaemonResponse({ requestId: "req_1", - protocolVersion: 2, + protocolVersion: 3, ok: true, result: record, }); @@ -76,7 +93,7 @@ assert.equal(response.ok, true); const errorResponse = decodeLocalAgentDaemonResponse(JSON.parse(encodeLocalAgentDaemonResponse({ requestId: "req_error", - protocolVersion: 2, + protocolVersion: 3, ok: false, error: { code: "PROVIDER_UNAVAILABLE", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index b7977361..bf9bf8e6 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -180,7 +180,7 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); return { id: requiredString(record?.id, "id"), - workspaceId: requiredString(record?.workspaceId, "workspaceId"), + workspaceId: optionalString(record?.workspaceId), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), profileName: requiredString(record?.profileName, "profileName"), provider: requiredString(record?.provider, "provider"), @@ -247,7 +247,7 @@ function decodeStartInput(value: unknown): StartLocalAgentInput { target: requiredString(record?.target, "target"), prompt: requiredContentString(record?.prompt, "prompt"), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), - workspaceId: requiredString(record?.workspaceId, "workspaceId"), + workspaceId: optionalString(record?.workspaceId), model: optionalString(record?.model), effort: optionalString(record?.effort), writeMode: decodeWriteMode(record?.writeMode), @@ -273,7 +273,7 @@ function decodeWorkspaceScope(value: unknown): LocalAgentWorkspaceScope { const record = asRecord(value); if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workspace scope is required."); return { - workspaceId: requiredString(record.workspaceId, "scope.workspaceId"), + workspaceId: optionalString(record.workspaceId), workspaceRoot: requiredString(record.workspaceRoot, "scope.workspaceRoot"), }; } diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 61024190..3b5abef4 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -237,7 +237,7 @@ const legacyServer = createNetServer((socket) => { ok: false, error: { code: "DAEMON_PROTOCOL_MISMATCH", - message: "Unsupported daemon protocol version 2; expected 1.", + message: "Unsupported daemon protocol version 3; expected 1.", retryable: false, }, })); @@ -291,10 +291,10 @@ const upgradeClient = new LocalAgentClient({ }, }); try { - assert.equal(unwrap(await upgradeClient.ensureReady()).protocolVersion, 2); + assert.equal(unwrap(await upgradeClient.ensureReady()).protocolVersion, 3); assert.equal(replacementSpawns, 1); assert.equal(spawnedBeforeLegacyLockReleased, false); - assert.deepEqual(legacyMethods.slice(0, 3), ["hello:2", "hello:1", "daemon.stop:1"]); + assert.deepEqual(legacyMethods.slice(0, 3), ["hello:3", "hello:1", "daemon.stop:1"]); } finally { legacyLock.release(); await replacementDaemon.close(); @@ -387,11 +387,11 @@ const timeoutServer = createNetServer((socket) => { if (request.method !== "hello") return; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 2, + protocolVersion: 3, ok: true, result: { state: "ready", - protocolVersion: 2, + protocolVersion: 3, pid: process.pid, endpoint: timeoutPaths.endpoint, startedAt: "now", @@ -433,7 +433,7 @@ const invalidServer = createNetServer((socket) => { if (!buffer.includes("\n")) return; socket.end(encodeLocalAgentDaemonResponse({ requestId: "wrong_request_id", - protocolVersion: 2, + protocolVersion: 3, ok: true, result: {}, })); @@ -487,7 +487,7 @@ try { const unauthorized = await sendRawRequest(socketDaemon.paths.endpoint, JSON.stringify({ requestId: "unauthorized", - protocolVersion: 2, + protocolVersion: 3, authToken: "wrong-secret", method: "hello", params: {}, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 2c1514b4..bf553e70 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -20,6 +20,7 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-manager-test-")); +const directRoot = await mkdtemp(join(tmpdir(), "devspace-direct-agent-manager-test-")); const stateDir = join(root, "state"); const scope = { workspaceId: "ws_test", workspaceRoot: root }; const profile: LocalAgentProfile = { @@ -278,6 +279,29 @@ const wrongWorkspaceId = await manager.continue( assert.equal(wrongWorkspaceId.isErr(), true); if (wrongWorkspaceId.isErr()) assert.equal(wrongWorkspaceId.error.code, "WORKSPACE_MISMATCH"); +const directOutside = unwrap(await manager.start({ + target: "reviewer", + prompt: "direct outside allowed roots", + workspaceRoot: directRoot, +})); +await waitFor(() => unwrap(manager.get(directOutside.id, { workspaceRoot: directRoot })).status === "idle"); +assert.equal(directOutside.workspaceId, undefined); +assert.deepEqual(unwrap(manager.list({ workspaceRoot: directRoot })).map((record) => record.id), [ + directOutside.id, +]); + +const direct = unwrap(await manager.start({ + target: "reviewer", + prompt: "direct harness", + workspaceRoot: root, +})); +await waitFor(() => unwrap(manager.get(direct.id, { workspaceRoot: root })).status === "idle"); +assert.equal(direct.workspaceId, undefined); +assert.equal(unwrap(manager.get(first.id, { workspaceRoot: root })).id, first.id); +const directWrongId = manager.get(direct.id, { workspaceId: "ws_other", workspaceRoot: root }); +assert.equal(directWrongId.isErr(), true); +if (directWrongId.isErr()) assert.equal(directWrongId.error.code, "WORKSPACE_MISMATCH"); + const defect = unwrap(await manager.start({ target: "reviewer", prompt: "defect", @@ -302,6 +326,7 @@ await closing; await manager.close(); await rm(root, { recursive: true, force: true }); +await rm(directRoot, { recursive: true, force: true }); function getRecord(id: string) { return unwrap(manager.get(id, scope)); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 16de67aa..e45f33b9 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -36,7 +36,7 @@ export interface StartLocalAgentInput { target: string; prompt: string; workspaceRoot: string; - workspaceId: string; + workspaceId?: string; model?: string; effort?: string; writeMode?: LocalAgentWriteMode; @@ -102,7 +102,11 @@ export class LocalAgentManager { const manager = this; return Result.gen(async function* () { yield* manager.acceptingResult("start"); - const workspaceRoot = yield* manager.authorizeWorkspace(input.workspaceRoot, "start"); + const workspaceRoot = yield* manager.authorizeWorkspace( + input.workspaceRoot, + input.workspaceId, + "start", + ); const profiles = yield* Result.await(manager.loadProfilesResult(workspaceRoot, input.target)); const target = resolveLocalAgentTarget(input.target, profiles, input.model, input.effort); if (!target) { @@ -135,7 +139,7 @@ export class LocalAgentManager { model: target.model, effort: target.effort, writeMode: input.writeMode, - }); + }, input.workspaceId); }); } @@ -154,7 +158,7 @@ export class LocalAgentManager { const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); yield* manager.profileForRecordResult(record, profiles); yield* manager.driverResult(record.provider, "continue", agentId); - return manager.begin(record, prompt, overrides); + return manager.begin(record, prompt, overrides, scope.workspaceId); }); } @@ -172,7 +176,7 @@ export class LocalAgentManager { } list(scope: LocalAgentWorkspaceScope): BetterResult { - return this.authorizeWorkspace(scope.workspaceRoot, "list").andThen((workspaceRoot) => ( + return this.authorizeWorkspace(scope.workspaceRoot, scope.workspaceId, "list").andThen((workspaceRoot) => ( this.store.listResult({ workspaceId: scope.workspaceId, workspaceRoot, @@ -215,6 +219,7 @@ export class LocalAgentManager { record: LocalAgentRecord, prompt: string, overrides: RunOverrides, + workspaceId?: string, ): BetterResult { if (this.activeTurns.has(record.id)) { return Result.err(new AgentConflictError({ @@ -238,7 +243,9 @@ export class LocalAgentManager { if (updated.isErr()) return updated; // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. - const turn = Promise.resolve().then(() => this.runTurn(updated.value, prompt, overrides)); + const turn = Promise.resolve().then(() => ( + this.runTurn(updated.value, prompt, overrides, workspaceId) + )); this.activeTurns.set(record.id, turn); void turn.catch(() => undefined); return updated; @@ -248,6 +255,7 @@ export class LocalAgentManager { record: LocalAgentRecord, prompt: string, overrides: RunOverrides, + workspaceId?: string, ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { @@ -256,7 +264,7 @@ export class LocalAgentManager { providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); try { - const authorized = this.authorizeWorkspace(record.workspaceRoot, "run"); + const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run"); if (authorized.isErr()) { this.persistRunError(record, authorized.error, startedAt); return; @@ -478,10 +486,11 @@ export class LocalAgentManager { private authorizeWorkspace( workspaceRoot: string, + workspaceId: string | undefined, operation: string, ): BetterResult { const normalized = resolve(workspaceRoot); - if (!this.allowedRoots) return Result.ok(normalized); + if (!workspaceId || !this.allowedRoots) return Result.ok(normalized); try { return Result.ok(assertAllowedPath(normalized, [...this.allowedRoots])); } catch (cause) { @@ -500,9 +509,10 @@ export class LocalAgentManager { scope: LocalAgentWorkspaceScope, operation: string, ): BetterResult { - const workspaceRoot = this.authorizeWorkspace(scope.workspaceRoot, operation); + const workspaceRoot = this.authorizeWorkspace(scope.workspaceRoot, scope.workspaceId, operation); if (workspaceRoot.isErr()) return workspaceRoot; - if (workspaceRoot.value !== record.workspaceRoot || record.workspaceId !== scope.workspaceId) { + const idMismatch = scope.workspaceId !== undefined && record.workspaceId !== scope.workspaceId; + if (workspaceRoot.value !== record.workspaceRoot || idMismatch) { return Result.err(new AgentScopeError({ code: "WORKSPACE_MISMATCH", agentId: record.id, diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index ef987a8e..74bf875d 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -25,7 +25,7 @@ export interface LocalAgentRecord { } export interface CreateLocalAgentRecordInput { - workspaceId: string; + workspaceId?: string; workspaceRoot: string; profileName: string; provider: string; @@ -34,7 +34,7 @@ export interface CreateLocalAgentRecordInput { } export interface LocalAgentWorkspaceScope { - workspaceId: string; + workspaceId?: string; workspaceRoot: string; } diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index e243579d..c7723ea5 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -73,6 +73,23 @@ assert.throws( /Missing value for --effort/, ); +assert.throws( + () => parseLocalAgentRunArgs(["codex", "--unknown", "hello"]), + /Unknown option: --unknown/, +); + +assert.throws( + () => parseLocalAgentRunArgs(["codex", "--model", "--unknown", "hello"]), + /Unknown option: --unknown/, +); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { + target: "codex", + prompt: "--json literal", + model: undefined, + effort: undefined, +}); + { const target = resolveLocalAgentTarget("reviewer", profiles); assert.equal(target?.kind, "profile"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 868aad30..adf0f1d9 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -64,34 +64,42 @@ function parseAgentPromptArgs( let model: string | undefined; let effort: string | undefined; const promptParts: string[] = []; + let optionsEnded = false; for (let index = 0; index < rest.length; index += 1) { const part = rest[index]; + if (!optionsEnded && part === "--") { + optionsEnded = true; + continue; + } + if (optionsEnded) { + promptParts.push(part ?? ""); + continue; + } if (part === "--model") { - const value = rest[index + 1]?.trim(); - if (!value) throw new Error("Missing value for --model."); + const value = parseOptionValue(rest[index + 1], "--model"); model = value; index += 1; continue; } if (part?.startsWith("--model=")) { - const value = part.slice("--model=".length).trim(); - if (!value) throw new Error("Missing value for --model."); + const value = parseOptionValue(part.slice("--model=".length), "--model"); model = value; continue; } if (part === "--effort") { - const value = rest[index + 1]?.trim(); - if (!value) throw new Error("Missing value for --effort."); + const value = parseOptionValue(rest[index + 1], "--effort"); effort = value; index += 1; continue; } if (part?.startsWith("--effort=")) { - const value = part.slice("--effort=".length).trim(); - if (!value) throw new Error("Missing value for --effort."); + const value = parseOptionValue(part.slice("--effort=".length), "--effort"); effort = value; continue; } + if (part?.startsWith("-")) { + throw unknownOptionError(part); + } promptParts.push(part ?? ""); } @@ -103,6 +111,17 @@ function parseAgentPromptArgs( return { target, prompt, model, effort }; } +function parseOptionValue(value: string | undefined, option: string): string { + const trimmed = value?.trim(); + if (!trimmed) throw new Error(`Missing value for ${option}.`); + if (trimmed.startsWith("-")) throw unknownOptionError(trimmed); + return trimmed; +} + +function unknownOptionError(option: string): Error { + return new Error(`Unknown option: ${option}. Use -- before prompt text that starts with a dash.`); +} + export function resolveLocalAgentTarget( target: string, profiles: LocalAgentProfile[],