@sagentlab/navarch-runtime 0.1.141 → 0.1.143
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.
- package/README.md +1 -1
- package/bin/worktree-guard-hook.cjs +408 -3
- package/dist/adapters/claude-conversation.cjs +11 -88
- package/dist/adapters/codex-conversation.cjs +85 -0
- package/dist/adapters/codex.cjs +2 -0
- package/dist/adapters/conversation-process.cjs +91 -0
- package/dist/conversation/loop.cjs +15 -1
- package/dist/conversation/prompt.cjs +4 -2
- package/dist/conversation/turn-runner.cjs +2 -2
- package/dist/worktree-guard.cjs +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -592,7 +592,7 @@ unchanged across the deployment.
|
|
|
592
592
|
| `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
|
|
593
593
|
| `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable it for every runtime on the machine — required to run OpenCode or Muse on an already-isolated host. |
|
|
594
594
|
| `NAVARCH_NAVI_CONVERSATIONS` | on | Serve read-only Navi repository conversations on this machine. Only the `claude-code` adapter on the host sandbox with the worktree guard on is supported; `navarch-runtime doctor` prints the `navi_chat:` state and reason. Set `off` to stop polling for conversations. |
|
|
595
|
-
| `NAVARCH_NAVI_WORKSPACE_CHAT` | off | Explicitly enable writable Navi chat on a supported Claude Code host runtime. Owners can choose **Edit and run commands**; automatic permission review and
|
|
595
|
+
| `NAVARCH_NAVI_WORKSPACE_CHAT` | off | Explicitly enable writable Navi chat on a supported Claude Code or Codex host runtime. Generated host connect and supervise commands for both adapters include it; remove it from both lines to keep chat read-only. Owners can choose **Edit and run commands**; Claude uses automatic permission review and its worktree hook, while Codex uses a native checkout permission profile with escalation disabled. Commands run on this host, not in a hard sandbox. Changes stay under `<workspaceRoot>/chat-workspaces/<conversationId>/repo` until the operator removes them. See [workspace chat](../docs/navarch/navi-workspace-chat.md). |
|
|
596
596
|
| `NAVARCH_CONVERSATION_POLL_MS` | `5000` | Idle poll interval for queued conversation turns (1000–30000). |
|
|
597
597
|
| `NAVARCH_CONVERSATION_MIRROR` | on | Seed new conversation checkouts from a per-repository mirror under `<workspaceRoot>/conversation-mirrors/` (fetch + local clone with independent object files) instead of a full network clone. Set `off` to always clone directly. |
|
|
598
598
|
| `NAVARCH_CONVERSATION_TURN_TIMEOUT_MS` | `540000` | Time limit for one conversation turn's agent process (at most nine minutes). |
|
|
@@ -26,7 +26,10 @@
|
|
|
26
26
|
* standard system prefixes (/usr, /etc, ...) so toolchains keep working.
|
|
27
27
|
* - Bash commands are screened lexically: absolute, `~`/$HOME, and
|
|
28
28
|
* `..`-traversal path references must land inside the allowed roots or
|
|
29
|
-
* the system prefixes.
|
|
29
|
+
* the system prefixes. Slash-led text that cannot name an existing root
|
|
30
|
+
* entry (routes, regex, sed addresses) and bare root text inside heredoc
|
|
31
|
+
* or quoted program bodies are not paths (extractBashPathCandidates).
|
|
32
|
+
* This cannot catch every obfuscated escape (shell
|
|
30
33
|
* is Turing-complete) — it is a strong guardrail, not a security
|
|
31
34
|
* boundary. Operators needing a hard boundary should use
|
|
32
35
|
* NAVARCH_SANDBOX_MODE=docker (sandbox.cts).
|
|
@@ -311,23 +314,417 @@ function isCommentOrMarkupToken(command, start, token) {
|
|
|
311
314
|
return token === "//" && /\S/.test(command.slice(end));
|
|
312
315
|
}
|
|
313
316
|
|
|
317
|
+
let rootEntries;
|
|
318
|
+
|
|
319
|
+
/** Entry names at the filesystem root, or null when they cannot be listed. */
|
|
320
|
+
function listRootEntries() {
|
|
321
|
+
if (rootEntries === undefined) {
|
|
322
|
+
try {
|
|
323
|
+
rootEntries = fs.readdirSync(path.parse(process.cwd()).root);
|
|
324
|
+
} catch {
|
|
325
|
+
rootEntries = null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return rootEntries;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Glob segment to a case-insensitive regex source, as a superset of what the
|
|
333
|
+
* shell could match: `*` is `.*`, `?` and any bracket expression are `.`, and
|
|
334
|
+
* a backslash makes the next character literal.
|
|
335
|
+
*/
|
|
336
|
+
function globSegmentToRegexSource(segment) {
|
|
337
|
+
let source = "";
|
|
338
|
+
for (let i = 0; i < segment.length; i++) {
|
|
339
|
+
const ch = segment[i];
|
|
340
|
+
if (ch === "\\" && i + 1 < segment.length) {
|
|
341
|
+
source += segment[++i].replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
342
|
+
} else if (ch === "*") {
|
|
343
|
+
source += ".*";
|
|
344
|
+
} else if (ch === "?") {
|
|
345
|
+
source += ".";
|
|
346
|
+
} else if (ch === "[" && segment.indexOf("]", i + 2) !== -1) {
|
|
347
|
+
source += ".";
|
|
348
|
+
i = segment.indexOf("]", i + 2);
|
|
349
|
+
} else {
|
|
350
|
+
source += ch.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return source;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* False only when an absolute token cannot reach any existing location: its
|
|
358
|
+
* first segment neither names nor glob-matches an entry at the filesystem
|
|
359
|
+
* root. URL routes (`/dev-preview/knowledge`), regex literals (`/^export/`,
|
|
360
|
+
* `/v[0-9]+`) and sed addresses (`/pattern/,/^}/p`) fall here, while
|
|
361
|
+
* `/Users/...`, `/home/...`, `/*` and the bare root stay candidates. A first
|
|
362
|
+
* segment that expansion could change (`$`, `{`, `(` outside the quotes that
|
|
363
|
+
* make them literal, `.`/`..` segments) stays a candidate, and so does one
|
|
364
|
+
* that ends in a backslash, which can continue the word onto the next line.
|
|
365
|
+
* When a quote or backtick follows the token, the shell word may continue, so
|
|
366
|
+
* an unterminated first segment is matched as a prefix. `quote` is the plain
|
|
367
|
+
* quote character around the token when a text or program command reads the
|
|
368
|
+
* word, or "" otherwise.
|
|
369
|
+
*/
|
|
370
|
+
function mayNameRootEntry(token, nextChar, quote) {
|
|
371
|
+
const segment = token.replace(/^\/+/, "").split("/")[0];
|
|
372
|
+
if (segment === "") return true;
|
|
373
|
+
const expanding = quote === "'" ? null : quote === '"' ? /\$/ : /[$({]/;
|
|
374
|
+
if (expanding && expanding.test(segment)) return true;
|
|
375
|
+
if (/(^|[^\\])(\\\\)*\\$/.test(segment)) return true;
|
|
376
|
+
if (/(^|\/)\.\.?(\/|$)/.test(token)) return true;
|
|
377
|
+
const entries = listRootEntries();
|
|
378
|
+
if (!entries) return true;
|
|
379
|
+
const terminated = token.replace(/^\/+/, "").includes("/");
|
|
380
|
+
const continues = nextChar === "'" || nextChar === '"' || nextChar === "`";
|
|
381
|
+
const anchor = new RegExp(
|
|
382
|
+
`^${globSegmentToRegexSource(segment)}${terminated || !continues ? "$" : ""}`,
|
|
383
|
+
"i",
|
|
384
|
+
);
|
|
385
|
+
return entries.some((entry) => anchor.test(entry));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Commands that only read, filter or print text and never run it. Bare root
|
|
390
|
+
* text (`/`, `//`, `/*`, `/**`) in their heredoc or quoted argument is prose,
|
|
391
|
+
* a comment, or an operator rather than a path argument.
|
|
392
|
+
*/
|
|
393
|
+
const TEXT_COMMANDS = new Set([
|
|
394
|
+
"base64",
|
|
395
|
+
"cat",
|
|
396
|
+
"diff",
|
|
397
|
+
"egrep",
|
|
398
|
+
"envsubst",
|
|
399
|
+
"fgrep",
|
|
400
|
+
"grep",
|
|
401
|
+
"head",
|
|
402
|
+
"jq",
|
|
403
|
+
"rg",
|
|
404
|
+
"sort",
|
|
405
|
+
"tail",
|
|
406
|
+
"tee",
|
|
407
|
+
"uniq",
|
|
408
|
+
"wc",
|
|
409
|
+
"yq",
|
|
410
|
+
]);
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Commands that may share a command with an exempted text without running
|
|
414
|
+
* it. Their own quoted arguments get no exemption, so `echo '.a // x'` and
|
|
415
|
+
* `ls "/"` stay screened.
|
|
416
|
+
*/
|
|
417
|
+
const CHAINABLE_COMMANDS = new Set([...TEXT_COMMANDS, "cd", "echo", "ls", "mkdir", "printf", "pwd", "true"]);
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Commands whose single-quoted arguments are literal patterns, so `$`, `(` and
|
|
421
|
+
* `{` there do not expand (`sed '/^$/d'`, `awk '/^$/'`). This affects only the
|
|
422
|
+
* root-entry test; these commands get no bare-root text exemption.
|
|
423
|
+
*/
|
|
424
|
+
const LITERAL_PATTERN_COMMANDS = new Set([...CHAINABLE_COMMANDS, "sed", "awk", "gawk", "mawk", "nawk"]);
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Non-shell interpreters whose program body may hold bare root text as a
|
|
428
|
+
* comment or operator (`a // b`, `/* x *\/`, `"/".join`). A body that can
|
|
429
|
+
* start a process (PROGRAM_SHELL_RE) gets no exemption. Languages that pipe
|
|
430
|
+
* to commands in plain syntax (awk, perl, ruby, psql, sqlite3) are excluded.
|
|
431
|
+
*/
|
|
432
|
+
const PROGRAM_COMMANDS = new Set(["bun", "deno", "node", "python", "python3", "tsx"]);
|
|
433
|
+
|
|
434
|
+
const PROGRAM_SHELL_RE = /system|subprocess|popen|exec|spawn|child_process|shell|pty|command|\.run\b|`/i;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* git and gh run aliases, hooks and extensions through a shell, so only the
|
|
438
|
+
* message text of message subcommands is exempt: a quoted message-flag value,
|
|
439
|
+
* or a heredoc read through `--body-file`/`-F`/`--file`.
|
|
440
|
+
*/
|
|
441
|
+
const MESSAGE_SUBCOMMAND_RE =
|
|
442
|
+
/^\s*(?:git\s+(?:commit|tag|notes|merge)|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|close))\b/;
|
|
443
|
+
const MESSAGE_FLAG_RE = /\s(?:-m|--message|-t|--title|-b|--body|--notes)(?:=|\s+)$/;
|
|
444
|
+
const MESSAGE_FILE_RE = /\s(?:--body-file|-F|--file)(?:=|\s)/;
|
|
445
|
+
|
|
446
|
+
const ASSIGNMENTS_RE = /^\s*!?\s*(?:[A-Za-z_]\w*=\S*\s+)*/;
|
|
447
|
+
const LEADING_REDIRECTS_RE = /^(?:\s*\d*(?:<<?|>>?|&>>?)&?\s*[^\s;|&<>()]+)*\s*/;
|
|
448
|
+
|
|
449
|
+
/** Command word of a simple-command prefix, without assignments or a directory. */
|
|
450
|
+
function commandWordOf(segment) {
|
|
451
|
+
const rest = segment.replace(ASSIGNMENTS_RE, "");
|
|
452
|
+
const word = /^[^\s;|&<>()'"`]+/.exec(rest);
|
|
453
|
+
if (!word) return "";
|
|
454
|
+
return path.posix.basename(word[0]).replace(/^(python3)\.\d+$/, "$1");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Classifies the simple command starting at `start`: "empty" (nothing runs),
|
|
459
|
+
* "text" (a CHAINABLE_COMMANDS command), "active" (a PROGRAM_COMMANDS interpreter
|
|
460
|
+
* or a git/gh message subcommand), or "unsafe" (anything else, including a
|
|
461
|
+
* command word that starts with a quote, `$` or a backtick).
|
|
462
|
+
*/
|
|
463
|
+
function classifySegment(command, start) {
|
|
464
|
+
const end = command.slice(start).search(/[;|&\n()`]/);
|
|
465
|
+
const text = command.slice(start, end === -1 ? command.length : start + end);
|
|
466
|
+
const rest = text.replace(ASSIGNMENTS_RE, "").replace(LEADING_REDIRECTS_RE, "");
|
|
467
|
+
if (rest === "" || /^[)]/.test(command.slice(start).replace(ASSIGNMENTS_RE, ""))) return "empty";
|
|
468
|
+
const word = commandWordOf(rest);
|
|
469
|
+
if (CHAINABLE_COMMANDS.has(word)) return "text";
|
|
470
|
+
if (PROGRAM_COMMANDS.has(word)) return "active";
|
|
471
|
+
if (MESSAGE_SUBCOMMAND_RE.test(rest) && !/\s-c\s|alias/.test(command)) return "active";
|
|
472
|
+
return "unsafe";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Text of the simple command starting at `start`, up to its first separator. */
|
|
476
|
+
function segmentText(command, start) {
|
|
477
|
+
const end = command.slice(start).search(/[;|&\n()`]/);
|
|
478
|
+
return command.slice(start, end === -1 ? command.length : start + end);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* True when nothing else in the command can run text that an exempted body or
|
|
483
|
+
* argument supplies. Every command must be chainable text or empty, and no
|
|
484
|
+
* text may name a git or husky hook location. An active command must be the
|
|
485
|
+
* only command, without leading assignments (`GIT_EDITOR=sh`) or an editor
|
|
486
|
+
* flag. A git/gh message command may additionally take `cat`, `echo` or
|
|
487
|
+
* `printf` output through `$(...)` when those write no file
|
|
488
|
+
* (`--body "$(cat <<'EOF' ... )"`).
|
|
489
|
+
*/
|
|
490
|
+
function exemptionContextIsSafe(command, segments) {
|
|
491
|
+
if (/\.git\/|\.husky|hooks/i.test(command)) return false;
|
|
492
|
+
const classes = segments.map((segment) => ({ ...segment, kind: classifySegment(command, segment.at) }));
|
|
493
|
+
if (classes.some(({ kind }) => kind === "unsafe")) return false;
|
|
494
|
+
const active = classes.filter(({ kind }) => kind === "active");
|
|
495
|
+
if (active.length === 0) return true;
|
|
496
|
+
if (active.length > 1) return false;
|
|
497
|
+
const activeText = segmentText(command, active[0].at);
|
|
498
|
+
if (activeText.replace(ASSIGNMENTS_RE, "") !== activeText.trimStart()) return false;
|
|
499
|
+
const isMessage = MESSAGE_SUBCOMMAND_RE.test(activeText);
|
|
500
|
+
// git accepts unique long-option prefixes, so `--ed` also opens the editor.
|
|
501
|
+
if (isMessage && /\s(?:-e|--ed(?:i(?:t(?:or)?)?)?)(?:[\s=]|$)/.test(activeText)) return false;
|
|
502
|
+
return classes.every(({ at, kind, nested }) => {
|
|
503
|
+
if (kind === "empty" || kind === "active") return true;
|
|
504
|
+
if (!isMessage || !nested) return false;
|
|
505
|
+
const text = segmentText(command, at).replace(ASSIGNMENTS_RE, "");
|
|
506
|
+
return /^(?:cat|echo|printf)\b/.test(text) && !/>/.test(text);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** True for root-only text: `/`, `//`, `/*`, `/**`, `/=`, `//=`. */
|
|
511
|
+
function isBareRootText(token) {
|
|
512
|
+
return /^\/+\**=?$/.test(token);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Heredoc bodies as [{start, end, word, segment, quoted}], every simple
|
|
517
|
+
* command outside them as [{at, nested}] (nested inside `$(`, a backtick or a
|
|
518
|
+
* subshell), and `ok`. A small lexer tracks single and double quotes, `$(`,
|
|
519
|
+
* backticks, subshells and shell comments to find each `<<` operator and its
|
|
520
|
+
* body. Any construct it cannot follow (`$'`, arithmetic `((`, process
|
|
521
|
+
* substitution `<(`/`>(`, a delimiter with `$` or an unbalanced quote, an
|
|
522
|
+
* unterminated quote, substitution or heredoc, an unbalanced `)`) returns
|
|
523
|
+
* `ok: false`, so nothing is exempted on a misread.
|
|
524
|
+
*/
|
|
525
|
+
function scanHeredocs(command) {
|
|
526
|
+
const failed = { ok: false, bodies: [], segments: [] };
|
|
527
|
+
const bodies = [];
|
|
528
|
+
const segments = [{ at: 0, nested: false }];
|
|
529
|
+
const stack = [];
|
|
530
|
+
let pending = [];
|
|
531
|
+
let segmentStart = 0;
|
|
532
|
+
const startSegment = (at) => {
|
|
533
|
+
segmentStart = at;
|
|
534
|
+
segments.push({ at, nested: stack.some((frame) => frame !== "dq") });
|
|
535
|
+
};
|
|
536
|
+
let i = 0;
|
|
537
|
+
const n = command.length;
|
|
538
|
+
while (i < n) {
|
|
539
|
+
const ch = command[i];
|
|
540
|
+
const top = stack[stack.length - 1];
|
|
541
|
+
if (top === "dq") {
|
|
542
|
+
if (ch === "\\") i += 2;
|
|
543
|
+
else if (ch === '"') {
|
|
544
|
+
stack.pop();
|
|
545
|
+
i++;
|
|
546
|
+
} else if (ch === "$" && command[i + 1] === "(") {
|
|
547
|
+
if (command[i + 2] === "(") return failed;
|
|
548
|
+
stack.push("sub");
|
|
549
|
+
i += 2;
|
|
550
|
+
startSegment(i);
|
|
551
|
+
} else if (ch === "`") {
|
|
552
|
+
stack.push("bt");
|
|
553
|
+
i++;
|
|
554
|
+
startSegment(i);
|
|
555
|
+
} else i++;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (ch === "\\") {
|
|
559
|
+
i += 2;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (ch === "'") {
|
|
563
|
+
const close = command.indexOf("'", i + 1);
|
|
564
|
+
if (close === -1) return failed;
|
|
565
|
+
i = close + 1;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (ch === '"') {
|
|
569
|
+
stack.push("dq");
|
|
570
|
+
i++;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (ch === "$" && command[i + 1] === "'") return failed;
|
|
574
|
+
if (ch === "$" && command[i + 1] === "(") {
|
|
575
|
+
if (command[i + 2] === "(") return failed;
|
|
576
|
+
stack.push("sub");
|
|
577
|
+
i += 2;
|
|
578
|
+
startSegment(i);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (ch === "(") {
|
|
582
|
+
if (command[i + 1] === "(" || command[i - 1] === "<" || command[i - 1] === ">") return failed;
|
|
583
|
+
stack.push("sub");
|
|
584
|
+
startSegment(++i);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (ch === ")") {
|
|
588
|
+
if (top !== "sub") return failed;
|
|
589
|
+
stack.pop();
|
|
590
|
+
i++;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (ch === "`") {
|
|
594
|
+
if (top === "bt") stack.pop();
|
|
595
|
+
else stack.push("bt");
|
|
596
|
+
startSegment(++i);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (ch === "#" && (i === 0 || /[\s;|&()`]/.test(command[i - 1]))) {
|
|
600
|
+
const newline = command.indexOf("\n", i);
|
|
601
|
+
i = newline === -1 ? n : newline;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (ch === ";" || ch === "|" || ch === "&") {
|
|
605
|
+
startSegment(++i);
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (ch === "<" && command[i + 1] === "<") {
|
|
609
|
+
if (command[i + 2] === "<") {
|
|
610
|
+
i += 3;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
let j = i + 2;
|
|
614
|
+
const stripTabs = command[j] === "-";
|
|
615
|
+
if (stripTabs) j++;
|
|
616
|
+
while (command[j] === " " || command[j] === "\t") j++;
|
|
617
|
+
const word = /^[^\s;|&<>()]+/.exec(command.slice(j));
|
|
618
|
+
if (!word) return failed;
|
|
619
|
+
const delimiter = word[0].replace(/['"\\]/g, "");
|
|
620
|
+
if (!delimiter || delimiter.includes("$")) return failed;
|
|
621
|
+
if ((word[0].split("'").length - 1) % 2 !== 0) return failed;
|
|
622
|
+
if ((word[0].split('"').length - 1) % 2 !== 0) return failed;
|
|
623
|
+
const lineEnd = command.indexOf("\n", i);
|
|
624
|
+
pending.push({
|
|
625
|
+
delimiter,
|
|
626
|
+
stripTabs,
|
|
627
|
+
quoted: /['"\\]/.test(word[0]),
|
|
628
|
+
word: commandWordOf(command.slice(segmentStart, i)),
|
|
629
|
+
segment: command.slice(segmentStart, lineEnd === -1 ? n : lineEnd),
|
|
630
|
+
});
|
|
631
|
+
i = j + word[0].length;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (ch === "\n") {
|
|
635
|
+
i++;
|
|
636
|
+
for (const doc of pending) {
|
|
637
|
+
const start = i;
|
|
638
|
+
let end = -1;
|
|
639
|
+
while (i <= n) {
|
|
640
|
+
const newline = command.indexOf("\n", i);
|
|
641
|
+
const lineEnd = newline === -1 ? n : newline;
|
|
642
|
+
let line = command.slice(i, lineEnd);
|
|
643
|
+
if (doc.stripTabs) line = line.replace(/^\t+/, "");
|
|
644
|
+
const lineStart = i;
|
|
645
|
+
i = newline === -1 ? n + 1 : newline + 1;
|
|
646
|
+
if (line === doc.delimiter) {
|
|
647
|
+
end = lineStart;
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
if (newline === -1) break;
|
|
651
|
+
}
|
|
652
|
+
if (end === -1) return failed;
|
|
653
|
+
bodies.push({ start, end, word: doc.word, segment: doc.segment, quoted: doc.quoted });
|
|
654
|
+
}
|
|
655
|
+
if (i > n) i = n;
|
|
656
|
+
pending = [];
|
|
657
|
+
startSegment(i);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
i++;
|
|
661
|
+
}
|
|
662
|
+
if (stack.length > 0 || pending.length > 0) return failed;
|
|
663
|
+
return { ok: true, bodies, segments };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* True when the command in `segment` reads `text` as plain text, as a
|
|
668
|
+
* non-shell program that cannot start a process, or as a git/gh message.
|
|
669
|
+
*/
|
|
670
|
+
function readsAsText(segment, text, isMessage) {
|
|
671
|
+
const word = commandWordOf(segment);
|
|
672
|
+
if (TEXT_COMMANDS.has(word)) return true;
|
|
673
|
+
if (PROGRAM_COMMANDS.has(word)) return !PROGRAM_SHELL_RE.test(text);
|
|
674
|
+
return MESSAGE_SUBCOMMAND_RE.test(segment.replace(ASSIGNMENTS_RE, "")) && isMessage;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* True when bare root text sits in a heredoc body or quoted argument that a
|
|
679
|
+
* text command, a non-shell program, or a git/gh message reads: a
|
|
680
|
+
* `// comment` or `a / b` prose in `cat <<'EOF'`,
|
|
681
|
+
* `gh pr create --body-file - <<'EOF'` or `python3 -c "print(a // b)"`. The
|
|
682
|
+
* quoted argument must hold more than the token itself, so `ls "/"` stays
|
|
683
|
+
* screened. An unquoted-delimiter heredoc qualifies only without `$(` or
|
|
684
|
+
* backtick expansion in its body. No exemption applies unless the rest of the
|
|
685
|
+
* command cannot run that text (exemptionContextIsSafe).
|
|
686
|
+
*/
|
|
687
|
+
function isProgramTextRootToken(command, start, token, heredocs) {
|
|
688
|
+
if (!isBareRootText(token)) return false;
|
|
689
|
+
const { ok, bodies, segments } = heredocs();
|
|
690
|
+
if (!ok || !exemptionContextIsSafe(command, segments)) return false;
|
|
691
|
+
const body = bodies.find((doc) => start >= doc.start && start < doc.end);
|
|
692
|
+
if (body) {
|
|
693
|
+
const text = command.slice(body.start, body.end);
|
|
694
|
+
if (!body.quoted && /`|\$\(/.test(text)) return false;
|
|
695
|
+
return readsAsText(body.segment, text, MESSAGE_FILE_RE.test(body.segment));
|
|
696
|
+
}
|
|
697
|
+
const span = quotedSpanAt(command, start);
|
|
698
|
+
if (!span) return false;
|
|
699
|
+
const text = command.slice(span[0] + 1, span[1]);
|
|
700
|
+
if (text.trim() === token) return false;
|
|
701
|
+
const prefix = command.slice(span[2], span[0]);
|
|
702
|
+
return readsAsText(prefix, text, MESSAGE_FLAG_RE.test(prefix));
|
|
703
|
+
}
|
|
704
|
+
|
|
314
705
|
/**
|
|
315
706
|
* Pulls path-like references out of a shell command: absolute paths,
|
|
316
707
|
* home-anchored paths (`~...`, `$HOME/...`), and relative `..` traversals.
|
|
317
708
|
* Lexical by design — see the file header for why this is a guardrail, not
|
|
318
709
|
* a parser. URLs survive untouched (the char before `//` in `https://` is
|
|
319
710
|
* `:`, which is not a path delimiter here). Comment and markup text that
|
|
320
|
-
* merely starts with "/" (`/** doc *\/`, `// note`, `</p>`) is not a path
|
|
711
|
+
* merely starts with "/" (`/** doc *\/`, `// note`, `</p>`) is not a path,
|
|
712
|
+
* nor is a token whose first segment names nothing at the filesystem root
|
|
713
|
+
* (routes, regex and sed text), nor bare root text in a heredoc or quoted
|
|
714
|
+
* program body (isProgramTextRootToken).
|
|
321
715
|
*/
|
|
322
716
|
function extractBashPathCandidates(command) {
|
|
323
717
|
const candidates = [];
|
|
324
718
|
const seen = new Set();
|
|
719
|
+
const clean = (raw) => raw.replace(/[)\]}"'`,]+$/, "");
|
|
325
720
|
const add = (raw) => {
|
|
326
|
-
const cleaned = raw
|
|
721
|
+
const cleaned = clean(raw);
|
|
327
722
|
if (!cleaned || seen.has(cleaned)) return;
|
|
328
723
|
seen.add(cleaned);
|
|
329
724
|
candidates.push(cleaned);
|
|
330
725
|
};
|
|
726
|
+
let heredocs;
|
|
727
|
+
const getHeredocs = () => (heredocs ??= scanHeredocs(command));
|
|
331
728
|
|
|
332
729
|
const absoluteRe = /(?:^|[\s'"`=(<>;|&])(\/[^\s'"`<>;|&)]*)/g;
|
|
333
730
|
const homeRe = /(?:^|[\s'"`=(<>;|&])((?:~|\$\{?HOME\}?)[^\s'"`<>;|&)]*)/g;
|
|
@@ -335,6 +732,14 @@ function extractBashPathCandidates(command) {
|
|
|
335
732
|
while ((match = absoluteRe.exec(command))) {
|
|
336
733
|
const start = match.index + match[0].length - match[1].length;
|
|
337
734
|
if (isCommentOrMarkupToken(command, start, match[1])) continue;
|
|
735
|
+
const token = clean(match[1]);
|
|
736
|
+
// Quotes keep `$`, `(` and `{` literal only for a command that does not
|
|
737
|
+
// hand the word to a shell, such as `sed '/^$/d'`; `sh -c '... /$D'` expands.
|
|
738
|
+
const span = quotedSpanAt(command, start);
|
|
739
|
+
const word = span ? commandWordOf(command.slice(span[2], span[0])) : "";
|
|
740
|
+
const quote = LITERAL_PATTERN_COMMANDS.has(word) ? command[span[0]] : "";
|
|
741
|
+
if (!mayNameRootEntry(token, command[start + match[1].length], quote)) continue;
|
|
742
|
+
if (isProgramTextRootToken(command, start, token, getHeredocs)) continue;
|
|
338
743
|
add(match[1]);
|
|
339
744
|
}
|
|
340
745
|
while ((match = homeRe.exec(command))) add(match[1]);
|
|
@@ -3,10 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.claudeConversationRunner = exports.ClaudeStreamParser = exports.CLAUDE_WORKSPACE_TOOLS = exports.CLAUDE_CONVERSATION_TOOLS = void 0;
|
|
4
4
|
exports.claudeConversationArgs = claudeConversationArgs;
|
|
5
5
|
exports.runClaudeConversationTurn = runClaudeConversationTurn;
|
|
6
|
-
const
|
|
7
|
-
const node_perf_hooks_1 = require("node:perf_hooks");
|
|
8
|
-
const kill_agent_cjs_1 = require("../kill-agent.cjs");
|
|
9
|
-
const session_processes_cjs_1 = require("../session-processes.cjs");
|
|
6
|
+
const conversation_process_cjs_1 = require("./conversation-process.cjs");
|
|
10
7
|
/**
|
|
11
8
|
* Read-only tools a Navi repository question may use. Anything not listed
|
|
12
9
|
* (Bash, Edit, Write, WebFetch, Task, MCP tools) is absent from the model's
|
|
@@ -17,7 +14,6 @@ const session_processes_cjs_1 = require("../session-processes.cjs");
|
|
|
17
14
|
*/
|
|
18
15
|
exports.CLAUDE_CONVERSATION_TOOLS = "Read,Grep,Glob";
|
|
19
16
|
exports.CLAUDE_WORKSPACE_TOOLS = "Read,Grep,Glob,Edit,Write,Bash";
|
|
20
|
-
const STDERR_TAIL_BYTES = 8 * 1024;
|
|
21
17
|
/**
|
|
22
18
|
* Incremental parser for `claude -p --output-format stream-json --verbose
|
|
23
19
|
* --include-partial-messages`. Chunks may split a record anywhere. Text is
|
|
@@ -194,90 +190,17 @@ function claudeConversationArgs(options) {
|
|
|
194
190
|
return args;
|
|
195
191
|
}
|
|
196
192
|
async function runClaudeConversationTurn(options) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
let closed = false;
|
|
208
|
-
let stderr = "";
|
|
209
|
-
const deliver = (event) => {
|
|
210
|
-
// After stop, timeout or exit, late output must not reach the caller.
|
|
211
|
-
if (closed || aborted || timedOut)
|
|
212
|
-
return;
|
|
213
|
-
if (event.type === "text" && firstTextMs === null)
|
|
214
|
-
firstTextMs = node_perf_hooks_1.performance.now() - startedAt;
|
|
215
|
-
options.onEvent(event);
|
|
216
|
-
};
|
|
217
|
-
const parser = new ClaudeStreamParser(deliver, () => {
|
|
218
|
-
if (firstEventMs === null)
|
|
219
|
-
firstEventMs = node_perf_hooks_1.performance.now() - startedAt;
|
|
220
|
-
});
|
|
221
|
-
return new Promise((resolve) => {
|
|
222
|
-
const child = (0, node_child_process_1.spawn)(options.bin, claudeConversationArgs(options), {
|
|
223
|
-
detached: process.platform !== "win32",
|
|
224
|
-
cwd: options.cwd,
|
|
225
|
-
env: options.env,
|
|
226
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
227
|
-
});
|
|
228
|
-
child.stdin?.on("error", () => undefined);
|
|
229
|
-
child.stdin?.end(options.prompt);
|
|
230
|
-
options.onProcessStarted?.(child.pid);
|
|
231
|
-
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
232
|
-
timedOut = true;
|
|
233
|
-
(0, kill_agent_cjs_1.killAgent)(child);
|
|
234
|
-
}, options.timeoutMs) : undefined;
|
|
235
|
-
const onAbort = () => {
|
|
236
|
-
aborted = true;
|
|
237
|
-
(0, kill_agent_cjs_1.killAgent)(child);
|
|
238
|
-
};
|
|
239
|
-
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
240
|
-
child.stdout.setEncoding("utf8");
|
|
241
|
-
child.stdout.on("data", (chunk) => {
|
|
242
|
-
parser.push(chunk);
|
|
243
|
-
});
|
|
244
|
-
child.stderr.setEncoding("utf8");
|
|
245
|
-
child.stderr.on("data", (chunk) => {
|
|
246
|
-
stderr = (stderr + chunk).slice(-STDERR_TAIL_BYTES);
|
|
247
|
-
});
|
|
248
|
-
const finish = (exitCode) => {
|
|
249
|
-
if (closed)
|
|
250
|
-
return;
|
|
251
|
-
parser.flush();
|
|
252
|
-
// An answer delivered only in the final result record (no stream deltas).
|
|
253
|
-
if (!parser.hasEmittedText && parser.result && !parser.result.isError && parser.result.text) {
|
|
254
|
-
deliver({ type: "text", text: parser.result.text });
|
|
255
|
-
}
|
|
256
|
-
closed = true;
|
|
257
|
-
clearTimeout(timer);
|
|
258
|
-
options.signal.removeEventListener("abort", onAbort);
|
|
259
|
-
resolve({
|
|
260
|
-
exitCode, timedOut, aborted,
|
|
261
|
-
succeeded: exitCode === 0 && !aborted && !timedOut && !!parser.result && !parser.result.isError,
|
|
262
|
-
firstEventMs, firstTextMs, stderr,
|
|
263
|
-
});
|
|
193
|
+
return (0, conversation_process_cjs_1.runConversationProcess)(options, claudeConversationArgs(options), (emit, onRecord) => {
|
|
194
|
+
const parser = new ClaudeStreamParser(emit, onRecord);
|
|
195
|
+
return {
|
|
196
|
+
push: (chunk) => parser.push(chunk),
|
|
197
|
+
flush: () => {
|
|
198
|
+
parser.flush();
|
|
199
|
+
if (!parser.hasEmittedText && parser.result && !parser.result.isError && parser.result.text)
|
|
200
|
+
emit({ type: "text", text: parser.result.text });
|
|
201
|
+
},
|
|
202
|
+
get succeeded() { return !!parser.result && !parser.result.isError; },
|
|
264
203
|
};
|
|
265
|
-
child.on("error", (err) => {
|
|
266
|
-
stderr = (stderr + `\n${String(err)}`).slice(-STDERR_TAIL_BYTES);
|
|
267
|
-
finish(null);
|
|
268
|
-
});
|
|
269
|
-
let cleanup = Promise.resolve();
|
|
270
|
-
child.on("exit", () => {
|
|
271
|
-
if (options.mode !== "workspace")
|
|
272
|
-
return;
|
|
273
|
-
(0, kill_agent_cjs_1.killAgent)(child);
|
|
274
|
-
// Start on exit, not close: a detached tool may still hold the output
|
|
275
|
-
// pipes open. Reap only this checkout before acknowledging the turn.
|
|
276
|
-
cleanup = (0, session_processes_cjs_1.reapSessionProcesses)(options.cwd);
|
|
277
|
-
});
|
|
278
|
-
child.on("close", (code) => { void cleanup.then(() => finish(code)); });
|
|
279
|
-
if (options.signal.aborted)
|
|
280
|
-
onAbort();
|
|
281
204
|
});
|
|
282
205
|
}
|
|
283
206
|
exports.claudeConversationRunner = { run: runClaudeConversationTurn };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.codexConversationRunner = exports.CodexConversationParser = void 0;
|
|
4
|
+
exports.codexConversationArgs = codexConversationArgs;
|
|
5
|
+
const conversation_process_cjs_1 = require("./conversation-process.cjs");
|
|
6
|
+
/** Codex emits completed assistant messages; reasoning and command output stay local. */
|
|
7
|
+
class CodexConversationParser {
|
|
8
|
+
emit;
|
|
9
|
+
onRecord;
|
|
10
|
+
buffer = "";
|
|
11
|
+
emitted = false;
|
|
12
|
+
seen = new Set();
|
|
13
|
+
completed = false;
|
|
14
|
+
failed = false;
|
|
15
|
+
get succeeded() { return this.completed && !this.failed; }
|
|
16
|
+
constructor(emit, onRecord = () => undefined) {
|
|
17
|
+
this.emit = emit;
|
|
18
|
+
this.onRecord = onRecord;
|
|
19
|
+
}
|
|
20
|
+
push(chunk) {
|
|
21
|
+
this.buffer += chunk;
|
|
22
|
+
if (this.buffer.length > 2_000_000) {
|
|
23
|
+
this.failed = true;
|
|
24
|
+
this.buffer = "";
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let end;
|
|
28
|
+
while ((end = this.buffer.indexOf("\n")) >= 0) {
|
|
29
|
+
this.record(this.buffer.slice(0, end));
|
|
30
|
+
this.buffer = this.buffer.slice(end + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
flush() { if (this.buffer.trim())
|
|
34
|
+
this.record(this.buffer); this.buffer = ""; }
|
|
35
|
+
record(line) {
|
|
36
|
+
let event;
|
|
37
|
+
try {
|
|
38
|
+
event = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!event || typeof event !== "object")
|
|
44
|
+
return;
|
|
45
|
+
this.onRecord();
|
|
46
|
+
if (event.type === "turn.failed" || event.type === "error") {
|
|
47
|
+
this.failed = true;
|
|
48
|
+
this.emit({ type: "failure", reason: "codex_turn_failed" });
|
|
49
|
+
}
|
|
50
|
+
else if (event.type === "turn.completed")
|
|
51
|
+
this.completed = true;
|
|
52
|
+
else if (event.type === "item.completed") {
|
|
53
|
+
const item = event.item;
|
|
54
|
+
if (item?.type !== "agent_message" || typeof item.text !== "string" || !item.text)
|
|
55
|
+
return;
|
|
56
|
+
if (typeof item.id === "string") {
|
|
57
|
+
if (this.seen.has(item.id))
|
|
58
|
+
return;
|
|
59
|
+
this.seen.add(item.id);
|
|
60
|
+
}
|
|
61
|
+
this.emit({ type: "text", text: `${this.emitted ? "\n\n" : ""}${item.text}` });
|
|
62
|
+
this.emitted = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.CodexConversationParser = CodexConversationParser;
|
|
67
|
+
function codexConversationArgs(options) {
|
|
68
|
+
if (!options.codexGuardArgs?.length)
|
|
69
|
+
throw new Error("Codex chat requires a native checkout permission profile.");
|
|
70
|
+
if (options.mode && options.mode !== "read_only" && options.mode !== "workspace")
|
|
71
|
+
throw new Error("Unknown conversation mode.");
|
|
72
|
+
return [
|
|
73
|
+
"exec", "-", "--json", "--ephemeral", "--ignore-rules",
|
|
74
|
+
...options.codexGuardArgs,
|
|
75
|
+
"-c", 'approval_policy="never"',
|
|
76
|
+
"-c", `projects.${JSON.stringify(options.cwd)}.trust_level="untrusted"`,
|
|
77
|
+
"-c", 'mcp_servers={}',
|
|
78
|
+
"-c", 'features.multi_agent=false',
|
|
79
|
+
"-c", 'web_search="disabled"',
|
|
80
|
+
...(options.model ? ["--model", options.model] : []),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
exports.codexConversationRunner = {
|
|
84
|
+
run: (options) => (0, conversation_process_cjs_1.runConversationProcess)(options, codexConversationArgs(options), (emit, onRecord) => new CodexConversationParser(emit, onRecord)),
|
|
85
|
+
};
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.codexAdapter = void 0;
|
|
4
4
|
exports.runCodexAdapter = runCodexAdapter;
|
|
5
5
|
exports.hasExplicitPermissionPolicy = hasExplicitPermissionPolicy;
|
|
6
|
+
const codex_conversation_cjs_1 = require("./codex-conversation.cjs");
|
|
6
7
|
const node_child_process_1 = require("node:child_process");
|
|
7
8
|
const node_fs_1 = require("node:fs");
|
|
8
9
|
const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
@@ -368,6 +369,7 @@ function shellQuote(value) {
|
|
|
368
369
|
exports.codexAdapter = {
|
|
369
370
|
agentType: "codex",
|
|
370
371
|
run: runCodexAdapter,
|
|
372
|
+
conversation: codex_conversation_cjs_1.codexConversationRunner,
|
|
371
373
|
resumesTurns: true,
|
|
372
374
|
steering: types_cjs_1.TURN_RESTART_STEERING,
|
|
373
375
|
// `exec resume` reports the thread's total_token_usage, not the turn's.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runConversationProcess = runConversationProcess;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_perf_hooks_1 = require("node:perf_hooks");
|
|
6
|
+
const kill_agent_cjs_1 = require("../kill-agent.cjs");
|
|
7
|
+
const session_processes_cjs_1 = require("../session-processes.cjs");
|
|
8
|
+
const STDERR_TAIL_BYTES = 8 * 1024;
|
|
9
|
+
async function runConversationProcess(options, args, makeParser) {
|
|
10
|
+
const idle = (aborted) => ({
|
|
11
|
+
exitCode: null, timedOut: false, aborted, succeeded: false, firstEventMs: null, firstTextMs: null, stderr: "",
|
|
12
|
+
});
|
|
13
|
+
if (options.signal.aborted)
|
|
14
|
+
return idle(true);
|
|
15
|
+
const startedAt = node_perf_hooks_1.performance.now();
|
|
16
|
+
let firstEventMs = null;
|
|
17
|
+
let firstTextMs = null;
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
let aborted = false;
|
|
20
|
+
let closed = false;
|
|
21
|
+
let stderr = "";
|
|
22
|
+
const deliver = (event) => {
|
|
23
|
+
// After stop, timeout or exit, late output must not reach the caller.
|
|
24
|
+
if (closed || aborted || timedOut)
|
|
25
|
+
return;
|
|
26
|
+
if (event.type === "text" && firstTextMs === null)
|
|
27
|
+
firstTextMs = node_perf_hooks_1.performance.now() - startedAt;
|
|
28
|
+
options.onEvent(event);
|
|
29
|
+
};
|
|
30
|
+
const parser = makeParser(deliver, () => {
|
|
31
|
+
if (firstEventMs === null)
|
|
32
|
+
firstEventMs = node_perf_hooks_1.performance.now() - startedAt;
|
|
33
|
+
});
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const child = (0, node_child_process_1.spawn)(options.bin, args, {
|
|
36
|
+
detached: process.platform !== "win32",
|
|
37
|
+
cwd: options.cwd,
|
|
38
|
+
env: options.env,
|
|
39
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
40
|
+
});
|
|
41
|
+
child.stdin?.on("error", () => undefined);
|
|
42
|
+
child.stdin?.end(options.prompt);
|
|
43
|
+
options.onProcessStarted?.(child.pid);
|
|
44
|
+
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
45
|
+
timedOut = true;
|
|
46
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
47
|
+
}, options.timeoutMs) : undefined;
|
|
48
|
+
const onAbort = () => {
|
|
49
|
+
aborted = true;
|
|
50
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
51
|
+
};
|
|
52
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
53
|
+
child.stdout.setEncoding("utf8");
|
|
54
|
+
child.stdout.on("data", (chunk) => {
|
|
55
|
+
parser.push(chunk);
|
|
56
|
+
});
|
|
57
|
+
child.stderr.setEncoding("utf8");
|
|
58
|
+
child.stderr.on("data", (chunk) => {
|
|
59
|
+
stderr = (stderr + chunk).slice(-STDERR_TAIL_BYTES);
|
|
60
|
+
});
|
|
61
|
+
const finish = (exitCode) => {
|
|
62
|
+
if (closed)
|
|
63
|
+
return;
|
|
64
|
+
parser.flush();
|
|
65
|
+
closed = true;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
68
|
+
resolve({
|
|
69
|
+
exitCode, timedOut, aborted,
|
|
70
|
+
succeeded: exitCode === 0 && !aborted && !timedOut && parser.succeeded,
|
|
71
|
+
firstEventMs, firstTextMs, stderr,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
child.on("error", (err) => {
|
|
75
|
+
stderr = (stderr + `\n${String(err)}`).slice(-STDERR_TAIL_BYTES);
|
|
76
|
+
finish(null);
|
|
77
|
+
});
|
|
78
|
+
let cleanup = Promise.resolve();
|
|
79
|
+
child.on("exit", () => {
|
|
80
|
+
if (options.mode !== "workspace")
|
|
81
|
+
return;
|
|
82
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
83
|
+
// Start on exit, not close: a detached tool may still hold the output
|
|
84
|
+
// pipes open. Reap only this checkout before acknowledging the turn.
|
|
85
|
+
cleanup = (0, session_processes_cjs_1.reapSessionProcesses)(options.cwd);
|
|
86
|
+
});
|
|
87
|
+
child.on("close", (code) => { void cleanup.then(() => finish(code)); });
|
|
88
|
+
if (options.signal.aborted)
|
|
89
|
+
onAbort();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ConversationLoop = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
4
5
|
const index_cjs_1 = require("../adapters/index.cjs");
|
|
5
6
|
const logger_cjs_1 = require("../logger.cjs");
|
|
6
7
|
const worktree_guard_cjs_1 = require("../worktree-guard.cjs");
|
|
@@ -216,7 +217,7 @@ class ConversationLoop {
|
|
|
216
217
|
machineId,
|
|
217
218
|
journal: this.journal,
|
|
218
219
|
adapter: (0, index_cjs_1.selectAdapter)(config.agentType),
|
|
219
|
-
bin: config.claudeBin,
|
|
220
|
+
bin: config.agentType === "codex" ? config.codexBin : config.claudeBin,
|
|
220
221
|
...(config.model ? { model: config.model } : {}),
|
|
221
222
|
env,
|
|
222
223
|
turnTimeoutMs: config.conversationTurnTimeoutMs,
|
|
@@ -230,6 +231,19 @@ class ConversationLoop {
|
|
|
230
231
|
...(this.options.gitRunner ? { runner: this.options.gitRunner } : {}),
|
|
231
232
|
...(this.mirrors ? { mirrors: this.mirrors } : {}),
|
|
232
233
|
});
|
|
234
|
+
if (config.agentType === "codex") {
|
|
235
|
+
const tmp = (0, worktree_guard_cjs_1.sessionCodexTmpDir)(workspace.metaDir);
|
|
236
|
+
await node_fs_1.promises.mkdir(tmp, { recursive: true, mode: 0o700 });
|
|
237
|
+
return {
|
|
238
|
+
cwd: workspace.checkout, settingsPath: null, env: { TMPDIR: tmp, TMP: tmp, TEMP: tmp },
|
|
239
|
+
codexGuardArgs: (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
|
|
240
|
+
workDir: workspace.metaDir, worktreePath: workspace.checkout,
|
|
241
|
+
repositoryPath: workspace.checkout, workspaceRoot: config.workspaceRoot,
|
|
242
|
+
// Chat never imports operator extra roots or task credentials.
|
|
243
|
+
codexBin: config.codexBin, readOnly: mode !== "workspace",
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
233
247
|
const guard = await (0, worktree_guard_cjs_1.prepareWorktreeGuard)({
|
|
234
248
|
workDir: workspace.metaDir,
|
|
235
249
|
worktreePath: workspace.checkout,
|
|
@@ -32,7 +32,7 @@ const WORKSPACE_INSTRUCTIONS = [
|
|
|
32
32
|
* session, so a follow-up works the same after a runtime restart and no
|
|
33
33
|
* unbounded transcript is kept on disk.
|
|
34
34
|
*/
|
|
35
|
-
function buildConversationPrompt(snapshot, turnId) {
|
|
35
|
+
function buildConversationPrompt(snapshot, turnId, readCommands = false) {
|
|
36
36
|
const index = snapshot.turns.findIndex((turn) => turn.id === turnId);
|
|
37
37
|
const current = snapshot.turns[index];
|
|
38
38
|
if (!current || current.status !== "running")
|
|
@@ -56,7 +56,9 @@ function buildConversationPrompt(snapshot, turnId) {
|
|
|
56
56
|
}
|
|
57
57
|
const omitted = prior.length - kept.length;
|
|
58
58
|
return [
|
|
59
|
-
snapshot.mode === "workspace" ? WORKSPACE_INSTRUCTIONS :
|
|
59
|
+
snapshot.mode === "workspace" ? WORKSPACE_INSTRUCTIONS : readCommands
|
|
60
|
+
? INSTRUCTIONS.replace("You cannot edit files, run commands,", "You may run sandboxed read-only inspection commands. You cannot edit files, run mutating commands,")
|
|
61
|
+
: INSTRUCTIONS,
|
|
60
62
|
kept.length || omitted
|
|
61
63
|
? `\nEarlier conversation${omitted ? ` (${omitted} older turn${omitted === 1 ? "" : "s"} omitted)` : ""}:\n${kept.join("\n\n")}`
|
|
62
64
|
: "",
|
|
@@ -297,7 +297,7 @@ async function runConversationTurn(deps, conversationId, turnId) {
|
|
|
297
297
|
throw new Error("Unknown conversation mode");
|
|
298
298
|
if (snapshot.mode === "workspace" && !deps.workspaceEnabled)
|
|
299
299
|
throw new Error("Workspace chat is disabled on this machine");
|
|
300
|
-
prompt = (0, prompt_cjs_1.buildConversationPrompt)(snapshot, journal.turnId);
|
|
300
|
+
prompt = (0, prompt_cjs_1.buildConversationPrompt)(snapshot, journal.turnId, deps.adapter.agentType === "codex");
|
|
301
301
|
// Older servers omit the working branch: the checkout stays on the default branch.
|
|
302
302
|
const workingBranch = (0, workspace_cjs_1.validWorkingBranch)(claim.workingBranch);
|
|
303
303
|
prepared = await deps.prepareWorkspace({ conversationId, repositoryUrl, workingBranch, mode: snapshot.mode ?? "read_only", signal: controller.signal });
|
|
@@ -313,7 +313,7 @@ async function runConversationTurn(deps, conversationId, turnId) {
|
|
|
313
313
|
run.launchedAt = node_perf_hooks_1.performance.now();
|
|
314
314
|
const result = await deps.adapter.conversation.run({
|
|
315
315
|
prompt, bin: deps.bin, ...(deps.model ? { model: deps.model } : {}),
|
|
316
|
-
cwd: prepared.cwd, env: deps.env, settingsPath: prepared.settingsPath, mode: snapshot?.mode ?? "read_only",
|
|
316
|
+
cwd: prepared.cwd, env: { ...deps.env, ...prepared.env }, settingsPath: prepared.settingsPath, codexGuardArgs: prepared.codexGuardArgs, mode: snapshot?.mode ?? "read_only",
|
|
317
317
|
timeoutMs: deps.turnTimeoutMs, signal: controller.signal, onEvent,
|
|
318
318
|
});
|
|
319
319
|
metrics = { firstEventMs: result.firstEventMs, firstTextMs: result.firstTextMs };
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -423,8 +423,8 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
|
|
|
423
423
|
":slash_tmp": "write",
|
|
424
424
|
[workspaceRoot]: "deny",
|
|
425
425
|
}),
|
|
426
|
-
[node_path_1.default.resolve(options.worktreePath)]: "write",
|
|
427
|
-
[node_path_1.default.resolve(options.repositoryPath)]: "write",
|
|
426
|
+
[node_path_1.default.resolve(options.worktreePath)]: options.readOnly ? "read" : "write",
|
|
427
|
+
[node_path_1.default.resolve(options.repositoryPath)]: options.readOnly ? "read" : "write",
|
|
428
428
|
[sessionGhConfigDir(options.workDir)]: "read",
|
|
429
429
|
};
|
|
430
430
|
// git runs the credential helper inside the sandbox. Without these grants
|
|
@@ -443,7 +443,7 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
|
|
|
443
443
|
// the runtime-created gitdir explicitly, without reopening session metadata.
|
|
444
444
|
const gitdir = linkedWorktreeGitDir(options.worktreePath, options.repositoryPath);
|
|
445
445
|
if (gitdir)
|
|
446
|
-
filesystem[gitdir] = "write";
|
|
446
|
+
filesystem[gitdir] = options.readOnly ? "read" : "write";
|
|
447
447
|
const canonicalWorkspace = canonicalPathWithMissingTail(workspaceRoot);
|
|
448
448
|
const overlapsWorkspace = (root) => {
|
|
449
449
|
const canonicalRoot = canonicalPathWithMissingTail(root);
|
|
@@ -469,7 +469,7 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
|
|
|
469
469
|
// reopen sibling sessions or metadata inside the Navarch workspace.
|
|
470
470
|
if (overlapsWorkspace(resolved))
|
|
471
471
|
continue;
|
|
472
|
-
filesystem[resolved] = "write";
|
|
472
|
+
filesystem[resolved] = options.readOnly ? "read" : "write";
|
|
473
473
|
}
|
|
474
474
|
return [
|
|
475
475
|
"--ignore-user-config",
|
|
@@ -489,7 +489,7 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
|
|
|
489
489
|
// Auto-review escalation, while leaving every non-GitHub destination
|
|
490
490
|
// blocked. The profile still constrains filesystem access independently.
|
|
491
491
|
"-c",
|
|
492
|
-
`permissions.${CODEX_GUARD_PROFILE}.network.enabled
|
|
492
|
+
`permissions.${CODEX_GUARD_PROFILE}.network.enabled=${!options.readOnly}`,
|
|
493
493
|
"-c",
|
|
494
494
|
`permissions.${CODEX_GUARD_PROFILE}.network.domains=${tomlInlineTable({
|
|
495
495
|
...CODEX_GITHUB_NETWORK_DOMAINS,
|
package/package.json
CHANGED