doer-agent 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,17 +5,25 @@ import { StringCodec } from "nats";
5
5
  import { extract as extractTar } from "tar";
6
6
  import { validateImageBytes } from "./agent-runtime-utils.js";
7
7
  const fsRpcCodec = StringCodec();
8
- function normalizeFsRpcPath(workspaceRoot, rawPath) {
8
+ function isPathInsideRoot(root, target) {
9
+ return target === root || target.startsWith(root + path.sep);
10
+ }
11
+ export function normalizeFsRpcPath(workspaceRoot, rawPath, options = {}) {
9
12
  const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
10
13
  const normalizedRaw = raw.replace(/\\/g, "/");
11
14
  const useAbsolute = path.isAbsolute(normalizedRaw);
12
15
  const rel = normalizedRaw.replace(/^\/+/, "") || ".";
13
- const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(workspaceRoot, rel);
14
- if (abs !== workspaceRoot && !abs.startsWith(workspaceRoot + path.sep)) {
16
+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
17
+ const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(resolvedWorkspaceRoot, rel);
18
+ if (!options.allowOutsideWorkspace && !isPathInsideRoot(resolvedWorkspaceRoot, abs)) {
15
19
  throw new Error("path escapes workspace root");
16
20
  }
17
21
  const formatPath = (target) => {
18
- return path.relative(workspaceRoot, target).split(path.sep).join("/") || ".";
22
+ const resolvedTarget = path.resolve(target);
23
+ if (!isPathInsideRoot(resolvedWorkspaceRoot, resolvedTarget)) {
24
+ return resolvedTarget.split(path.sep).join("/") || "/";
25
+ }
26
+ return path.relative(resolvedWorkspaceRoot, resolvedTarget).split(path.sep).join("/") || ".";
19
27
  };
20
28
  return { abs, formatPath };
21
29
  }
@@ -90,7 +98,13 @@ function sha256Hex(bytes) {
90
98
  }
91
99
  async function executeFsRpc(args) {
92
100
  const action = parseFsRpcAction(args.request.action);
93
- const { abs, formatPath } = normalizeFsRpcPath(args.workspaceRoot, args.request.path);
101
+ const allowOutsideWorkspace = action === "list" ||
102
+ action === "stat" ||
103
+ action === "upload_file" ||
104
+ action === "read_text";
105
+ const { abs, formatPath } = normalizeFsRpcPath(args.workspaceRoot, args.request.path, {
106
+ allowOutsideWorkspace,
107
+ });
94
108
  if (action === "stat") {
95
109
  const entry = await stat(abs);
96
110
  return {
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { StringCodec } from "nats";
7
+ import { handleFsRpcMessage, normalizeFsRpcPath } from "./agent-fs-rpc.js";
8
+ const workspaceRoot = path.resolve("/tmp/doer-workspace");
9
+ const outsidePath = path.resolve("/tmp/outside/file.csv");
10
+ test("normalizes workspace paths as workspace-relative paths", () => {
11
+ const target = normalizeFsRpcPath(workspaceRoot, "folder/file.txt");
12
+ assert.equal(target.abs, path.join(workspaceRoot, "folder/file.txt"));
13
+ assert.equal(target.formatPath(target.abs), "folder/file.txt");
14
+ });
15
+ test("allows and preserves absolute paths outside the workspace for read operations", () => {
16
+ const target = normalizeFsRpcPath(workspaceRoot, outsidePath, {
17
+ allowOutsideWorkspace: true,
18
+ });
19
+ assert.equal(target.abs, outsidePath);
20
+ assert.equal(target.formatPath(target.abs), outsidePath.split(path.sep).join("/"));
21
+ });
22
+ test("allows parent traversal outside the workspace for read operations", () => {
23
+ const target = normalizeFsRpcPath(workspaceRoot, "../outside/file.csv", {
24
+ allowOutsideWorkspace: true,
25
+ });
26
+ assert.equal(target.abs, outsidePath);
27
+ assert.equal(target.formatPath(target.abs), outsidePath.split(path.sep).join("/"));
28
+ });
29
+ test("keeps paths outside the workspace blocked by default", () => {
30
+ assert.throws(() => normalizeFsRpcPath(workspaceRoot, outsidePath), /path escapes workspace root/);
31
+ assert.throws(() => normalizeFsRpcPath(workspaceRoot, "../outside/file.csv"), /path escapes workspace root/);
32
+ });
33
+ test("filesystem RPC lists and reads files outside the workspace but does not delete them", async () => {
34
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "doer-fs-rpc-"));
35
+ const rpcWorkspaceRoot = path.join(tempRoot, "workspace");
36
+ const externalDir = path.join(tempRoot, "external");
37
+ const externalFile = path.join(externalDir, "example.txt");
38
+ const codec = StringCodec();
39
+ await mkdir(rpcWorkspaceRoot);
40
+ await mkdir(externalDir);
41
+ await writeFile(externalFile, "outside workspace");
42
+ async function request(action, targetPath) {
43
+ let responseData;
44
+ const msg = {
45
+ data: codec.encode(JSON.stringify({ action, path: targetPath })),
46
+ respond(data) {
47
+ responseData = data;
48
+ return true;
49
+ },
50
+ };
51
+ await handleFsRpcMessage({
52
+ msg,
53
+ workspaceRoot: rpcWorkspaceRoot,
54
+ serverBaseUrl: "https://example.com",
55
+ agentId: "agent-1",
56
+ agentToken: "token",
57
+ onError: () => undefined,
58
+ });
59
+ assert.ok(responseData);
60
+ return JSON.parse(codec.decode(responseData));
61
+ }
62
+ try {
63
+ const listing = await request("list", externalDir);
64
+ assert.equal(listing.ok, true);
65
+ assert.equal(listing.path, externalDir.split(path.sep).join("/"));
66
+ assert.deepEqual(listing.items.map((item) => item.path), [externalFile.split(path.sep).join("/")]);
67
+ const text = await request("read_text", externalFile);
68
+ assert.equal(text.ok, true);
69
+ assert.equal(text.text, "outside workspace");
70
+ const deletion = await request("delete_path", externalFile);
71
+ assert.equal(deletion.ok, false);
72
+ assert.match(String(deletion.error), /path escapes workspace root/);
73
+ assert.equal(await readFile(externalFile, "utf8"), "outside workspace");
74
+ }
75
+ finally {
76
+ await rm(tempRoot, { recursive: true, force: true });
77
+ }
78
+ });
@@ -6,6 +6,50 @@ const MAX_ITEM_TEXT_CHARS = 4_000;
6
6
  const MAX_TURN_TEXT_CHARS = 8_000;
7
7
  const MAX_TRANSCRIPT_CHARS = 160_000;
8
8
  const MAX_HANDOFF_CHARS = 20_000;
9
+ const HANDOFF_USER_MARKER = "Continue from the handoff summary above and wait for my next request.";
10
+ export function buildThreadHandoffInjectionItems(handoff) {
11
+ return [
12
+ {
13
+ type: "message",
14
+ role: "assistant",
15
+ content: [{
16
+ type: "output_text",
17
+ text: handoff,
18
+ }],
19
+ },
20
+ ];
21
+ }
22
+ export function buildThreadHandoffSeedInput() {
23
+ return [{
24
+ type: "text",
25
+ text: HANDOFF_USER_MARKER,
26
+ }];
27
+ }
28
+ async function waitForThreadTurnStarted(manager, threadId, timeoutMs = 1_000) {
29
+ await new Promise((resolve) => {
30
+ let settled = false;
31
+ let cleanup = () => { };
32
+ const finish = () => {
33
+ if (settled) {
34
+ return;
35
+ }
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ cleanup();
39
+ resolve();
40
+ };
41
+ const timer = setTimeout(finish, timeoutMs);
42
+ cleanup = manager.onNotification((method, params) => {
43
+ if (method !== "turn/started") {
44
+ return;
45
+ }
46
+ const event = recordValue(params);
47
+ if (stringValue(event?.threadId) === threadId) {
48
+ finish();
49
+ }
50
+ });
51
+ });
52
+ }
9
53
  function recordValue(value) {
10
54
  return value && typeof value === "object" && !Array.isArray(value)
11
55
  ? value
@@ -339,16 +383,26 @@ export async function createCodexThreadHandoff(args) {
339
383
  }
340
384
  try {
341
385
  args.onProgress?.("injecting");
386
+ // Raw injected items do not make a new thread discoverable through thread/list.
387
+ const seedStarted = waitForThreadTurnStarted(args.manager, threadId);
388
+ const seedResult = recordValue(await args.manager.request("turn/start", {
389
+ threadId,
390
+ input: buildThreadHandoffSeedInput(),
391
+ }, 30_000));
392
+ const seedTurnId = stringValue(recordValue(seedResult?.turn)?.id);
393
+ if (!seedTurnId) {
394
+ throw new Error("Codex app-server did not return a handoff seed turn");
395
+ }
396
+ await seedStarted;
397
+ await args.manager.request("turn/interrupt", {
398
+ threadId,
399
+ turnId: seedTurnId,
400
+ }, 30_000).catch((error) => {
401
+ warnings.push(`Could not interrupt handoff seed turn: ${error instanceof Error ? error.message : String(error)}`);
402
+ });
342
403
  await args.manager.request("thread/inject_items", {
343
404
  threadId,
344
- items: [{
345
- type: "message",
346
- role: "assistant",
347
- content: [{
348
- type: "output_text",
349
- text: handoff,
350
- }],
351
- }],
405
+ items: buildThreadHandoffInjectionItems(handoff),
352
406
  }, 90_000);
353
407
  }
354
408
  catch (error) {
@@ -1,6 +1,24 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildBoundedHandoffTranscript, summarizeThreadTurn, } from "./codex-thread-handoff.js";
3
+ import { buildBoundedHandoffTranscript, buildThreadHandoffInjectionItems, buildThreadHandoffSeedInput, summarizeThreadTurn, } from "./codex-thread-handoff.js";
4
+ test("buildThreadHandoffInjectionItems adds the summary to model-visible history", () => {
5
+ assert.deepEqual(buildThreadHandoffInjectionItems("# Thread handoff\n\nContinue the work."), [
6
+ {
7
+ type: "message",
8
+ role: "assistant",
9
+ content: [{
10
+ type: "output_text",
11
+ text: "# Thread handoff\n\nContinue the work.",
12
+ }],
13
+ },
14
+ ]);
15
+ });
16
+ test("buildThreadHandoffSeedInput creates a real user turn for thread indexing", () => {
17
+ assert.deepEqual(buildThreadHandoffSeedInput(), [{
18
+ type: "text",
19
+ text: "Continue from the handoff summary above and wait for my next request.",
20
+ }]);
21
+ });
4
22
  test("summarizeThreadTurn omits raw command output, diffs, images, and MCP results", () => {
5
23
  const summary = summarizeThreadTurn({
6
24
  id: "turn-1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",