codeden 0.6.2 → 0.7.0
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 +12 -5
- package/dist/cli.js +1 -1
- package/dist/drizzle/20261112140714_turn_fork_point/migration.sql +1 -0
- package/dist/drizzle/20261112140714_turn_fork_point/snapshot.json +3524 -0
- package/dist/drizzle/20261113075317_transcript_search/migration.sql +29 -0
- package/dist/drizzle/20261113075317_transcript_search/snapshot.json +3635 -0
- package/dist/drizzle/20261113083655_runners/migration.sql +17 -0
- package/dist/drizzle/20261113083655_runners/snapshot.json +3792 -0
- package/dist/drizzle/20261113165404_search_newest_first/migration.sql +43 -0
- package/dist/drizzle/20261113165404_search_newest_first/snapshot.json +3785 -0
- package/dist/{main-hdQfX9V6.js → main-BITVD_Bx.js} +7311 -3019
- package/dist/main-BITVD_Bx.js.map +1 -0
- package/dist/{main-DZBmzxSH.js → main-NN0iK2tB.js} +2 -2
- package/dist/{main-DZBmzxSH.js.map → main-NN0iK2tB.js.map} +1 -1
- package/dist/{mediaType-nJs-4z5U.js → mediaType-N0hKQqG9.js} +2 -2
- package/dist/{mediaType-nJs-4z5U.js.map → mediaType-N0hKQqG9.js.map} +1 -1
- package/dist/{runner-DYf8dV43.js → runner-CQYh087M.js} +1948 -385
- package/dist/runner-CQYh087M.js.map +1 -0
- package/dist/runner.js +1 -1
- package/dist/{server-test-COAiKxTV.js → server-test-BwyCFssm.js} +3 -3
- package/dist/{server-test-COAiKxTV.js.map → server-test-BwyCFssm.js.map} +1 -1
- package/dist/{tree-CF7Q8tXK.js → tree-Bjv-rXir.js} +638 -41
- package/dist/tree-Bjv-rXir.js.map +1 -0
- package/dist/web/assets/{core-Rhf-hNq6.js → core-EsMgAasL.js} +1 -1
- package/dist/web/assets/engine-oniguruma-C2eJcn5h.js +1 -0
- package/dist/web/assets/{highlighted-body-JLMTFACH-DcKwRTm5.js → highlighted-body-JLMTFACH-Co1oXN1b.js} +1 -1
- package/dist/web/assets/{highlights-DCtMC0YR.js → highlights-DUjJZrPQ.js} +1 -1
- package/dist/web/assets/index-29XyPQXD.css +1 -0
- package/dist/web/assets/index-DK4GsIYY.js +943 -0
- package/dist/web/assets/langs-BduZAjBk.js +1 -0
- package/dist/web/assets/mermaid-WFAXDIZX-D00b7K53.js +1 -0
- package/dist/web/assets/{shiki-B3-ws3OK.js → shiki-DP8JfDZC.js} +2 -2
- package/dist/web/assets/{stream-tokenizer-hKFrMxml.js → stream-tokenizer-IR1cpBIQ.js} +1 -1
- package/dist/web/assets/{themes-Cvhh4Ovr.js → themes-D2aixyve.js} +2 -2
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/main-hdQfX9V6.js.map +0 -1
- package/dist/runner-DYf8dV43.js.map +0 -1
- package/dist/tree-CF7Q8tXK.js.map +0 -1
- package/dist/web/assets/engine-oniguruma-BbpT993F.js +0 -1
- package/dist/web/assets/index-BLggvVMT.js +0 -942
- package/dist/web/assets/index-JjF9o00P.css +0 -1
- package/dist/web/assets/langs-B2ZGl3f_.js +0 -1
- package/dist/web/assets/mermaid-WFAXDIZX-C2_MUy_a.js +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as __require, r as __toESM, t as __commonJSMin } from "./rolldown-runtime-DegdyBIC.js";
|
|
2
|
-
import {
|
|
2
|
+
import { $n as ProjectConfigFile, Hn as SETUP_TAIL_LIMIT, Un as BROWSER_RELAY_SECRET_HEADER, Xn as ProviderCapabilities, Yn as CODEDEN_CREDENTIAL_FILES, Zn as RuntimeResponse, a as childEnv, d as RunnerEventMessage, dr as mergeItemDeltas, f as RunnerLock, gr as UserInput, h as ServerToRunnerMessage, hr as itemDeltaText, i as listDescendants, lr as AgentEvent, mr as winningCommands, n as killTree$1, o as CODEDEN_VERSION, pr as oneLineDescription, qn as MAX_FOCUS_TYPE_CHARS, t as isAlive$1, zr as ReasoningEffort } from "./tree-Bjv-rXir.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { accessSync, appendFileSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
@@ -3612,6 +3612,8 @@ var import_websocket = /* @__PURE__ */ __toESM(require_websocket(), 1);
|
|
|
3612
3612
|
var import_websocket_server = /* @__PURE__ */ __toESM(require_websocket_server(), 1);
|
|
3613
3613
|
var wrapper_default = import_websocket.default;
|
|
3614
3614
|
// ../../packages/providers/src/adapter.ts
|
|
3615
|
+
/** `ProviderSessionError` code: the conversation doesn't hold the fork point it was asked for. */
|
|
3616
|
+
const FORK_POINT_MISSING = "fork_point_missing";
|
|
3615
3617
|
/**
|
|
3616
3618
|
* Session options ready to open (`SessionOptions.forkFrom`): with `forkFrom` and no
|
|
3617
3619
|
* `resumeToken`, the conversation is copied first (`forkConversation`) and the session resumes
|
|
@@ -4513,6 +4515,52 @@ function parseClaudeAuthStatus(stdout) {
|
|
|
4513
4515
|
return unknown;
|
|
4514
4516
|
}
|
|
4515
4517
|
}
|
|
4518
|
+
// ../../packages/providers/src/claude/fork.ts
|
|
4519
|
+
const reasonOf = (error) => error instanceof Error ? error.message : String(error);
|
|
4520
|
+
/**
|
|
4521
|
+
* Copy a Claude conversation with the SDK's `forkSession()`: the transcript is written to a new
|
|
4522
|
+
* session file at once (fresh message ids, the original untouched) and the new session id is
|
|
4523
|
+
* returned for resuming. With `upTo` (a turn's fork point: its last transcript message), the
|
|
4524
|
+
* copy ends with that message. A conversation that no longer holds it (replaced or deleted)
|
|
4525
|
+
* throws `FORK_POINT_MISSING` before anything is copied, so it never becomes a whole copy
|
|
4526
|
+
* instead: `confirmForkPoint` refuses it from the transcript read when it can, and otherwise
|
|
4527
|
+
* `forkSession()` does, as it slices the whole transcript file and refuses a message the file
|
|
4528
|
+
* doesn't hold before writing anything (SDK 0.3.318: `Message <uuid> not found in session <id>`).
|
|
4529
|
+
*/
|
|
4530
|
+
async function forkClaudeConversation(sdk, resumeToken, upTo) {
|
|
4531
|
+
if (upTo) await confirmForkPoint(sdk, resumeToken, upTo);
|
|
4532
|
+
try {
|
|
4533
|
+
return (await sdk.forkSession(resumeToken, upTo ? { upToMessageId: upTo } : void 0)).sessionId;
|
|
4534
|
+
} catch (error) {
|
|
4535
|
+
if (upTo && refusedForkPoint(error, upTo)) throw forkPointMissing();
|
|
4536
|
+
throw new Error(`Could not fork the Claude conversation ${resumeToken}: ${reasonOf(error)}`);
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
/**
|
|
4540
|
+
* Read the transcript (`getSessionMessages`) and refuse a fork point it doesn't hold. That read
|
|
4541
|
+
* follows `parentUuid` links back from the newest message, and a context compaction's boundary
|
|
4542
|
+
* has none, so in a compacted conversation it starts at the latest boundary (a `system`
|
|
4543
|
+
* message) and leaves out every earlier message, although the transcript file still holds
|
|
4544
|
+
* them. A fork point missing from such a read may be before the compaction, so `forkSession()`
|
|
4545
|
+
* decides then. An empty read (a conversation that was deleted) holds nothing.
|
|
4546
|
+
*/
|
|
4547
|
+
async function confirmForkPoint(sdk, resumeToken, upTo) {
|
|
4548
|
+
let messages;
|
|
4549
|
+
try {
|
|
4550
|
+
messages = await sdk.getSessionMessages(resumeToken, { includeSystemMessages: true });
|
|
4551
|
+
} catch (error) {
|
|
4552
|
+
throw new Error(`Could not read the Claude conversation ${resumeToken}: ${reasonOf(error)}`);
|
|
4553
|
+
}
|
|
4554
|
+
if (!(messages[0]?.type === "system") && !messages.some((m) => m.uuid === upTo)) throw forkPointMissing();
|
|
4555
|
+
}
|
|
4556
|
+
/** Whether `forkSession()` refused `upTo` as a message its transcript doesn't hold. */
|
|
4557
|
+
function refusedForkPoint(error, upTo) {
|
|
4558
|
+
const reason = reasonOf(error);
|
|
4559
|
+
return reason.includes(upTo) && /\bnot found\b/i.test(reason);
|
|
4560
|
+
}
|
|
4561
|
+
function forkPointMissing() {
|
|
4562
|
+
return new ProviderSessionError(FORK_POINT_MISSING, "The conversation no longer holds the end of that turn (it was replaced or deleted), so it can't be forked there");
|
|
4563
|
+
}
|
|
4516
4564
|
// ../../packages/providers/src/util/model.ts
|
|
4517
4565
|
/** The id the model pickers use for "the provider's own default model". */
|
|
4518
4566
|
const DEFAULT_MODEL_ID = "default";
|
|
@@ -4524,8 +4572,26 @@ function explicitModel(model) {
|
|
|
4524
4572
|
const id = model?.trim();
|
|
4525
4573
|
return id && id !== "default" ? id : void 0;
|
|
4526
4574
|
}
|
|
4575
|
+
// ../../packages/providers/src/policy/assessment.ts
|
|
4576
|
+
const SAFE = { risky: false };
|
|
4577
|
+
const risky = (action, reason) => ({
|
|
4578
|
+
risky: true,
|
|
4579
|
+
action,
|
|
4580
|
+
reason
|
|
4581
|
+
});
|
|
4582
|
+
/** True for a verdict on CodeDen's own credential files. */
|
|
4583
|
+
const touchesCodeden = (risk) => risk.risky && risk.codeden === true;
|
|
4584
|
+
/**
|
|
4585
|
+
* The verdict for several checks of one action: CodeDen's own credentials first (a level may
|
|
4586
|
+
* allow an ordinary risk that comes before them), then the first risky one, else safe.
|
|
4587
|
+
*/
|
|
4588
|
+
function mostSevere(risks) {
|
|
4589
|
+
return risks.find(touchesCodeden) ?? risks.find((r) => r.risky) ?? SAFE;
|
|
4590
|
+
}
|
|
4527
4591
|
// ../../packages/providers/src/policy/paths.ts
|
|
4592
|
+
/** True when the context's paths are Windows paths. */
|
|
4528
4593
|
const isWin = (ctx) => (ctx.platform ?? process.platform) === "win32";
|
|
4594
|
+
/** The path functions for the context's platform. */
|
|
4529
4595
|
function pathApi(ctx) {
|
|
4530
4596
|
return isWin(ctx) ? path.win32 : path.posix;
|
|
4531
4597
|
}
|
|
@@ -4542,6 +4608,8 @@ const HOME_VARS = /^(\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=$|[\\/
|
|
|
4542
4608
|
* printed) is not the temp directory; PowerShell's and cmd's names are not.
|
|
4543
4609
|
*/
|
|
4544
4610
|
const TEMP_VARS = [/^(\$TMPDIR|\$\{TMPDIR\}|\$TEMP|\$\{TEMP\}|\$TMP|\$\{TMP\})(?=$|[\\/])/, /^(\$env:TEMP|\$env:TMP|%TEMP%|%TMP%)(?=$|[\\/])/i];
|
|
4611
|
+
/** CodeDen's home variable, which agents inherit: case-sensitive in POSIX shells only. */
|
|
4612
|
+
const CODEDEN_HOME_VARS = [/^(\$CODEDEN_HOME|\$\{CODEDEN_HOME\})(?=$|[\\/])/, /^(\$env:CODEDEN_HOME|%CODEDEN_HOME%)(?=$|[\\/])/i];
|
|
4545
4613
|
/**
|
|
4546
4614
|
* Git Bash paths on Windows: `/c/Users/x` is `C:\Users\x`, and `/tmp` is the user's temp
|
|
4547
4615
|
* directory.
|
|
@@ -4553,9 +4621,9 @@ function fromGitBash(value, temp) {
|
|
|
4553
4621
|
return value;
|
|
4554
4622
|
}
|
|
4555
4623
|
/**
|
|
4556
|
-
* Resolve `target` (absolute, relative to `dir`, `~`-prefixed, or starting with a home or
|
|
4557
|
-
*
|
|
4558
|
-
* Bash form too: the shell reports `/c/Users/x/repo` once a command has run in it.
|
|
4624
|
+
* Resolve `target` (absolute, relative to `dir`, `~`-prefixed, or starting with a home, temp or
|
|
4625
|
+
* CodeDen home variable) to an absolute path. On Windows the directory it resolves from may be
|
|
4626
|
+
* in Git Bash form too: the shell reports `/c/Users/x/repo` once a command has run in it.
|
|
4559
4627
|
*/
|
|
4560
4628
|
function resolveAgainst(target, ctx) {
|
|
4561
4629
|
const p = pathApi(ctx);
|
|
@@ -4563,6 +4631,8 @@ function resolveAgainst(target, ctx) {
|
|
|
4563
4631
|
const home = homeOf(ctx);
|
|
4564
4632
|
if (value === "~" || value.startsWith("~/") || value.startsWith("~\\")) value = home + value.slice(1);
|
|
4565
4633
|
value = value.replace(HOME_VARS, home);
|
|
4634
|
+
const codeden = ctx.codedenHome;
|
|
4635
|
+
if (codeden) for (const vars of CODEDEN_HOME_VARS) value = value.replace(vars, codeden);
|
|
4566
4636
|
const temp = tempRootsOf(ctx)[0];
|
|
4567
4637
|
if (temp) for (const vars of TEMP_VARS) value = value.replace(vars, temp);
|
|
4568
4638
|
let base = ctx.dir ?? ctx.cwd;
|
|
@@ -4582,6 +4652,10 @@ function relative$1(root, target, ctx) {
|
|
|
4582
4652
|
const norm = (s) => isWin(ctx) ? p.resolve(s).toLowerCase() : p.resolve(s);
|
|
4583
4653
|
return p.relative(norm(root), norm(target));
|
|
4584
4654
|
}
|
|
4655
|
+
/** True when two absolute paths name the same file. Case-insensitive on Windows. */
|
|
4656
|
+
function samePath(a, b, ctx) {
|
|
4657
|
+
return relative$1(a, b, ctx) === "";
|
|
4658
|
+
}
|
|
4585
4659
|
/** True when `target` is inside the worktree or one of the extra roots. */
|
|
4586
4660
|
function isInWorkspace(target, ctx) {
|
|
4587
4661
|
const abs = resolveAgainst(target, ctx);
|
|
@@ -4698,13 +4772,6 @@ function isSecretPath(target) {
|
|
|
4698
4772
|
if (SECRET_EXTENSIONS.some((ext) => base.endsWith(ext))) return true;
|
|
4699
4773
|
return /(^|\/)\.(ssh|aws|gnupg|docker)\//i.test(normalized) && !base.endsWith(".pub");
|
|
4700
4774
|
}
|
|
4701
|
-
// ../../packages/providers/src/policy/assessment.ts
|
|
4702
|
-
const SAFE = { risky: false };
|
|
4703
|
-
const risky = (action, reason) => ({
|
|
4704
|
-
risky: true,
|
|
4705
|
-
action,
|
|
4706
|
-
reason
|
|
4707
|
-
});
|
|
4708
4775
|
// ../../packages/providers/src/policy/git.ts
|
|
4709
4776
|
/**
|
|
4710
4777
|
* Git rules of the risk classifier (§11.2): force pushes, deleting refs, global config. `args`
|
|
@@ -5655,6 +5722,590 @@ function unixToIso(value) {
|
|
|
5655
5722
|
const ms = value < 0xe8d4a51000 ? value * 1e3 : value;
|
|
5656
5723
|
return new Date(ms).toISOString();
|
|
5657
5724
|
}
|
|
5725
|
+
// ../../packages/providers/src/policy/codeden-home.ts
|
|
5726
|
+
/**
|
|
5727
|
+
* CodeDen's own credential files (§15): the install token, the runner lock and the remote
|
|
5728
|
+
* access store in its data directory. An agent holding the install token or the runner's token
|
|
5729
|
+
* could drive CodeDen itself and approve its own requests, so touching one asks at every level,
|
|
5730
|
+
* Full included, and is refused for read-only sessions (§11.2). The data directory comes from
|
|
5731
|
+
* the session (`SessionOptions.codedenHome`); without it, this rule is off.
|
|
5732
|
+
*/
|
|
5733
|
+
/** A risky verdict on CodeDen's own credentials, which every level asks about. */
|
|
5734
|
+
function credentialsRisk(reason) {
|
|
5735
|
+
return {
|
|
5736
|
+
risky: true,
|
|
5737
|
+
action: "secrets",
|
|
5738
|
+
reason,
|
|
5739
|
+
codeden: true
|
|
5740
|
+
};
|
|
5741
|
+
}
|
|
5742
|
+
/** The credential files in the session's CodeDen home, as absolute paths (none without one). */
|
|
5743
|
+
function codedenCredentialFiles(ctx) {
|
|
5744
|
+
const home = ctx.codedenHome;
|
|
5745
|
+
if (!home) return [];
|
|
5746
|
+
const p = pathApi(ctx);
|
|
5747
|
+
return CODEDEN_CREDENTIAL_FILES.map((file) => p.join(home, file));
|
|
5748
|
+
}
|
|
5749
|
+
/** The credential files, then the folders that hold them. */
|
|
5750
|
+
function guardedPaths(ctx) {
|
|
5751
|
+
const files = codedenCredentialFiles(ctx);
|
|
5752
|
+
const p = pathApi(ctx);
|
|
5753
|
+
return [...files, ...new Set(files.map((file) => p.dirname(file)))];
|
|
5754
|
+
}
|
|
5755
|
+
const WILDCARD = /[*?[]/;
|
|
5756
|
+
/** `c` matched as itself in a regular expression. */
|
|
5757
|
+
const literalChar = (c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5758
|
+
/**
|
|
5759
|
+
* A shell wildcard (`*`, `?`, `[...]`, and `**`, which before a slash stands for zero or more
|
|
5760
|
+
* folders) over a `/`-separated path as a regular expression. Its first `literal` characters
|
|
5761
|
+
* match as themselves. Undefined when malformed.
|
|
5762
|
+
*/
|
|
5763
|
+
function wildcardPattern(pattern, ignoreCase, literal = 0) {
|
|
5764
|
+
let source = pattern.slice(0, literal).replace(/[\s\S]/g, literalChar);
|
|
5765
|
+
for (let i = literal; i < pattern.length; i += 1) {
|
|
5766
|
+
const c = pattern[i];
|
|
5767
|
+
if (c === "*" && pattern[i + 1] === "*") {
|
|
5768
|
+
const folders = pattern[i + 2] === "/";
|
|
5769
|
+
source += folders ? "(?:.*/)?" : ".*";
|
|
5770
|
+
i += folders ? 2 : 1;
|
|
5771
|
+
} else if (c === "*") source += "[^/]*";
|
|
5772
|
+
else if (c === "?") source += "[^/]";
|
|
5773
|
+
else if (c === "[" && pattern.indexOf("]", i + 2) > 0) {
|
|
5774
|
+
const end = pattern.indexOf("]", i + 2);
|
|
5775
|
+
const body = pattern.slice(i + 1, end).replace(/^!/, "^").replace(/\\/g, "\\\\");
|
|
5776
|
+
source += `[${body}]`;
|
|
5777
|
+
i = end;
|
|
5778
|
+
} else source += literalChar(c);
|
|
5779
|
+
}
|
|
5780
|
+
try {
|
|
5781
|
+
return new RegExp(`^${source}$`, ignoreCase ? "i" : "");
|
|
5782
|
+
} catch {
|
|
5783
|
+
return;
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
/** `/`-separated, for wildcard matching; Windows paths match in any case. */
|
|
5787
|
+
function slashes(value, ctx) {
|
|
5788
|
+
return isWin(ctx) ? value.replace(/\\/g, "/") : value;
|
|
5789
|
+
}
|
|
5790
|
+
/**
|
|
5791
|
+
* Variables left in a resolved path (`$f`, `${f}`, `$env:F`, `%F%`) could be any name, so they
|
|
5792
|
+
* match like `*`. `$CODEDEN_HOME`, `$HOME` and the temp variables at the start resolve first.
|
|
5793
|
+
*/
|
|
5794
|
+
const PATH_VARIABLE = /\$\{\w+\}|\$env:\w+|\$\w+|%\w+%/gi;
|
|
5795
|
+
/**
|
|
5796
|
+
* How many leading characters of `abs` are CodeDen's home or a folder above it, at a folder
|
|
5797
|
+
* boundary. That part comes from the session (`$CODEDEN_HOME`, `~`, the working directory), so it
|
|
5798
|
+
* is always literal: `[`, `*`, `?` or `$` in the home's own path (`CodeDen [dev]`) are never a
|
|
5799
|
+
* pattern. Compared like `samePath`, in any case on Windows.
|
|
5800
|
+
*/
|
|
5801
|
+
function literalPrefix(abs, ctx) {
|
|
5802
|
+
const home = ctx.codedenHome;
|
|
5803
|
+
if (!home) return 0;
|
|
5804
|
+
const p = pathApi(ctx);
|
|
5805
|
+
const value = slashes(abs, ctx);
|
|
5806
|
+
const fold = (s) => isWin(ctx) ? s.toLowerCase() : s;
|
|
5807
|
+
for (let dir = p.resolve(home);; dir = p.dirname(dir)) {
|
|
5808
|
+
const prefix = slashes(dir, ctx).replace(/\/$/, "");
|
|
5809
|
+
const n = prefix.length;
|
|
5810
|
+
const atBoundary = value.length === n || value[n] === "/";
|
|
5811
|
+
if (n > 0 && atBoundary && fold(value.slice(0, n)) === fold(prefix)) return n;
|
|
5812
|
+
if (p.dirname(dir) === dir) return 0;
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
/** `target` as an absolute path, the unknown variables it adds as wildcards. */
|
|
5816
|
+
function resolvePattern(target, ctx) {
|
|
5817
|
+
const abs = resolveAgainst(target, ctx);
|
|
5818
|
+
const literal = literalPrefix(abs, ctx);
|
|
5819
|
+
return abs.slice(0, literal) + abs.slice(literal).replace(PATH_VARIABLE, "*");
|
|
5820
|
+
}
|
|
5821
|
+
/**
|
|
5822
|
+
* The wildcards in `abs` (from `resolvePattern`) as a regular expression over `/`-separated
|
|
5823
|
+
* paths, or undefined when it has none (or is malformed). Only the agent's part can be a pattern:
|
|
5824
|
+
* CodeDen's home, and the folders above it, match literally (`literalPrefix`).
|
|
5825
|
+
*/
|
|
5826
|
+
function agentPattern(abs, ctx) {
|
|
5827
|
+
const value = slashes(abs, ctx);
|
|
5828
|
+
const literal = literalPrefix(abs, ctx);
|
|
5829
|
+
if (!WILDCARD.test(value.slice(literal))) return void 0;
|
|
5830
|
+
return wildcardPattern(value, isWin(ctx), literal);
|
|
5831
|
+
}
|
|
5832
|
+
/**
|
|
5833
|
+
* True when `target` reaches one of CodeDen's credential files: the file itself, a folder that
|
|
5834
|
+
* holds one (`<home>`, `<home>/runner`: copying or searching it reads them), or a wildcard that
|
|
5835
|
+
* matches either. `~`, `$HOME`, `$CODEDEN_HOME` (and its PowerShell and cmd forms) and relative
|
|
5836
|
+
* paths resolve as in the shell. The home itself is always compared literally.
|
|
5837
|
+
*/
|
|
5838
|
+
function touchesCodedenCredentials(target, ctx) {
|
|
5839
|
+
if (!ctx.codedenHome || !target.trim()) return false;
|
|
5840
|
+
const abs = resolvePattern(target, ctx);
|
|
5841
|
+
const guarded = guardedPaths(ctx);
|
|
5842
|
+
if (guarded.some((p) => samePath(p, abs, ctx))) return true;
|
|
5843
|
+
const pattern = agentPattern(abs, ctx);
|
|
5844
|
+
return pattern !== void 0 && guarded.some((p) => pattern.test(slashes(p, ctx)));
|
|
5845
|
+
}
|
|
5846
|
+
// ../../packages/providers/src/policy/search-words.ts
|
|
5847
|
+
/**
|
|
5848
|
+
* Search commands (`grep`, `rg`, `findstr`, `Select-String`, ...) read as the tool reads them, for
|
|
5849
|
+
* the risk classifier (§11.2). Each word gets one role, once: an option, an option's value or an
|
|
5850
|
+
* operand. The pattern is an option's pattern value, or else the first operand. Because roles are
|
|
5851
|
+
* fixed in one pass, a value that looks like an option (`rg -r-e`: replacement text `-e`) is never
|
|
5852
|
+
* read as one, and a word that looks like a pattern option is never skipped as a value.
|
|
5853
|
+
*/
|
|
5854
|
+
/** Search commands, whose first operand is the pattern (`grep -rn "process.env" src`). */
|
|
5855
|
+
const SEARCHES = /* @__PURE__ */ new Set([
|
|
5856
|
+
"grep",
|
|
5857
|
+
"egrep",
|
|
5858
|
+
"fgrep",
|
|
5859
|
+
"zgrep",
|
|
5860
|
+
"rg",
|
|
5861
|
+
"ag",
|
|
5862
|
+
"ack",
|
|
5863
|
+
"findstr",
|
|
5864
|
+
"select-string",
|
|
5865
|
+
"sls"
|
|
5866
|
+
]);
|
|
5867
|
+
/** Options that take the next word as their value, per search tool. */
|
|
5868
|
+
const GREP_VALUED = [
|
|
5869
|
+
"-A",
|
|
5870
|
+
"-B",
|
|
5871
|
+
"-C",
|
|
5872
|
+
"-m",
|
|
5873
|
+
"-d",
|
|
5874
|
+
"-D",
|
|
5875
|
+
"--after-context",
|
|
5876
|
+
"--before-context",
|
|
5877
|
+
"--context",
|
|
5878
|
+
"--max-count",
|
|
5879
|
+
"--include",
|
|
5880
|
+
"--exclude",
|
|
5881
|
+
"--exclude-dir",
|
|
5882
|
+
"--label",
|
|
5883
|
+
"--binary-files",
|
|
5884
|
+
"--devices",
|
|
5885
|
+
"--directories"
|
|
5886
|
+
];
|
|
5887
|
+
const RG_VALUED = [
|
|
5888
|
+
...GREP_VALUED,
|
|
5889
|
+
"-g",
|
|
5890
|
+
"--glob",
|
|
5891
|
+
"--iglob",
|
|
5892
|
+
"-t",
|
|
5893
|
+
"--type",
|
|
5894
|
+
"-T",
|
|
5895
|
+
"--type-not",
|
|
5896
|
+
"-j",
|
|
5897
|
+
"--threads",
|
|
5898
|
+
"--max-depth",
|
|
5899
|
+
"-M",
|
|
5900
|
+
"--max-columns",
|
|
5901
|
+
"--sort",
|
|
5902
|
+
"--sortr",
|
|
5903
|
+
"-r",
|
|
5904
|
+
"--replace",
|
|
5905
|
+
"--colors",
|
|
5906
|
+
"-E",
|
|
5907
|
+
"--encoding"
|
|
5908
|
+
];
|
|
5909
|
+
const SELECT_STRING_VALUED = [
|
|
5910
|
+
"-path",
|
|
5911
|
+
"-literalpath",
|
|
5912
|
+
"-include",
|
|
5913
|
+
"-exclude",
|
|
5914
|
+
"-encoding",
|
|
5915
|
+
"-context"
|
|
5916
|
+
];
|
|
5917
|
+
/** Tools whose option names are matched in any case (names are kept in lower case). */
|
|
5918
|
+
const ANY_CASE = /* @__PURE__ */ new Set([
|
|
5919
|
+
"findstr",
|
|
5920
|
+
"select-string",
|
|
5921
|
+
"sls"
|
|
5922
|
+
]);
|
|
5923
|
+
/**
|
|
5924
|
+
* Tools whose short options combine in one word, the getopt way (`-ne.` is `-n -e .`), and whose
|
|
5925
|
+
* `-e` and `-f` take a value. `ag -f` and `ack -f` take none, so their words stay as written.
|
|
5926
|
+
*/
|
|
5927
|
+
const CLUSTERING = /* @__PURE__ */ new Set([
|
|
5928
|
+
"grep",
|
|
5929
|
+
"egrep",
|
|
5930
|
+
"fgrep",
|
|
5931
|
+
"zgrep",
|
|
5932
|
+
"rg"
|
|
5933
|
+
]);
|
|
5934
|
+
/** Options whose value is the search pattern. */
|
|
5935
|
+
const PATTERN_OPTIONS = [
|
|
5936
|
+
"-e",
|
|
5937
|
+
"--regexp",
|
|
5938
|
+
"-pattern",
|
|
5939
|
+
"/c"
|
|
5940
|
+
];
|
|
5941
|
+
/** Options that give the pattern another way, a pattern file: then no operand is the pattern. */
|
|
5942
|
+
const isPatternFile = (name) => name === "-f" || name.startsWith("--file") || name === "/g";
|
|
5943
|
+
/** True when the option `name` takes the next word as its value. */
|
|
5944
|
+
function takesValue(bin, name) {
|
|
5945
|
+
if ([
|
|
5946
|
+
"-e",
|
|
5947
|
+
"--regexp",
|
|
5948
|
+
"-pattern",
|
|
5949
|
+
"-f",
|
|
5950
|
+
"--file"
|
|
5951
|
+
].includes(name)) return true;
|
|
5952
|
+
if (bin === "rg") return RG_VALUED.includes(name);
|
|
5953
|
+
if (bin === "select-string" || bin === "sls") return SELECT_STRING_VALUED.includes(name);
|
|
5954
|
+
return GREP_VALUED.includes(name);
|
|
5955
|
+
}
|
|
5956
|
+
/** An option word's value bound with `=` or `:` (`--glob=x`, `-Path:x`, `/c:x`). */
|
|
5957
|
+
const BOUND = /^(--?[A-Za-z][\w-]*|\/[A-Za-z])[=:]/;
|
|
5958
|
+
/** Parse a search command's arguments (after the program name) into roles. */
|
|
5959
|
+
function parseSearch(bin, args) {
|
|
5960
|
+
const words = [];
|
|
5961
|
+
const add = (text, role, arg, from = 0, option) => words.push({
|
|
5962
|
+
text,
|
|
5963
|
+
role,
|
|
5964
|
+
option,
|
|
5965
|
+
arg,
|
|
5966
|
+
from,
|
|
5967
|
+
pattern: false
|
|
5968
|
+
});
|
|
5969
|
+
const fold = (name) => ANY_CASE.has(bin) ? name.toLowerCase() : name;
|
|
5970
|
+
const isOption = (a) => a.startsWith("-") || bin === "findstr" && a.startsWith("/");
|
|
5971
|
+
let operandsOnly = false;
|
|
5972
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
5973
|
+
const a = args[i];
|
|
5974
|
+
if (operandsOnly || !isOption(a)) add(a, "operand", i);
|
|
5975
|
+
else if (a === "--") {
|
|
5976
|
+
operandsOnly = true;
|
|
5977
|
+
add(a, "option", i, 0, a);
|
|
5978
|
+
} else if (CLUSTERING.has(bin) && /^-[^-]./.test(a)) for (let j = 1; j < a.length; j += 1) {
|
|
5979
|
+
const flag = `-${a[j]}`;
|
|
5980
|
+
add(flag, "option", -1, 0, flag);
|
|
5981
|
+
if (!takesValue(bin, flag)) continue;
|
|
5982
|
+
if (j + 1 < a.length) add(a.slice(j + 1), "value", i, j + 1, flag);
|
|
5983
|
+
else if (i + 1 < args.length) add(args[++i], "value", i, 0, flag);
|
|
5984
|
+
break;
|
|
5985
|
+
}
|
|
5986
|
+
else {
|
|
5987
|
+
const bound = BOUND.exec(a);
|
|
5988
|
+
const name = fold(bound?.[1] ?? a);
|
|
5989
|
+
add(a, "option", i, 0, name);
|
|
5990
|
+
if (!bound && takesValue(bin, name) && i + 1 < args.length) add(args[++i], "value", i, 0, name);
|
|
5991
|
+
}
|
|
5992
|
+
}
|
|
5993
|
+
markPatterns(words);
|
|
5994
|
+
return words;
|
|
5995
|
+
}
|
|
5996
|
+
/**
|
|
5997
|
+
* Mark the pattern words: the values of `-e`, `--regexp`, `-Pattern` and `findstr /c:`, or, when
|
|
5998
|
+
* no option gives the pattern (nor a pattern file, `-f`), the first operand.
|
|
5999
|
+
*/
|
|
6000
|
+
function markPatterns(words) {
|
|
6001
|
+
let explicit = false;
|
|
6002
|
+
for (const w of words) {
|
|
6003
|
+
const name = w.option ?? "";
|
|
6004
|
+
if (w.role === "value" && PATTERN_OPTIONS.includes(name)) {
|
|
6005
|
+
w.pattern = true;
|
|
6006
|
+
explicit = true;
|
|
6007
|
+
} else if (w.role === "option" && PATTERN_OPTIONS.includes(name)) {
|
|
6008
|
+
explicit = true;
|
|
6009
|
+
if (w.text.length > name.length) w.pattern = true;
|
|
6010
|
+
} else if (w.role === "option" && isPatternFile(name)) explicit = true;
|
|
6011
|
+
}
|
|
6012
|
+
if (explicit) return;
|
|
6013
|
+
const first = words.find((w) => w.role === "operand");
|
|
6014
|
+
if (first) first.pattern = true;
|
|
6015
|
+
}
|
|
6016
|
+
/** The file operands of a parsed search (`grep -r -e x src lib` -> `src`, `lib`). */
|
|
6017
|
+
function searchOperands(words) {
|
|
6018
|
+
return words.filter((w) => w.role === "operand" && !w.pattern);
|
|
6019
|
+
}
|
|
6020
|
+
/** The values given to the named options: `name X`, `name=X`, or in a cluster (`-gX`). */
|
|
6021
|
+
function searchOptionValues(words, names) {
|
|
6022
|
+
return words.flatMap((w) => {
|
|
6023
|
+
if (!w.option || !names.includes(w.option)) return [];
|
|
6024
|
+
if (w.role === "value") return [w.text];
|
|
6025
|
+
return w.role === "option" && w.text.length > w.option.length ? [w.text.slice(w.option.length + 1)] : [];
|
|
6026
|
+
});
|
|
6027
|
+
}
|
|
6028
|
+
// ../../packages/providers/src/policy/secrets.ts
|
|
6029
|
+
/**
|
|
6030
|
+
* Secrets rules of the risk classifier (§11.2): which words of a command read a credentials
|
|
6031
|
+
* file, and which commands dump the environment. Writing a secrets file inside the worktree is
|
|
6032
|
+
* not access (`cp .env.example .env`); reading one is.
|
|
6033
|
+
*/
|
|
6034
|
+
/**
|
|
6035
|
+
* The argument as a path, or undefined when it can't be one: inline code, multi-line text
|
|
6036
|
+
* or anything with shell or code punctuation (`selected={row.key`) is not a file name. A
|
|
6037
|
+
* variable in braces (`${HOME}/.env`) is a path, and so is an option's value: `--file=x`, or
|
|
6038
|
+
* PowerShell's colon-bound `-Path:x` and `-LiteralPath:x`.
|
|
6039
|
+
*/
|
|
6040
|
+
function asPath(arg) {
|
|
6041
|
+
const value = arg.replace(/^-+[a-z-]*=/i, "").replace(/^-[a-z][a-z-]*:(?=.)/i, "");
|
|
6042
|
+
if (!value || /[\n\r{}()<>|;=`]/.test(value.replace(/\$\{\w+\}/g, ""))) return void 0;
|
|
6043
|
+
return value;
|
|
6044
|
+
}
|
|
6045
|
+
/** Commands whose words are data or only checked for existence (`echo .env >> .gitignore`). */
|
|
6046
|
+
const DATA = /* @__PURE__ */ new Set([
|
|
6047
|
+
"echo",
|
|
6048
|
+
"printf",
|
|
6049
|
+
"write-output",
|
|
6050
|
+
"write-host",
|
|
6051
|
+
"test",
|
|
6052
|
+
"[",
|
|
6053
|
+
"[["
|
|
6054
|
+
]);
|
|
6055
|
+
/** Commands that list or describe files without reading them (`ls certs/*.pem`). */
|
|
6056
|
+
const LISTS = /* @__PURE__ */ new Set([
|
|
6057
|
+
"ls",
|
|
6058
|
+
"dir",
|
|
6059
|
+
"get-childitem",
|
|
6060
|
+
"gci",
|
|
6061
|
+
"find",
|
|
6062
|
+
"fd",
|
|
6063
|
+
"tree",
|
|
6064
|
+
"stat",
|
|
6065
|
+
"du",
|
|
6066
|
+
"test-path"
|
|
6067
|
+
]);
|
|
6068
|
+
/**
|
|
6069
|
+
* The first argument that reads a credentials file, if any. `written` are the paths the command
|
|
6070
|
+
* writes (a copy's destination), which classifyWrite judges instead. `isSecret` decides which
|
|
6071
|
+
* paths count (default: secrets files by name). `paths` gives what a word may stand for (each
|
|
6072
|
+
* element of a PowerShell list, `shell.wordPaths`). A search's patterns are skipped whole, list
|
|
6073
|
+
* elements included: the tool searches for them, not in them.
|
|
6074
|
+
*/
|
|
6075
|
+
function secretArgument(bin, args, written = [], isSecret = isSecretPath, paths = (word, from) => [word.slice(from)]) {
|
|
6076
|
+
if (DATA.has(bin)) return void 0;
|
|
6077
|
+
const runs = (bin === "find" || bin === "fd") && args.some((a) => /^-(exec|execdir|ok|okdir|x|X|-exec|-exec-batch)$/.test(a));
|
|
6078
|
+
if (LISTS.has(bin) && !runs) return void 0;
|
|
6079
|
+
return (SEARCHES.has(bin) ? parseSearch(bin, args) : args.map((text, arg) => ({
|
|
6080
|
+
text,
|
|
6081
|
+
arg,
|
|
6082
|
+
from: 0,
|
|
6083
|
+
pattern: false
|
|
6084
|
+
}))).find((w) => {
|
|
6085
|
+
if (w.pattern || written.includes(w.text)) return false;
|
|
6086
|
+
return (w.arg >= 0 ? paths(args[w.arg], w.from) : [w.text]).some((word) => braceAlternatives(word).some((alt) => {
|
|
6087
|
+
const value = asPath(alt);
|
|
6088
|
+
return value !== void 0 && isSecret(value);
|
|
6089
|
+
}));
|
|
6090
|
+
})?.text;
|
|
6091
|
+
}
|
|
6092
|
+
/** The most words one argument's brace expansion is followed to. */
|
|
6093
|
+
const MAX_ALTERNATIVES = 64;
|
|
6094
|
+
/**
|
|
6095
|
+
* The words a shell's brace expansion makes of `word` (`~/.ssh/{id_rsa,x}` -> `~/.ssh/id_rsa`,
|
|
6096
|
+
* `~/.ssh/x`), capped; just `word` when it has no `{a,b}` group.
|
|
6097
|
+
*/
|
|
6098
|
+
function braceAlternatives(word) {
|
|
6099
|
+
const group = /\{([^{}]*,[^{}]*)\}/.exec(word);
|
|
6100
|
+
if (!group?.[1]) return [word];
|
|
6101
|
+
const before = word.slice(0, group.index);
|
|
6102
|
+
const after = word.slice(group.index + group[0].length);
|
|
6103
|
+
const out = [];
|
|
6104
|
+
for (const part of group[1].split(",")) for (const alt of braceAlternatives(before + part + after)) {
|
|
6105
|
+
if (out.length >= MAX_ALTERNATIVES) return out;
|
|
6106
|
+
out.push(alt);
|
|
6107
|
+
}
|
|
6108
|
+
return out;
|
|
6109
|
+
}
|
|
6110
|
+
/** Files a tool writes through its own options (`openssl req -keyout key.pem -out cert.pem`). */
|
|
6111
|
+
function toolOutputs(bin, args) {
|
|
6112
|
+
if (bin !== "openssl" && bin !== "ssh-keygen") return [];
|
|
6113
|
+
const flags = bin === "openssl" ? ["-out", "-keyout"] : ["-f"];
|
|
6114
|
+
return args.flatMap((a, i) => flags.includes(a) && args[i + 1] ? [args[i + 1]] : []);
|
|
6115
|
+
}
|
|
6116
|
+
/** Variable names that may hold a credential. */
|
|
6117
|
+
const SECRET_VAR = /key|token|secret|passw|pwd|credential|auth|private|session|cookie/i;
|
|
6118
|
+
/**
|
|
6119
|
+
* True for `printenv` and `Get-ChildItem env:` listings. Printing named variables that don't
|
|
6120
|
+
* look like credentials (`printenv PATH`, `Get-ChildItem env:NODE_ENV`) is not a dump.
|
|
6121
|
+
*/
|
|
6122
|
+
function dumpsEnvironment(bin, args) {
|
|
6123
|
+
const named = (names) => names.length > 0 && names.every((n) => /^\w+$/.test(n) && !SECRET_VAR.test(n));
|
|
6124
|
+
if (bin === "printenv") return !named(args.filter((a) => !a.startsWith("-")));
|
|
6125
|
+
if (!/^(get-childitem|gci|ls|dir)$/.test(bin)) return false;
|
|
6126
|
+
const drives = args.filter((a) => /^env:/i.test(a));
|
|
6127
|
+
return drives.length > 0 && !named(drives.map((a) => a.slice(4)));
|
|
6128
|
+
}
|
|
6129
|
+
// ../../packages/providers/src/policy/codeden-search.ts
|
|
6130
|
+
/** The values of an option, given as `name X`, `name=X` or `name:X`, each time it appears. */
|
|
6131
|
+
function optionValues(args, names, anyCase) {
|
|
6132
|
+
const out = [];
|
|
6133
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
6134
|
+
const a = args[i];
|
|
6135
|
+
const word = anyCase ? a.toLowerCase() : a;
|
|
6136
|
+
if (names.includes(word)) {
|
|
6137
|
+
if (i + 1 < args.length) out.push(args[i + 1]);
|
|
6138
|
+
i += 1;
|
|
6139
|
+
continue;
|
|
6140
|
+
}
|
|
6141
|
+
const bound = names.find((n) => word.startsWith(`${n}=`) || word.startsWith(`${n}:`));
|
|
6142
|
+
if (bound) out.push(a.slice(bound.length + 1));
|
|
6143
|
+
}
|
|
6144
|
+
return out;
|
|
6145
|
+
}
|
|
6146
|
+
const GREPS = /* @__PURE__ */ new Set([
|
|
6147
|
+
"grep",
|
|
6148
|
+
"egrep",
|
|
6149
|
+
"fgrep",
|
|
6150
|
+
"zgrep"
|
|
6151
|
+
]);
|
|
6152
|
+
/** `grep -r`, `-R`, `-rn`, `--recursive`, `-d recurse`: grep reads folders only then. */
|
|
6153
|
+
function grepRecursive(words) {
|
|
6154
|
+
return words.some((w) => w.role === "option" && /^(-r|-R|--recursive|--dereference-recursive)$/.test(w.text)) || searchOptionValues(words, ["-d", "--directories"]).includes("recurse");
|
|
6155
|
+
}
|
|
6156
|
+
/**
|
|
6157
|
+
* The folders a search reads recursively, with its name filters. Roles come from `parseSearch`
|
|
6158
|
+
* (`grep -re. ~` is `grep -r -e . ~`; in `rg -r-g x ~`, `-g` is replacement text, not a filter),
|
|
6159
|
+
* and each root may be a PowerShell list (`paths`).
|
|
6160
|
+
*/
|
|
6161
|
+
function searchWalks(bin, args, ctx, paths) {
|
|
6162
|
+
const words = parseSearch(bin, args);
|
|
6163
|
+
const operands = searchOperands(words).flatMap((w) => paths(args[w.arg], w.from));
|
|
6164
|
+
if (bin === "rg" || bin === "ag" || bin === "ack") return [{
|
|
6165
|
+
roots: operands,
|
|
6166
|
+
recursive: true,
|
|
6167
|
+
names: bin === "rg" ? searchOptionValues(words, [
|
|
6168
|
+
"-g",
|
|
6169
|
+
"--glob",
|
|
6170
|
+
"--iglob"
|
|
6171
|
+
]).filter((n) => !n.startsWith("!")) : []
|
|
6172
|
+
}];
|
|
6173
|
+
if (GREPS.has(bin)) {
|
|
6174
|
+
if (!grepRecursive(words)) return [];
|
|
6175
|
+
return [{
|
|
6176
|
+
roots: operands,
|
|
6177
|
+
recursive: true,
|
|
6178
|
+
names: searchOptionValues(words, ["--include"])
|
|
6179
|
+
}];
|
|
6180
|
+
}
|
|
6181
|
+
const sub = words.some((w) => w.role === "option" && /^\/[a-z]*s[a-z]*$/i.test(w.text));
|
|
6182
|
+
if (bin === "findstr" && sub) {
|
|
6183
|
+
const p = pathApi(ctx);
|
|
6184
|
+
return operands.map((operand) => ({
|
|
6185
|
+
roots: [p.dirname(operand)],
|
|
6186
|
+
recursive: true,
|
|
6187
|
+
names: [p.basename(operand)]
|
|
6188
|
+
}));
|
|
6189
|
+
}
|
|
6190
|
+
return [];
|
|
6191
|
+
}
|
|
6192
|
+
const LISTINGS = /* @__PURE__ */ new Set([
|
|
6193
|
+
"get-childitem",
|
|
6194
|
+
"gci",
|
|
6195
|
+
"dir",
|
|
6196
|
+
"ls"
|
|
6197
|
+
]);
|
|
6198
|
+
const LISTING_VALUED = [
|
|
6199
|
+
"-filter",
|
|
6200
|
+
"-include",
|
|
6201
|
+
"-exclude",
|
|
6202
|
+
"-depth",
|
|
6203
|
+
"-attributes"
|
|
6204
|
+
];
|
|
6205
|
+
/** `find -exec cat {} +` and `fd -x cat` hand what they find to a command. */
|
|
6206
|
+
const RUNS = /^-(exec|execdir|ok|okdir|x|X|-exec|-exec-batch)$/;
|
|
6207
|
+
/** The folders a listing goes through, with its name filters. */
|
|
6208
|
+
function listingWalk(bin, args) {
|
|
6209
|
+
if (bin === "find") {
|
|
6210
|
+
const end = args.findIndex((a) => /^[-(!)]/.test(a));
|
|
6211
|
+
const either = args.some((a) => [
|
|
6212
|
+
"-o",
|
|
6213
|
+
"-or",
|
|
6214
|
+
"!",
|
|
6215
|
+
"-not"
|
|
6216
|
+
].includes(a));
|
|
6217
|
+
return {
|
|
6218
|
+
roots: end < 0 ? args : args.slice(0, end),
|
|
6219
|
+
recursive: true,
|
|
6220
|
+
names: either ? [] : optionValues(args, ["-name", "-iname"], false)
|
|
6221
|
+
};
|
|
6222
|
+
}
|
|
6223
|
+
if (bin === "fd") return {
|
|
6224
|
+
roots: args.filter((a) => !a.startsWith("-")).slice(1),
|
|
6225
|
+
recursive: true,
|
|
6226
|
+
names: []
|
|
6227
|
+
};
|
|
6228
|
+
if (!LISTINGS.has(bin)) return void 0;
|
|
6229
|
+
const recursive = args.some((a) => /^-rec/i.test(a) || a === "-r" || /^-[a-zA-Z]*R[a-zA-Z]*$/.test(a) || /^\/s$/i.test(a));
|
|
6230
|
+
const named = optionValues(args, ["-path", "-literalpath"], true);
|
|
6231
|
+
const positional = [];
|
|
6232
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
6233
|
+
const a = args[i];
|
|
6234
|
+
const lower = a.toLowerCase();
|
|
6235
|
+
if (LISTING_VALUED.includes(lower) || lower === "-path" || lower === "-literalpath") i += 1;
|
|
6236
|
+
else if (!a.startsWith("-") && !/^\/[a-z](:\S*)?$/i.test(a)) positional.push(a);
|
|
6237
|
+
}
|
|
6238
|
+
return {
|
|
6239
|
+
roots: [...named, ...positional].flatMap((root) => root.split(",")),
|
|
6240
|
+
recursive,
|
|
6241
|
+
names: optionValues(args, ["-filter", "-include"], true).flatMap((n) => n.split(","))
|
|
6242
|
+
};
|
|
6243
|
+
}
|
|
6244
|
+
/**
|
|
6245
|
+
* True when listing `dir` (and, recursively, its subfolders) reaches `folder`: by its path, or
|
|
6246
|
+
* through the wildcards the agent wrote in it (never ones in CodeDen's home path).
|
|
6247
|
+
*/
|
|
6248
|
+
function holds(dir, folder, recursive, ctx) {
|
|
6249
|
+
if (recursive ? isInside$1(folder, dir, ctx) : samePath(dir, folder, ctx)) return true;
|
|
6250
|
+
const pattern = agentPattern(dir, ctx);
|
|
6251
|
+
if (!pattern) return false;
|
|
6252
|
+
const p = pathApi(ctx);
|
|
6253
|
+
for (let at = folder;; at = p.dirname(at)) {
|
|
6254
|
+
if (pattern.test(slashes(at, ctx))) return true;
|
|
6255
|
+
if (!recursive || p.dirname(at) === at) return false;
|
|
6256
|
+
}
|
|
6257
|
+
}
|
|
6258
|
+
/** True when `file`, found under `root`, passes one of the name filters (or there are none). */
|
|
6259
|
+
function keeps(names, file, root, ctx) {
|
|
6260
|
+
if (names.length === 0) return true;
|
|
6261
|
+
const p = pathApi(ctx);
|
|
6262
|
+
const base = p.basename(file);
|
|
6263
|
+
const below = slashes(p.relative(root, file), ctx);
|
|
6264
|
+
return names.flatMap((name) => braceAlternatives(name.trim().replace(/^\.[\\/]/, ""))).some((name) => {
|
|
6265
|
+
const pattern = wildcardPattern(slashes(name, ctx), true);
|
|
6266
|
+
if (!pattern) return true;
|
|
6267
|
+
return /[\\/]/.test(name) ? pattern.test(below) : pattern.test(base);
|
|
6268
|
+
});
|
|
6269
|
+
}
|
|
6270
|
+
/** The root of `walk` from which it reaches a credential file, if any. */
|
|
6271
|
+
function walkReaches(walk, ctx) {
|
|
6272
|
+
const files = codedenCredentialFiles(ctx);
|
|
6273
|
+
const p = pathApi(ctx);
|
|
6274
|
+
for (const root of walk.roots.length ? walk.roots : ["."]) for (const alt of braceAlternatives(root)) {
|
|
6275
|
+
const dir = resolvePattern(alt, ctx);
|
|
6276
|
+
if (files.some((file) => holds(dir, p.dirname(file), walk.recursive, ctx) && keeps(walk.names, file, dir, ctx))) return root;
|
|
6277
|
+
}
|
|
6278
|
+
}
|
|
6279
|
+
/**
|
|
6280
|
+
* A recursive search from `roots` (Claude's Grep), keeping files whose names match `names`:
|
|
6281
|
+
* the root it reaches a credential file from, if any.
|
|
6282
|
+
*/
|
|
6283
|
+
function searchReaches(roots, names, ctx) {
|
|
6284
|
+
return ctx.codedenHome ? walkReaches({
|
|
6285
|
+
roots,
|
|
6286
|
+
recursive: true,
|
|
6287
|
+
names
|
|
6288
|
+
}, ctx) : void 0;
|
|
6289
|
+
}
|
|
6290
|
+
/**
|
|
6291
|
+
* How a command reaches CodeDen's credential files other than by naming one: a recursive search
|
|
6292
|
+
* from a folder above them, or a listing (or `echo`ed path) whose output `feeds` another command.
|
|
6293
|
+
* `paths` gives what a word may stand for (a PowerShell list's elements). Returns the word it
|
|
6294
|
+
* reaches them through.
|
|
6295
|
+
*/
|
|
6296
|
+
function reachesThroughFolder(bin, args, ctx, feeds, paths = (word, from) => [word.slice(from)]) {
|
|
6297
|
+
if (!ctx.codedenHome) return void 0;
|
|
6298
|
+
if (SEARCHES.has(bin)) for (const walk of searchWalks(bin, args, ctx, paths)) {
|
|
6299
|
+
const root = walkReaches(walk, ctx);
|
|
6300
|
+
if (root) return root;
|
|
6301
|
+
}
|
|
6302
|
+
if (feeds || (bin === "find" || bin === "fd") && args.some((a) => RUNS.test(a))) {
|
|
6303
|
+
const walk = listingWalk(bin, args);
|
|
6304
|
+
const root = walk && walkReaches(walk, ctx);
|
|
6305
|
+
if (root) return root;
|
|
6306
|
+
}
|
|
6307
|
+
if (feeds && DATA.has(bin)) return args.find((a) => paths(a, 0).some((path) => touchesCodedenCredentials(path, ctx)));
|
|
6308
|
+
}
|
|
5658
6309
|
// ../../packages/providers/src/util/git-head.ts
|
|
5659
6310
|
/**
|
|
5660
6311
|
* The branch checked out in the repository at `dir` (its `.git` folder, or the `gitdir:` a
|
|
@@ -5911,186 +6562,32 @@ function runsStdin(cmd) {
|
|
|
5911
6562
|
const args = tokens.slice(1);
|
|
5912
6563
|
for (let i = 0; i < args.length; i += 1) {
|
|
5913
6564
|
const arg = args[i];
|
|
5914
|
-
if (arg === "--") return isStdin(args[i + 1]);
|
|
5915
|
-
if (!/^[-+]./.test(arg)) return isStdin(arg);
|
|
5916
|
-
if (spec.stdin?.(arg)) return true;
|
|
5917
|
-
if (spec.program(arg)) return !arg.includes("=") && isStdin(args[i + 1]);
|
|
5918
|
-
if (spec.valued(arg)) i += 1;
|
|
5919
|
-
}
|
|
5920
|
-
return true;
|
|
5921
|
-
}
|
|
5922
|
-
// ../../packages/providers/src/policy/secrets.ts
|
|
5923
|
-
/**
|
|
5924
|
-
* Secrets rules of the risk classifier (§11.2): which words of a command read a credentials
|
|
5925
|
-
* file, and which commands dump the environment. Writing a secrets file inside the worktree is
|
|
5926
|
-
* not access (`cp .env.example .env`); reading one is.
|
|
5927
|
-
*/
|
|
5928
|
-
/**
|
|
5929
|
-
* The argument as a path, or undefined when it can't be one: inline code, multi-line text
|
|
5930
|
-
* or anything with shell or code punctuation (`selected={row.key`) is not a file name.
|
|
5931
|
-
*/
|
|
5932
|
-
function asPath(arg) {
|
|
5933
|
-
const value = arg.replace(/^-+[a-z-]*=/i, "");
|
|
5934
|
-
if (!value || /[\n\r{}()<>|;=`]/.test(value)) return void 0;
|
|
5935
|
-
return value;
|
|
5936
|
-
}
|
|
5937
|
-
/** Commands whose words are data or only checked for existence (`echo .env >> .gitignore`). */
|
|
5938
|
-
const DATA = /* @__PURE__ */ new Set([
|
|
5939
|
-
"echo",
|
|
5940
|
-
"printf",
|
|
5941
|
-
"write-output",
|
|
5942
|
-
"write-host",
|
|
5943
|
-
"test",
|
|
5944
|
-
"[",
|
|
5945
|
-
"[["
|
|
5946
|
-
]);
|
|
5947
|
-
/** Commands that list or describe files without reading them (`ls certs/*.pem`). */
|
|
5948
|
-
const LISTS = /* @__PURE__ */ new Set([
|
|
5949
|
-
"ls",
|
|
5950
|
-
"dir",
|
|
5951
|
-
"get-childitem",
|
|
5952
|
-
"gci",
|
|
5953
|
-
"find",
|
|
5954
|
-
"fd",
|
|
5955
|
-
"tree",
|
|
5956
|
-
"stat",
|
|
5957
|
-
"du",
|
|
5958
|
-
"test-path"
|
|
5959
|
-
]);
|
|
5960
|
-
/** Search commands, whose first operand is the pattern (`grep -rn "process.env" src`). */
|
|
5961
|
-
const SEARCHES = /* @__PURE__ */ new Set([
|
|
5962
|
-
"grep",
|
|
5963
|
-
"egrep",
|
|
5964
|
-
"fgrep",
|
|
5965
|
-
"zgrep",
|
|
5966
|
-
"rg",
|
|
5967
|
-
"ag",
|
|
5968
|
-
"ack",
|
|
5969
|
-
"findstr",
|
|
5970
|
-
"select-string",
|
|
5971
|
-
"sls"
|
|
5972
|
-
]);
|
|
5973
|
-
/** Options that take the next word as their value, per search tool. */
|
|
5974
|
-
const GREP_VALUED = [
|
|
5975
|
-
"-A",
|
|
5976
|
-
"-B",
|
|
5977
|
-
"-C",
|
|
5978
|
-
"-m",
|
|
5979
|
-
"-d",
|
|
5980
|
-
"-D",
|
|
5981
|
-
"--after-context",
|
|
5982
|
-
"--before-context",
|
|
5983
|
-
"--context",
|
|
5984
|
-
"--max-count",
|
|
5985
|
-
"--include",
|
|
5986
|
-
"--exclude",
|
|
5987
|
-
"--exclude-dir",
|
|
5988
|
-
"--label",
|
|
5989
|
-
"--binary-files",
|
|
5990
|
-
"--devices",
|
|
5991
|
-
"--directories"
|
|
5992
|
-
];
|
|
5993
|
-
const RG_VALUED = [
|
|
5994
|
-
...GREP_VALUED,
|
|
5995
|
-
"-g",
|
|
5996
|
-
"--glob",
|
|
5997
|
-
"--iglob",
|
|
5998
|
-
"-t",
|
|
5999
|
-
"--type",
|
|
6000
|
-
"-T",
|
|
6001
|
-
"--type-not",
|
|
6002
|
-
"-j",
|
|
6003
|
-
"--threads",
|
|
6004
|
-
"--max-depth",
|
|
6005
|
-
"-M",
|
|
6006
|
-
"--max-columns",
|
|
6007
|
-
"--sort",
|
|
6008
|
-
"--sortr",
|
|
6009
|
-
"-r",
|
|
6010
|
-
"--replace",
|
|
6011
|
-
"--colors",
|
|
6012
|
-
"-E",
|
|
6013
|
-
"--encoding"
|
|
6014
|
-
];
|
|
6015
|
-
const SELECT_STRING_VALUED = [
|
|
6016
|
-
"-path",
|
|
6017
|
-
"-literalpath",
|
|
6018
|
-
"-include",
|
|
6019
|
-
"-exclude",
|
|
6020
|
-
"-encoding",
|
|
6021
|
-
"-context"
|
|
6022
|
-
];
|
|
6023
|
-
function searchValued(bin, arg) {
|
|
6024
|
-
if (bin === "rg") return RG_VALUED.includes(arg);
|
|
6025
|
-
if (bin === "select-string" || bin === "sls") return SELECT_STRING_VALUED.includes(arg.toLowerCase());
|
|
6026
|
-
return GREP_VALUED.includes(arg);
|
|
6027
|
-
}
|
|
6028
|
-
/**
|
|
6029
|
-
* The search pattern words of a search command: `-e`/`--regexp`/`-Pattern` values, or the first
|
|
6030
|
-
* operand when the pattern isn't given by an option (or a pattern file, `-f`). `findstr`'s
|
|
6031
|
-
* `/c:"text"` is a pattern too.
|
|
6032
|
-
*/
|
|
6033
|
-
function searchPatterns(bin, args) {
|
|
6034
|
-
const patterns = /* @__PURE__ */ new Set();
|
|
6035
|
-
let explicit = false;
|
|
6036
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
6037
|
-
const a = args[i];
|
|
6038
|
-
if (a === "-e" || a === "--regexp" || /^-pattern$/i.test(a)) {
|
|
6039
|
-
explicit = true;
|
|
6040
|
-
const value = args[i + 1];
|
|
6041
|
-
if (value !== void 0) patterns.add(value);
|
|
6042
|
-
i += 1;
|
|
6043
|
-
} else if (a.startsWith("--regexp=") || /^\/c:/i.test(a)) {
|
|
6044
|
-
explicit = true;
|
|
6045
|
-
patterns.add(a);
|
|
6046
|
-
} else if (a === "-f" || a.startsWith("--file") || /^\/g:/i.test(a)) explicit = true;
|
|
6047
|
-
}
|
|
6048
|
-
if (explicit) return patterns;
|
|
6049
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
6050
|
-
const a = args[i];
|
|
6051
|
-
if (searchValued(bin, a)) i += 1;
|
|
6052
|
-
else if (!a.startsWith("-") && !(bin === "findstr" && a.startsWith("/"))) {
|
|
6053
|
-
patterns.add(a);
|
|
6054
|
-
break;
|
|
6055
|
-
}
|
|
6565
|
+
if (arg === "--") return isStdin(args[i + 1]);
|
|
6566
|
+
if (!/^[-+]./.test(arg)) return isStdin(arg);
|
|
6567
|
+
if (spec.stdin?.(arg)) return true;
|
|
6568
|
+
if (spec.program(arg)) return !arg.includes("=") && isStdin(args[i + 1]);
|
|
6569
|
+
if (spec.valued(arg)) i += 1;
|
|
6056
6570
|
}
|
|
6057
|
-
return
|
|
6058
|
-
}
|
|
6059
|
-
/**
|
|
6060
|
-
* The first argument that reads a credentials file, if any. `written` are the paths the command
|
|
6061
|
-
* writes (a copy's destination), which classifyWrite judges instead.
|
|
6062
|
-
*/
|
|
6063
|
-
function secretArgument(bin, args, written = []) {
|
|
6064
|
-
if (DATA.has(bin)) return void 0;
|
|
6065
|
-
const runs = (bin === "find" || bin === "fd") && args.some((a) => /^-(exec|execdir|ok|okdir|x|X|-exec|-exec-batch)$/.test(a));
|
|
6066
|
-
if (LISTS.has(bin) && !runs) return void 0;
|
|
6067
|
-
const patterns = SEARCHES.has(bin) ? searchPatterns(bin, args) : /* @__PURE__ */ new Set();
|
|
6068
|
-
return args.find((a) => {
|
|
6069
|
-
if (patterns.has(a) || written.includes(a)) return false;
|
|
6070
|
-
const value = asPath(a);
|
|
6071
|
-
return value !== void 0 && isSecretPath(value);
|
|
6072
|
-
});
|
|
6073
|
-
}
|
|
6074
|
-
/** Files a tool writes through its own options (`openssl req -keyout key.pem -out cert.pem`). */
|
|
6075
|
-
function toolOutputs(bin, args) {
|
|
6076
|
-
if (bin !== "openssl" && bin !== "ssh-keygen") return [];
|
|
6077
|
-
const flags = bin === "openssl" ? ["-out", "-keyout"] : ["-f"];
|
|
6078
|
-
return args.flatMap((a, i) => flags.includes(a) && args[i + 1] ? [args[i + 1]] : []);
|
|
6571
|
+
return true;
|
|
6079
6572
|
}
|
|
6080
|
-
|
|
6081
|
-
const SECRET_VAR = /key|token|secret|passw|pwd|credential|auth|private|session|cookie/i;
|
|
6573
|
+
// ../../packages/providers/src/policy/shell.ts
|
|
6082
6574
|
/**
|
|
6083
|
-
*
|
|
6084
|
-
*
|
|
6575
|
+
* The paths a word of `cmd` may stand for, from `from` on: its text and, for a PowerShell list
|
|
6576
|
+
* (`a,b`), each element. The whole text stays: when the shell is unknown it may be one name, or a
|
|
6577
|
+
* brace expansion (`{a,b}`). Callers apply it after reading the word's role, so a list given as a
|
|
6578
|
+
* search pattern stays data.
|
|
6085
6579
|
*/
|
|
6086
|
-
function
|
|
6087
|
-
const
|
|
6088
|
-
|
|
6089
|
-
if (
|
|
6090
|
-
const
|
|
6091
|
-
|
|
6580
|
+
function wordPaths(cmd, word, from = 0) {
|
|
6581
|
+
const text = word.slice(from);
|
|
6582
|
+
const commas = cmd.lists?.get(word)?.filter((at) => at >= from) ?? [];
|
|
6583
|
+
if (!commas.length) return [text];
|
|
6584
|
+
const bounds = [
|
|
6585
|
+
from - 1,
|
|
6586
|
+
...commas,
|
|
6587
|
+
word.length
|
|
6588
|
+
];
|
|
6589
|
+
return [text, ...bounds.slice(1).map((end, k) => word.slice(bounds[k] + 1, end)).filter(Boolean)];
|
|
6092
6590
|
}
|
|
6093
|
-
// ../../packages/providers/src/policy/shell.ts
|
|
6094
6591
|
/** Parse a command line into its simple commands. */
|
|
6095
6592
|
function parseShell(src, dialect = "auto") {
|
|
6096
6593
|
return new Lexer(src, dialect).list(null);
|
|
@@ -6111,6 +6608,8 @@ var Builder = class {
|
|
|
6111
6608
|
target = "word";
|
|
6112
6609
|
heredocStrip = false;
|
|
6113
6610
|
heredocs = [];
|
|
6611
|
+
/** Where the word's unquoted commas are: a PowerShell list's element boundaries. */
|
|
6612
|
+
commas = [];
|
|
6114
6613
|
constructor(out) {
|
|
6115
6614
|
this.out = out;
|
|
6116
6615
|
}
|
|
@@ -6118,6 +6617,11 @@ var Builder = class {
|
|
|
6118
6617
|
this.word += text;
|
|
6119
6618
|
this.inWord = true;
|
|
6120
6619
|
}
|
|
6620
|
+
/** An unquoted comma, which separates a PowerShell list's elements (`a,b`). */
|
|
6621
|
+
addComma() {
|
|
6622
|
+
this.commas.push(this.word.length);
|
|
6623
|
+
this.add(",");
|
|
6624
|
+
}
|
|
6121
6625
|
endWord() {
|
|
6122
6626
|
if (!this.inWord) return;
|
|
6123
6627
|
if (this.target === "write") this.cmd.writes.push(this.word);
|
|
@@ -6125,10 +6629,24 @@ var Builder = class {
|
|
|
6125
6629
|
delim: this.word.replace(/\\/g, ""),
|
|
6126
6630
|
strip: this.heredocStrip
|
|
6127
6631
|
});
|
|
6128
|
-
else if (this.target === "word")
|
|
6632
|
+
else if (this.target === "word") {
|
|
6633
|
+
this.cmd.words.push(this.word);
|
|
6634
|
+
if (this.commas.length) this.addList();
|
|
6635
|
+
} else if (this.target === "skip") this.cmd.input = [...this.cmd.input ?? [], this.word];
|
|
6129
6636
|
this.target = "word";
|
|
6130
6637
|
this.word = "";
|
|
6131
6638
|
this.inWord = false;
|
|
6639
|
+
this.commas = [];
|
|
6640
|
+
}
|
|
6641
|
+
/**
|
|
6642
|
+
* Note where the current word's unquoted commas are. The same text written twice with different
|
|
6643
|
+
* quoting keeps every comma either one had, so its list is never missed.
|
|
6644
|
+
*/
|
|
6645
|
+
addList() {
|
|
6646
|
+
this.cmd.lists ??= /* @__PURE__ */ new Map();
|
|
6647
|
+
const seen = this.cmd.lists.get(this.word) ?? [];
|
|
6648
|
+
const commas = [.../* @__PURE__ */ new Set([...seen, ...this.commas])].sort((a, b) => a - b);
|
|
6649
|
+
this.cmd.lists.set(this.word, commas);
|
|
6132
6650
|
}
|
|
6133
6651
|
endCommand() {
|
|
6134
6652
|
this.endWord();
|
|
@@ -6205,6 +6723,9 @@ var Lexer = class {
|
|
|
6205
6723
|
this.i += 1;
|
|
6206
6724
|
b.cmd.nested.push(this.list(")"));
|
|
6207
6725
|
b.add(src.slice(start, this.i));
|
|
6726
|
+
} else if (c === "," && this.dialect !== "posix") {
|
|
6727
|
+
b.addComma();
|
|
6728
|
+
this.i += 1;
|
|
6208
6729
|
} else {
|
|
6209
6730
|
b.add(c);
|
|
6210
6731
|
this.i += 1;
|
|
@@ -6347,12 +6868,62 @@ const MAX_DEPTH = 4;
|
|
|
6347
6868
|
* Classify a shell command (any shell: POSIX, PowerShell or cmd). Downloads piped into an
|
|
6348
6869
|
* interpreter (`curl … | sh`, `irm … | iex`) are found on the parsed pipeline in
|
|
6349
6870
|
* `classifyList`; here, a download run through a substitution (`iex (irm …)`,
|
|
6350
|
-
* `bash -c "$(curl …)"`, `bash <(curl …)`).
|
|
6871
|
+
* `bash -c "$(curl …)"`, `bash <(curl …)`). CodeDen's own credentials are looked for first,
|
|
6872
|
+
* across the whole command, so an earlier ordinary risk (`cat .env; …`) can't hide them.
|
|
6351
6873
|
*/
|
|
6352
6874
|
function classifyCommand(command, ctx) {
|
|
6875
|
+
const list = parseShell(command, ctx.shell ?? "auto");
|
|
6876
|
+
const credentials = credentialAccess(list, ctx, 0);
|
|
6877
|
+
if (credentials) return credentials;
|
|
6353
6878
|
if (/\b(iex|invoke-expression)\b[^|]*\b(irm|iwr|invoke-restmethod|invoke-webrequest|downloadstring)\b/i.test(command)) return risky("network", "Executes a downloaded script");
|
|
6354
6879
|
if (/\b(bash|sh|zsh)\s+(-c\s+)?["']?\$\(\s*(curl|wget)\b/i.test(command) || /<\(\s*(curl|wget)\b/i.test(command)) return risky("network", "Executes a downloaded script");
|
|
6355
|
-
return classifyList(
|
|
6880
|
+
return classifyList(list, ctx, 0);
|
|
6881
|
+
}
|
|
6882
|
+
/**
|
|
6883
|
+
* The first access to CodeDen's own credential files anywhere in `list`: substitutions,
|
|
6884
|
+
* subshells and inline scripts (`bash -c "…"`) included, following `cd` like `classifyList`.
|
|
6885
|
+
* `feeds`: the list's output is used by another command (it sits in a substitution or a pipe).
|
|
6886
|
+
*/
|
|
6887
|
+
function credentialAccess(list, ctx, depth, feeds = false) {
|
|
6888
|
+
let here = ctx;
|
|
6889
|
+
for (const [i, cmd] of list.entries()) {
|
|
6890
|
+
for (const nested of cmd.nested) {
|
|
6891
|
+
const found = credentialAccess(nested, here, depth, true);
|
|
6892
|
+
if (found) return found;
|
|
6893
|
+
}
|
|
6894
|
+
const piped = list[i + 1]?.piped === true;
|
|
6895
|
+
const found = credentialCommand(cmd, here, depth, feeds || piped);
|
|
6896
|
+
if (found) return found;
|
|
6897
|
+
here = afterCd(cmd, here);
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
/** The paths each word of `cmd` may stand for: itself, and a PowerShell list's elements. */
|
|
6901
|
+
const pathsOf = (cmd) => (word, from) => wordPaths(cmd, word, from);
|
|
6902
|
+
/**
|
|
6903
|
+
* One simple command's access to CodeDen's credentials: a redirect onto one, any argument (or
|
|
6904
|
+
* here-string) that reaches one (read, written, copied or deleted), or a search or listing that
|
|
6905
|
+
* reaches one from a folder above it (`./codeden-search`). Moving into CodeDen's home with `cd`
|
|
6906
|
+
* reads nothing; the command that then reads is caught.
|
|
6907
|
+
*/
|
|
6908
|
+
function credentialCommand(cmd, ctx, depth, feeds) {
|
|
6909
|
+
const { tokens } = unwrap(cmd.words);
|
|
6910
|
+
const bin = binName(tokens[0]);
|
|
6911
|
+
const args = tokens.slice(1);
|
|
6912
|
+
const script = inlineScript(bin, args);
|
|
6913
|
+
if (script && depth < MAX_DEPTH) {
|
|
6914
|
+
const inner = credentialAccess(parseShell(script.text, script.dialect), ctx, depth + 1, feeds);
|
|
6915
|
+
if (inner) return inner;
|
|
6916
|
+
}
|
|
6917
|
+
for (const target of cmd.writes) if (!DISCARD.test(target) && touchesCodedenCredentials(target, ctx)) return credentialsRisk(`Writes CodeDen's own credentials: ${target}`);
|
|
6918
|
+
const given = (cmd.input ?? []).find((word) => touchesCodedenCredentials(word, ctx));
|
|
6919
|
+
if (given) return credentialsRisk(`Passes CodeDen's own credentials: ${given}`);
|
|
6920
|
+
if (CD.has(bin)) return void 0;
|
|
6921
|
+
const paths = pathsOf(cmd);
|
|
6922
|
+
const reads = (a) => touchesCodedenCredentials(a, ctx);
|
|
6923
|
+
const touched = secretArgument(bin, args, [], reads, paths);
|
|
6924
|
+
if (touched) return credentialsRisk(`Accesses CodeDen's own credentials: ${touched}`);
|
|
6925
|
+
const through = reachesThroughFolder(bin, args, ctx, feeds, paths);
|
|
6926
|
+
return through ? credentialsRisk(`Reaches CodeDen's own credentials from ${through}`) : void 0;
|
|
6356
6927
|
}
|
|
6357
6928
|
/**
|
|
6358
6929
|
* Classify commands in order. A `cd` moves where later relative paths resolve; commands in
|
|
@@ -6392,10 +6963,17 @@ function afterCd(cmd, ctx) {
|
|
|
6392
6963
|
dir: resolveAgainst(target, ctx)
|
|
6393
6964
|
};
|
|
6394
6965
|
}
|
|
6395
|
-
/**
|
|
6966
|
+
/**
|
|
6967
|
+
* The value of a PowerShell-style named parameter, if present: `-Path x`, or bound with a
|
|
6968
|
+
* colon, `-Path:x`.
|
|
6969
|
+
*/
|
|
6396
6970
|
function namedArg(args, names) {
|
|
6397
|
-
|
|
6398
|
-
|
|
6971
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
6972
|
+
const arg = args[i].toLowerCase();
|
|
6973
|
+
if (names.includes(arg)) return args[i + 1];
|
|
6974
|
+
const bound = names.find((name) => arg.startsWith(`${name}:`));
|
|
6975
|
+
if (bound) return args[i].slice(bound.length + 1);
|
|
6976
|
+
}
|
|
6399
6977
|
}
|
|
6400
6978
|
/** The script of an inline shell invocation (`bash -c "…"`, `pwsh -Command …`, `cmd /c …`). */
|
|
6401
6979
|
function inlineScript(bin, args) {
|
|
@@ -6478,7 +7056,7 @@ function classifySimple(cmd, ctx, depth) {
|
|
|
6478
7056
|
const write = classifyWrite(target, ctx);
|
|
6479
7057
|
if (write.risky) return write;
|
|
6480
7058
|
}
|
|
6481
|
-
const secret = secretArgument(bin, args, written);
|
|
7059
|
+
const secret = secretArgument(bin, args, written, isSecretPath, pathsOf(cmd));
|
|
6482
7060
|
if (secret) return risky("secrets", `Accesses the secrets file ${secret}`);
|
|
6483
7061
|
if (dumpsEnvironment(bin, args)) return risky("secrets", "Dumps environment variables");
|
|
6484
7062
|
return SAFE;
|
|
@@ -6535,18 +7113,36 @@ function copyTargets(bin, args, dest, ctx) {
|
|
|
6535
7113
|
* Classify a file write/edit. The worktree, extra roots and OS temp are fine, secrets files in
|
|
6536
7114
|
* the worktree included: writing one (`cp .env.example .env`) reads nothing. A secrets file in
|
|
6537
7115
|
* temp counts as outside the worktree, so Full autonomy (which only asks for writes outside the
|
|
6538
|
-
* worktree) still asks for it.
|
|
7116
|
+
* worktree) still asks for it. CodeDen's own credential files ask at every level.
|
|
6539
7117
|
*/
|
|
6540
7118
|
function classifyWrite(target, ctx) {
|
|
7119
|
+
if (touchesCodedenCredentials(target, ctx)) return credentialsRisk(`Writes CodeDen's own credentials: ${target}`);
|
|
6541
7120
|
if (!isWritable(target, ctx)) return risky("outside_worktree", `Writes ${target}, outside the worktree`);
|
|
6542
7121
|
if (isSecretPath(target) && !isInWorkspace(target, ctx)) return risky("outside_worktree", `Writes the secrets file ${target}, outside the worktree`);
|
|
6543
7122
|
return SAFE;
|
|
6544
7123
|
}
|
|
6545
|
-
/**
|
|
6546
|
-
|
|
7124
|
+
/**
|
|
7125
|
+
* Classify a file read or search. Reads anywhere are fine except credential files; CodeDen's own
|
|
7126
|
+
* (or a folder holding them) ask at every level.
|
|
7127
|
+
*/
|
|
7128
|
+
function classifyRead(target, ctx) {
|
|
7129
|
+
if (touchesCodedenCredentials(target, ctx)) return credentialsRisk(`Reads CodeDen's own credentials: ${target}`);
|
|
6547
7130
|
if (isSecretPath(target)) return risky("secrets", `Reads the secrets file ${target}`);
|
|
6548
7131
|
return SAFE;
|
|
6549
7132
|
}
|
|
7133
|
+
/**
|
|
7134
|
+
* Classify a file-name search: `pattern` (Claude's Glob, or a search's `--glob` filter) from
|
|
7135
|
+
* `base` (default: the current directory). Listing names reads nothing, so only a pattern that
|
|
7136
|
+
* reaches CodeDen's own credential files asks; `{a,b}` alternatives are followed.
|
|
7137
|
+
*/
|
|
7138
|
+
function classifyGlob(pattern, base, ctx) {
|
|
7139
|
+
if (!pattern.trim()) return SAFE;
|
|
7140
|
+
const dir = base ? resolveAgainst(base, ctx) : ctx.dir ?? ctx.cwd;
|
|
7141
|
+
return braceAlternatives(pattern).some((alt) => touchesCodedenCredentials(alt, {
|
|
7142
|
+
...ctx,
|
|
7143
|
+
dir
|
|
7144
|
+
})) ? credentialsRisk(`Lists CodeDen's own credential files: ${pattern}`) : SAFE;
|
|
7145
|
+
}
|
|
6550
7146
|
/** Classify a web fetch. Only flagged when the context has an allowlist of hosts. */
|
|
6551
7147
|
function classifyFetch(url, ctx) {
|
|
6552
7148
|
if (!ctx.allowedHosts) return SAFE;
|
|
@@ -6772,9 +7368,11 @@ function assessClaudeTool(name, input, ctx) {
|
|
|
6772
7368
|
if (name === "Read") return classifyRead(str(input, "file_path") ?? "", ctx);
|
|
6773
7369
|
if (name === "Grep") {
|
|
6774
7370
|
const target = str(input, "path");
|
|
6775
|
-
|
|
7371
|
+
const filter = str(input, "glob");
|
|
7372
|
+
const root = searchReaches(target ? [target] : [], filter ? [filter] : [], ctx);
|
|
7373
|
+
return mostSevere([target ? classifyRead(target, ctx) : SAFE, root ? credentialsRisk(`Reaches CodeDen's own credentials from ${root}`) : SAFE]);
|
|
6776
7374
|
}
|
|
6777
|
-
if (name === "Glob") return
|
|
7375
|
+
if (name === "Glob") return classifyGlob(str(input, "pattern") ?? "", str(input, "path"), ctx);
|
|
6778
7376
|
if (name === "WebFetch") return classifyFetch(str(input, "url") ?? "", ctx);
|
|
6779
7377
|
return { risky: false };
|
|
6780
7378
|
}
|
|
@@ -6806,7 +7404,8 @@ const FINISH_PLAN = "Finish the plan and present it with ExitPlanMode; once the
|
|
|
6806
7404
|
* - auto -> `acceptEdits`, plus the risky-action classifier
|
|
6807
7405
|
* - full -> `bypassPermissions`, asking only for writes outside the worktree (the OS
|
|
6808
7406
|
* temp directory counts as inside, except for secrets files there)
|
|
6809
|
-
* Plan mode overrides all of them with `plan`.
|
|
7407
|
+
* Plan mode overrides all of them with `plan`. CodeDen's own credential files ask at every
|
|
7408
|
+
* level and are refused for read-only sessions (`../policy/codeden-home`).
|
|
6810
7409
|
*/
|
|
6811
7410
|
function permissionModeFor(autonomy, planMode) {
|
|
6812
7411
|
if (planMode) return "plan";
|
|
@@ -6824,6 +7423,8 @@ var ClaudePolicy = class {
|
|
|
6824
7423
|
ctx;
|
|
6825
7424
|
readOnly;
|
|
6826
7425
|
sessionAllow = /* @__PURE__ */ new Set();
|
|
7426
|
+
/** `allow_session` decisions on calls that touched CodeDen's own credentials (`credentialKey`). */
|
|
7427
|
+
credentialAllow = /* @__PURE__ */ new Set();
|
|
6827
7428
|
/** Plan mode: edits outside Claude's notes and writing commands are refused. */
|
|
6828
7429
|
planning = false;
|
|
6829
7430
|
constructor(autonomy, ctx, readOnly = false) {
|
|
@@ -6852,8 +7453,12 @@ var ClaudePolicy = class {
|
|
|
6852
7453
|
setPlanning(on) {
|
|
6853
7454
|
this.planning = on;
|
|
6854
7455
|
}
|
|
6855
|
-
/**
|
|
6856
|
-
|
|
7456
|
+
/**
|
|
7457
|
+
* Why a call is refused outright (read-only sessions, plan mode), or undefined. `dir` is the
|
|
7458
|
+
* shell's current directory, as for `gate`.
|
|
7459
|
+
*/
|
|
7460
|
+
denial(toolName, input, dir) {
|
|
7461
|
+
if (this.isReadOnly && touchesCodeden(this.assess(toolName, input, dir))) return this.readOnly ? "This session is read-only: CodeDen's own credential files are off limits. Act through CodeDen tools instead." : "This session has read-only permissions: CodeDen's own credential files are off limits. Report what you found instead.";
|
|
6857
7462
|
if (this.planning && !this.readOnly && this.isNotesEdit(toolName, input)) return void 0;
|
|
6858
7463
|
if (!this.isReadOnly) return this.planning ? this.planDenial(toolName, input) : void 0;
|
|
6859
7464
|
if (EDIT_TOOLS$1.has(toolName)) return this.readOnly ? "This session is read-only: it cannot edit files. Act through CodeDen tools instead." : "This session has read-only permissions: it cannot edit files, commit or push. Report what you found instead.";
|
|
@@ -6894,11 +7499,8 @@ var ClaudePolicy = class {
|
|
|
6894
7499
|
action: "plan",
|
|
6895
7500
|
reason: "The agent finished planning"
|
|
6896
7501
|
};
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
...this.ctx,
|
|
6900
|
-
dir
|
|
6901
|
-
} : this.ctx);
|
|
7502
|
+
const risk = this.assess(toolName, input, dir);
|
|
7503
|
+
if (touchesCodeden(risk) ? this.credentialAllow.has(this.credentialKey(toolName, input, dir)) : this.sessionAllow.has(this.key(toolName, input))) return { ask: false };
|
|
6902
7504
|
if (this.autonomy === "supervised") {
|
|
6903
7505
|
if (risk.risky) return {
|
|
6904
7506
|
ask: true,
|
|
@@ -6913,13 +7515,19 @@ var ClaudePolicy = class {
|
|
|
6913
7515
|
return { ask: false };
|
|
6914
7516
|
}
|
|
6915
7517
|
if (!risk.risky) return { ask: false };
|
|
6916
|
-
if (this.autonomy === "full" && risk.action !== "outside_worktree") return { ask: false };
|
|
7518
|
+
if (this.autonomy === "full" && risk.action !== "outside_worktree" && !risk.codeden) return { ask: false };
|
|
6917
7519
|
return {
|
|
6918
7520
|
ask: true,
|
|
6919
7521
|
action: risk.action,
|
|
6920
7522
|
reason: risk.reason
|
|
6921
7523
|
};
|
|
6922
7524
|
}
|
|
7525
|
+
assess(toolName, input, dir) {
|
|
7526
|
+
return assessClaudeTool(toolName, input, dir ? {
|
|
7527
|
+
...this.ctx,
|
|
7528
|
+
dir
|
|
7529
|
+
} : this.ctx);
|
|
7530
|
+
}
|
|
6923
7531
|
/**
|
|
6924
7532
|
* Whether a `canUseTool` call (Claude's own permission prompt) needs the user. In supervised
|
|
6925
7533
|
* mode everything Claude asks about goes to the user; otherwise only gated calls do.
|
|
@@ -6934,9 +7542,17 @@ var ClaudePolicy = class {
|
|
|
6934
7542
|
reason: "Claude asked for permission"
|
|
6935
7543
|
};
|
|
6936
7544
|
}
|
|
6937
|
-
/**
|
|
6938
|
-
|
|
7545
|
+
/**
|
|
7546
|
+
* Remember an `allow_session` decision. Commands and edits match exactly; others by name. A
|
|
7547
|
+
* call that touches CodeDen's own credentials is remembered only as itself, in `dir`.
|
|
7548
|
+
*/
|
|
7549
|
+
allowForSession(toolName, input, dir) {
|
|
6939
7550
|
this.sessionAllow.add(this.key(toolName, input));
|
|
7551
|
+
if (touchesCodeden(this.assess(toolName, input, dir))) this.credentialAllow.add(this.credentialKey(toolName, input, dir));
|
|
7552
|
+
}
|
|
7553
|
+
/** The exact call, in the directory its relative paths resolve from. */
|
|
7554
|
+
credentialKey(toolName, input, dir) {
|
|
7555
|
+
return `${toolName}\n${dir ?? this.ctx.cwd}\n${JSON.stringify(input)}`;
|
|
6940
7556
|
}
|
|
6941
7557
|
key(toolName, input) {
|
|
6942
7558
|
if (COMMAND_TOOLS.has(toolName)) return `${toolName}:${str(input, "command") ?? ""}`;
|
|
@@ -7695,7 +8311,7 @@ function policyHook(policy, shell) {
|
|
|
7695
8311
|
const toolInput = hookInput.tool_input ?? {};
|
|
7696
8312
|
const cwd = str(hookInput, "cwd");
|
|
7697
8313
|
if (cwd && (path.isAbsolute(cwd) || path.win32.isAbsolute(cwd))) shell.dir = cwd;
|
|
7698
|
-
const denied = policy.denial(name, toolInput);
|
|
8314
|
+
const denied = policy.denial(name, toolInput, shell.dir);
|
|
7699
8315
|
if (denied) return { hookSpecificOutput: {
|
|
7700
8316
|
hookEventName: "PreToolUse",
|
|
7701
8317
|
permissionDecision: "deny",
|
|
@@ -8024,23 +8640,130 @@ function statsFromUsageTrailer(text) {
|
|
|
8024
8640
|
};
|
|
8025
8641
|
return stats(field("total_tokens"), field("tool_uses"), field("duration_ms"));
|
|
8026
8642
|
}
|
|
8643
|
+
// ../../packages/providers/src/claude/subagent-usage.ts
|
|
8644
|
+
/** Token counts of one call from a Messages API `usage` object; undefined when it has none. */
|
|
8645
|
+
function callTokens(usage) {
|
|
8646
|
+
if (!usage || typeof usage !== "object") return void 0;
|
|
8647
|
+
const u = usage;
|
|
8648
|
+
const details = u.output_tokens_details;
|
|
8649
|
+
const tokens = {
|
|
8650
|
+
input: count(u.input_tokens) ?? 0,
|
|
8651
|
+
output: count(u.output_tokens) ?? 0,
|
|
8652
|
+
cacheRead: count(u.cache_read_input_tokens) ?? 0,
|
|
8653
|
+
cacheWrite: count(u.cache_creation_input_tokens) ?? 0,
|
|
8654
|
+
reasoning: count(details?.thinking_tokens) ?? 0
|
|
8655
|
+
};
|
|
8656
|
+
return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0 ? tokens : void 0;
|
|
8657
|
+
}
|
|
8658
|
+
/**
|
|
8659
|
+
* Tokens per model across one subagent's own API calls, for its card's cost (§7.3). Claude
|
|
8660
|
+
* streams a call as several `assistant` frames sharing its message id (one per content block),
|
|
8661
|
+
* each with the call's usage as it stood when the call started: input and cache counts are
|
|
8662
|
+
* final, output is not. So calls are keyed by message id, frames of one call merge (the larger
|
|
8663
|
+
* figure wins), and the subagent's transcript, read once it has finished, supplies the final
|
|
8664
|
+
* figures (`applyTranscript`).
|
|
8665
|
+
*/
|
|
8666
|
+
var SubagentUsage = class {
|
|
8667
|
+
calls = /* @__PURE__ */ new Map();
|
|
8668
|
+
/** Fold in one streamed frame (`message` of an `assistant` message). */
|
|
8669
|
+
addFrame(message) {
|
|
8670
|
+
const id = str(message, "id");
|
|
8671
|
+
const tokens = callTokens(message.usage);
|
|
8672
|
+
if (!id || !tokens) return;
|
|
8673
|
+
const model = modelOf(message);
|
|
8674
|
+
const prev = this.calls.get(id);
|
|
8675
|
+
this.calls.set(id, {
|
|
8676
|
+
...model || prev?.model ? { model: model ?? prev?.model } : {},
|
|
8677
|
+
input: Math.max(tokens.input, prev?.input ?? 0),
|
|
8678
|
+
output: Math.max(tokens.output, prev?.output ?? 0),
|
|
8679
|
+
cacheRead: Math.max(tokens.cacheRead, prev?.cacheRead ?? 0),
|
|
8680
|
+
cacheWrite: Math.max(tokens.cacheWrite, prev?.cacheWrite ?? 0),
|
|
8681
|
+
reasoning: Math.max(tokens.reasoning, prev?.reasoning ?? 0)
|
|
8682
|
+
});
|
|
8683
|
+
}
|
|
8684
|
+
/**
|
|
8685
|
+
* Take the final figures of the calls the frames showed from the subagent's transcript
|
|
8686
|
+
* (`getSubagentMessages`): per message id, the transcript wins. Calls only the transcript
|
|
8687
|
+
* holds are left out (an earlier run of a resumed subagent); calls it doesn't hold yet keep
|
|
8688
|
+
* their streamed figures. Returns whether anything changed.
|
|
8689
|
+
*/
|
|
8690
|
+
applyTranscript(messages) {
|
|
8691
|
+
let changed = false;
|
|
8692
|
+
for (const entry of messages) {
|
|
8693
|
+
const raw = str(entry, "type") === "assistant" ? entry.message : void 0;
|
|
8694
|
+
if (!raw || typeof raw !== "object") continue;
|
|
8695
|
+
const message = raw;
|
|
8696
|
+
const id = str(message, "id");
|
|
8697
|
+
const prev = id ? this.calls.get(id) : void 0;
|
|
8698
|
+
const tokens = callTokens(message.usage);
|
|
8699
|
+
if (!id || !prev || !tokens) continue;
|
|
8700
|
+
const model = modelOf(message) ?? prev.model;
|
|
8701
|
+
const next = {
|
|
8702
|
+
...model ? { model } : {},
|
|
8703
|
+
...tokens
|
|
8704
|
+
};
|
|
8705
|
+
if (!sameCall(prev, next)) {
|
|
8706
|
+
this.calls.set(id, next);
|
|
8707
|
+
changed = true;
|
|
8708
|
+
}
|
|
8709
|
+
}
|
|
8710
|
+
return changed;
|
|
8711
|
+
}
|
|
8712
|
+
/** Tokens per model in the order the models were first used; undefined with no calls. */
|
|
8713
|
+
summary() {
|
|
8714
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
8715
|
+
for (const call of this.calls.values()) {
|
|
8716
|
+
const key = call.model ?? "";
|
|
8717
|
+
const sum = byModel.get(key);
|
|
8718
|
+
byModel.set(key, {
|
|
8719
|
+
...call.model ? { model: call.model } : {},
|
|
8720
|
+
input: (sum?.input ?? 0) + call.input,
|
|
8721
|
+
output: (sum?.output ?? 0) + call.output,
|
|
8722
|
+
cacheRead: (sum?.cacheRead ?? 0) + call.cacheRead,
|
|
8723
|
+
cacheWrite: (sum?.cacheWrite ?? 0) + call.cacheWrite,
|
|
8724
|
+
reasoning: (sum?.reasoning ?? 0) + call.reasoning
|
|
8725
|
+
});
|
|
8726
|
+
}
|
|
8727
|
+
if (byModel.size === 0) return void 0;
|
|
8728
|
+
return [...byModel.values()].map((c) => ({
|
|
8729
|
+
...c.model ? { model: c.model } : {},
|
|
8730
|
+
inputTokens: c.input,
|
|
8731
|
+
outputTokens: c.output,
|
|
8732
|
+
cacheReadTokens: c.cacheRead,
|
|
8733
|
+
cacheWriteTokens: c.cacheWrite,
|
|
8734
|
+
...c.reasoning > 0 ? { reasoningTokens: c.reasoning } : {}
|
|
8735
|
+
}));
|
|
8736
|
+
}
|
|
8737
|
+
};
|
|
8738
|
+
/** The model that served a call; Claude Code marks its own placeholder replies `<synthetic>`. */
|
|
8739
|
+
function modelOf(message) {
|
|
8740
|
+
const model = str(message, "model")?.trim();
|
|
8741
|
+
return model && model !== "<synthetic>" ? model : void 0;
|
|
8742
|
+
}
|
|
8743
|
+
function sameCall(a, b) {
|
|
8744
|
+
return a.model === b.model && a.input === b.input && a.output === b.output && a.cacheRead === b.cacheRead && a.cacheWrite === b.cacheWrite && a.reasoning === b.reasoning;
|
|
8745
|
+
}
|
|
8027
8746
|
// ../../packages/providers/src/claude/subagents.ts
|
|
8028
8747
|
/**
|
|
8029
8748
|
* Subagent (Agent/Task tool) runs as child threads (§7.3). The parent gets a `subagent` item;
|
|
8030
8749
|
* messages tagged with `parent_tool_use_id` go to the child thread. Background subagents
|
|
8031
|
-
* finish on `task_notification`, foreground ones on their `tool_result`.
|
|
8750
|
+
* finish on `task_notification`, foreground ones on their `tool_result`. The card's figures
|
|
8751
|
+
* include the run's tokens per model, from its streamed messages; `onFinished` lets the
|
|
8752
|
+
* session read the final figures from the run's transcript (`correctUsage`).
|
|
8032
8753
|
*/
|
|
8033
8754
|
var SubagentTracker = class {
|
|
8034
8755
|
sessionId;
|
|
8035
8756
|
emit;
|
|
8036
8757
|
newId;
|
|
8037
8758
|
onThreadEnd;
|
|
8759
|
+
onFinished;
|
|
8038
8760
|
runs = /* @__PURE__ */ new Map();
|
|
8039
|
-
constructor(sessionId, emit, newId, onThreadEnd = () => {}) {
|
|
8761
|
+
constructor(sessionId, emit, newId, onThreadEnd = () => {}, onFinished = () => {}) {
|
|
8040
8762
|
this.sessionId = sessionId;
|
|
8041
8763
|
this.emit = emit;
|
|
8042
8764
|
this.newId = newId;
|
|
8043
8765
|
this.onThreadEnd = onThreadEnd;
|
|
8766
|
+
this.onFinished = onFinished;
|
|
8044
8767
|
}
|
|
8045
8768
|
ctx(toolUseId) {
|
|
8046
8769
|
return this.runs.get(toolUseId)?.child;
|
|
@@ -8068,7 +8791,8 @@ var SubagentTracker = class {
|
|
|
8068
8791
|
child,
|
|
8069
8792
|
item,
|
|
8070
8793
|
background: input.run_in_background === true,
|
|
8071
|
-
done: false
|
|
8794
|
+
done: false,
|
|
8795
|
+
usage: new SubagentUsage()
|
|
8072
8796
|
});
|
|
8073
8797
|
this.emit({
|
|
8074
8798
|
type: "thread.started",
|
|
@@ -8092,11 +8816,43 @@ var SubagentTracker = class {
|
|
|
8092
8816
|
}
|
|
8093
8817
|
onTaskStarted(msg) {
|
|
8094
8818
|
const run = this.runs.get(str(msg, "tool_use_id") ?? "");
|
|
8095
|
-
if (run
|
|
8819
|
+
if (!run) return;
|
|
8820
|
+
if (msg.is_backgrounded === true) run.background = true;
|
|
8821
|
+
run.agentId ??= str(msg, "task_id");
|
|
8822
|
+
}
|
|
8823
|
+
/** One of the run's own `assistant` messages (`parent_tool_use_id` is its tool use). */
|
|
8824
|
+
onAssistant(toolUseId, msg) {
|
|
8825
|
+
const run = this.runs.get(toolUseId);
|
|
8826
|
+
if (!run || run.done) return;
|
|
8827
|
+
run.agentId ??= str(msg, "agent_id");
|
|
8828
|
+
if (msg.message && typeof msg.message === "object") run.usage.addFrame(msg.message);
|
|
8829
|
+
}
|
|
8830
|
+
/**
|
|
8831
|
+
* The final figures of a finished run's calls, from its transcript: the card is sent again
|
|
8832
|
+
* when they change what it shows (§7.3).
|
|
8833
|
+
*/
|
|
8834
|
+
correctUsage(toolUseId, transcript) {
|
|
8835
|
+
const run = this.runs.get(toolUseId);
|
|
8836
|
+
if (!run?.done || !run.usage.applyTranscript(transcript)) return;
|
|
8837
|
+
const usage = run.usage.summary();
|
|
8838
|
+
if (!usage) return;
|
|
8839
|
+
run.item = {
|
|
8840
|
+
...run.item,
|
|
8841
|
+
stats: {
|
|
8842
|
+
...run.item.stats,
|
|
8843
|
+
usage
|
|
8844
|
+
}
|
|
8845
|
+
};
|
|
8846
|
+
this.emit({
|
|
8847
|
+
type: "item.completed",
|
|
8848
|
+
...run.parent,
|
|
8849
|
+
item: run.item
|
|
8850
|
+
});
|
|
8096
8851
|
}
|
|
8097
8852
|
onToolResult(toolUseId, content, isError, toolUseResult) {
|
|
8098
8853
|
const run = this.runs.get(toolUseId);
|
|
8099
8854
|
if (!run || run.done) return;
|
|
8855
|
+
run.agentId ??= str(toolUseResult, "agentId");
|
|
8100
8856
|
const status = str(toolUseResult, "status");
|
|
8101
8857
|
if (!isError && (status === "async_launched" || toolUseResult?.isAsync === true)) {
|
|
8102
8858
|
run.background = true;
|
|
@@ -8109,6 +8865,8 @@ var SubagentTracker = class {
|
|
|
8109
8865
|
onTaskNotification(msg) {
|
|
8110
8866
|
const toolUseId = str(msg, "tool_use_id");
|
|
8111
8867
|
if (!toolUseId) return;
|
|
8868
|
+
const run = this.runs.get(toolUseId);
|
|
8869
|
+
if (run) run.agentId ??= str(msg, "task_id");
|
|
8112
8870
|
const status = str(msg, "status");
|
|
8113
8871
|
const summary = str(msg, "summary");
|
|
8114
8872
|
this.finish(toolUseId, status === "completed" ? "completed" : status === "failed" ? "failed" : "interrupted", summary, statsFromTaskNotification(msg));
|
|
@@ -8141,16 +8899,23 @@ var SubagentTracker = class {
|
|
|
8141
8899
|
reason: "user"
|
|
8142
8900
|
});
|
|
8143
8901
|
const status = outcome === "completed" ? "completed" : outcome === "failed" ? "failed" : "cancelled";
|
|
8902
|
+
const usage = run.usage.summary();
|
|
8903
|
+
const figures = usage ? {
|
|
8904
|
+
...stats,
|
|
8905
|
+
usage
|
|
8906
|
+
} : stats;
|
|
8907
|
+
run.item = {
|
|
8908
|
+
...run.item,
|
|
8909
|
+
status,
|
|
8910
|
+
...summary ? { summary } : {},
|
|
8911
|
+
...figures ? { stats: figures } : {}
|
|
8912
|
+
};
|
|
8144
8913
|
this.emit({
|
|
8145
8914
|
type: "item.completed",
|
|
8146
8915
|
...run.parent,
|
|
8147
|
-
item:
|
|
8148
|
-
...run.item,
|
|
8149
|
-
status,
|
|
8150
|
-
...summary ? { summary } : {},
|
|
8151
|
-
...stats ? { stats } : {}
|
|
8152
|
-
}
|
|
8916
|
+
item: run.item
|
|
8153
8917
|
});
|
|
8918
|
+
if (usage && run.agentId) this.onFinished(toolUseId, run.agentId);
|
|
8154
8919
|
}
|
|
8155
8920
|
};
|
|
8156
8921
|
/** Drop the CLI's internal trailer (agent ids, usage) from a subagent result. */
|
|
@@ -8434,13 +9199,19 @@ var ClaudeEventMapper = class {
|
|
|
8434
9199
|
retryNoticeId;
|
|
8435
9200
|
/** What the current turn said about a failure, for classifying it (`errors.ts`). */
|
|
8436
9201
|
signals = {};
|
|
9202
|
+
/**
|
|
9203
|
+
* The `uuid` of the current root turn's last main-chain transcript message (an `assistant`
|
|
9204
|
+
* or `user` message without `parent_tool_use_id`): the turn's fork point, where
|
|
9205
|
+
* `forkSession({ upToMessageId })` ends a copy that keeps this turn and no later one.
|
|
9206
|
+
*/
|
|
9207
|
+
lastChainUuid;
|
|
8437
9208
|
/** The `/` command list last reported, so an unchanged list isn't sent again. */
|
|
8438
9209
|
commandsKey;
|
|
8439
9210
|
constructor(opts) {
|
|
8440
9211
|
this.rootThreadId = `${opts.sessionId}:thread-root`;
|
|
8441
9212
|
this.emit = opts.emit;
|
|
8442
9213
|
this.newId = opts.newId ?? randomUUID;
|
|
8443
|
-
this.subagents = new SubagentTracker(opts.sessionId, this.emit, this.newId, (ctx, ok) => this.tools.finishTurn(ctx, ok));
|
|
9214
|
+
this.subagents = new SubagentTracker(opts.sessionId, this.emit, this.newId, (ctx, ok) => this.tools.finishTurn(ctx, ok), opts.onSubagentFinished);
|
|
8444
9215
|
this.tools = new ToolTracker(opts.cwd, (ctx, phase, item) => this.item(ctx, phase, item), this.subagents, this.newId);
|
|
8445
9216
|
this.stream = new StreamAssembler({
|
|
8446
9217
|
rootCtx: () => this.rootCtx(),
|
|
@@ -8491,6 +9262,7 @@ var ClaudeEventMapper = class {
|
|
|
8491
9262
|
this.rootTurnId = turnId;
|
|
8492
9263
|
this.turnBaseline = this.queryTotal;
|
|
8493
9264
|
this.signals = {};
|
|
9265
|
+
this.lastChainUuid = void 0;
|
|
8494
9266
|
this.emit({
|
|
8495
9267
|
type: "turn.started",
|
|
8496
9268
|
threadId: this.rootThreadId,
|
|
@@ -8517,6 +9289,13 @@ var ClaudeEventMapper = class {
|
|
|
8517
9289
|
ctxForTool(toolUseId) {
|
|
8518
9290
|
return this.tools.ctx(toolUseId) ?? this.rootCtx();
|
|
8519
9291
|
}
|
|
9292
|
+
/**
|
|
9293
|
+
* A finished subagent's transcript (`getSubagentMessages`): its calls' final token counts
|
|
9294
|
+
* replace the streamed ones, and the card is sent again when that changes it.
|
|
9295
|
+
*/
|
|
9296
|
+
correctSubagentUsage(toolUseId, transcript) {
|
|
9297
|
+
this.subagents.correctUsage(toolUseId, transcript);
|
|
9298
|
+
}
|
|
8520
9299
|
/** Emit an item event in a context. */
|
|
8521
9300
|
item(ctx, phase, item) {
|
|
8522
9301
|
this.emit({
|
|
@@ -8535,9 +9314,11 @@ var ClaudeEventMapper = class {
|
|
|
8535
9314
|
return;
|
|
8536
9315
|
case "assistant":
|
|
8537
9316
|
this.onAssistant(msg);
|
|
9317
|
+
this.noteChainMessage(msg);
|
|
8538
9318
|
return;
|
|
8539
9319
|
case "user":
|
|
8540
9320
|
this.onUser(msg);
|
|
9321
|
+
this.noteChainMessage(msg);
|
|
8541
9322
|
return;
|
|
8542
9323
|
case "system":
|
|
8543
9324
|
this.onSystem(msg);
|
|
@@ -8560,12 +9341,23 @@ var ClaudeEventMapper = class {
|
|
|
8560
9341
|
default: return;
|
|
8561
9342
|
}
|
|
8562
9343
|
}
|
|
9344
|
+
/**
|
|
9345
|
+
* Keep a root turn's latest main-chain message as its fork point. Subagents' messages don't
|
|
9346
|
+
* count, nor do meta or synthetic ones, which the transcript read that confirms a fork point
|
|
9347
|
+
* (`getSessionMessages`) may leave out.
|
|
9348
|
+
*/
|
|
9349
|
+
noteChainMessage(msg) {
|
|
9350
|
+
if (msg.parent_tool_use_id || !this.rootTurnId || msg.isMeta || msg.isSynthetic) return;
|
|
9351
|
+
const uuid = str(msg, "uuid");
|
|
9352
|
+
if (uuid) this.lastChainUuid = uuid;
|
|
9353
|
+
}
|
|
8563
9354
|
onAssistant(msg) {
|
|
8564
9355
|
const message = msg.message ?? {};
|
|
8565
9356
|
const ctx = this.ctxFor(msg.parent_tool_use_id);
|
|
8566
9357
|
const isRoot = !msg.parent_tool_use_id;
|
|
8567
9358
|
const messageId = str(message, "id") ?? this.newId();
|
|
8568
9359
|
if (isRoot) this.contextTokens = contextTokensFromMessageUsage(message.usage) ?? this.contextTokens;
|
|
9360
|
+
else if (typeof msg.parent_tool_use_id === "string") this.subagents.onAssistant(msg.parent_tool_use_id, msg);
|
|
8569
9361
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
8570
9362
|
for (const block of content) if (block.type === "text" || block.type === "thinking" || block.type === "redacted_thinking") {
|
|
8571
9363
|
const type = block.type === "text" ? "text" : "thinking";
|
|
@@ -8703,19 +9495,24 @@ var ClaudeEventMapper = class {
|
|
|
8703
9495
|
this.tools.finishTurn(ctx, outcome.type === "completed");
|
|
8704
9496
|
this.rootTurnId = void 0;
|
|
8705
9497
|
this.interruptRequested = false;
|
|
9498
|
+
const end = {
|
|
9499
|
+
...ctx,
|
|
9500
|
+
...this.lastChainUuid ? { forkPoint: this.lastChainUuid } : {}
|
|
9501
|
+
};
|
|
9502
|
+
this.lastChainUuid = void 0;
|
|
8706
9503
|
if (outcome.type === "completed") this.emit({
|
|
8707
9504
|
type: "turn.completed",
|
|
8708
|
-
...
|
|
9505
|
+
...end,
|
|
8709
9506
|
stopReason: outcome.stopReason
|
|
8710
9507
|
});
|
|
8711
9508
|
else if (outcome.type === "failed") this.emit({
|
|
8712
9509
|
type: "turn.failed",
|
|
8713
|
-
...
|
|
9510
|
+
...end,
|
|
8714
9511
|
error: outcome.error
|
|
8715
9512
|
});
|
|
8716
9513
|
else this.emit({
|
|
8717
9514
|
type: "turn.interrupted",
|
|
8718
|
-
...
|
|
9515
|
+
...end,
|
|
8719
9516
|
reason: outcome.reason
|
|
8720
9517
|
});
|
|
8721
9518
|
}
|
|
@@ -8967,7 +9764,10 @@ var ClaudeSession = class {
|
|
|
8967
9764
|
this.mapper = new ClaudeEventMapper({
|
|
8968
9765
|
sessionId: opts.sessionId,
|
|
8969
9766
|
cwd: opts.cwd,
|
|
8970
|
-
emit
|
|
9767
|
+
emit,
|
|
9768
|
+
onSubagentFinished: (toolUseId, agentId) => {
|
|
9769
|
+
this.correctSubagentUsage(toolUseId, agentId);
|
|
9770
|
+
}
|
|
8971
9771
|
});
|
|
8972
9772
|
this.broker = new ApprovalBroker(emit);
|
|
8973
9773
|
this.autonomy = opts.autonomy;
|
|
@@ -8975,6 +9775,7 @@ var ClaudeSession = class {
|
|
|
8975
9775
|
cwd: opts.cwd,
|
|
8976
9776
|
extraRoots: opts.additionalDirectories,
|
|
8977
9777
|
...opts.ownBranch ? { ownBranch: opts.ownBranch } : {},
|
|
9778
|
+
...opts.codedenHome ? { codedenHome: opts.codedenHome } : {},
|
|
8978
9779
|
notesRoots: config.notesRoots ?? claudeNotesDirs({
|
|
8979
9780
|
cwd: opts.cwd,
|
|
8980
9781
|
...claudeHomes(opts.env)
|
|
@@ -8989,6 +9790,20 @@ var ClaudeSession = class {
|
|
|
8989
9790
|
return this.mapper.resumeToken ?? this.opts.resumeToken;
|
|
8990
9791
|
}
|
|
8991
9792
|
/**
|
|
9793
|
+
* A subagent's streamed messages don't carry their calls' final output counts; its
|
|
9794
|
+
* transcript does. Read it once the run finished and let the card take the final figures.
|
|
9795
|
+
* If it can't be read (another config folder, not written yet), the streamed ones stay.
|
|
9796
|
+
*/
|
|
9797
|
+
async correctSubagentUsage(toolUseId, agentId) {
|
|
9798
|
+
const read = this.config.getSubagentMessages;
|
|
9799
|
+
const claudeSessionId = this.resumeToken();
|
|
9800
|
+
if (!read || !claudeSessionId || this.closed) return;
|
|
9801
|
+
try {
|
|
9802
|
+
const transcript = await read(claudeSessionId, agentId, { dir: this.opts.cwd });
|
|
9803
|
+
if (!this.closed) this.mapper.correctSubagentUsage(toolUseId, transcript);
|
|
9804
|
+
} catch {}
|
|
9805
|
+
}
|
|
9806
|
+
/**
|
|
8992
9807
|
* Switch autonomy. The policy changes at once, so the next tool call (even in a running
|
|
8993
9808
|
* turn) follows the new level; approvals already waiting stay waiting. The CLI's own
|
|
8994
9809
|
* permission mode follows (`applyPermissionMode`), except while planning: the CLI stays in
|
|
@@ -9211,7 +10026,7 @@ var ClaudeSession = class {
|
|
|
9211
10026
|
mapper: this.mapper
|
|
9212
10027
|
}, input, options.toolUseID, options.signal);
|
|
9213
10028
|
if (toolName === "ExitPlanMode") return this.reviewPlan(input, options);
|
|
9214
|
-
const denied = this.policy.denial(toolName, input);
|
|
10029
|
+
const denied = this.policy.denial(toolName, input, this.shell.dir);
|
|
9215
10030
|
if (denied) return {
|
|
9216
10031
|
behavior: "deny",
|
|
9217
10032
|
message: denied
|
|
@@ -9249,7 +10064,7 @@ var ClaudeSession = class {
|
|
|
9249
10064
|
};
|
|
9250
10065
|
toPermissionResult(toolName, input, response) {
|
|
9251
10066
|
if (response.kind === "approval" && response.decision !== "deny") {
|
|
9252
|
-
if (response.decision === "allow_session") this.policy.allowForSession(toolName, input);
|
|
10067
|
+
if (response.decision === "allow_session") this.policy.allowForSession(toolName, input, this.shell.dir);
|
|
9253
10068
|
return {
|
|
9254
10069
|
behavior: "allow",
|
|
9255
10070
|
updatedInput: input
|
|
@@ -9393,10 +10208,11 @@ var ClaudeAdapter = class {
|
|
|
9393
10208
|
async openSession(opts) {
|
|
9394
10209
|
const executable = this.executable();
|
|
9395
10210
|
if (!executable) throw new Error("Claude Code is not installed (no `claude` on PATH)");
|
|
9396
|
-
const { query } = await (this.options.loadSdk ?? loadClaudeSdk)();
|
|
10211
|
+
const { query, getSubagentMessages } = await (this.options.loadSdk ?? loadClaudeSdk)();
|
|
9397
10212
|
return new ClaudeSession(await withFork(this, opts), {
|
|
9398
10213
|
executable,
|
|
9399
10214
|
query,
|
|
10215
|
+
...getSubagentMessages ? { getSubagentMessages } : {},
|
|
9400
10216
|
onModels: (models) => {
|
|
9401
10217
|
if (!models.length) return;
|
|
9402
10218
|
this.freshness.confirm(executableFingerprint(executable));
|
|
@@ -9405,18 +10221,12 @@ var ClaudeAdapter = class {
|
|
|
9405
10221
|
});
|
|
9406
10222
|
}
|
|
9407
10223
|
/**
|
|
9408
|
-
* Copy a conversation
|
|
9409
|
-
*
|
|
9410
|
-
*
|
|
10224
|
+
* Copy a conversation (see `forkClaudeConversation`): at once, with fresh message ids, the
|
|
10225
|
+
* original untouched; with `upTo`, only through the turn that ended with that message. The
|
|
10226
|
+
* copy is found from any worktree of the project.
|
|
9411
10227
|
*/
|
|
9412
|
-
async forkConversation(resumeToken) {
|
|
9413
|
-
|
|
9414
|
-
try {
|
|
9415
|
-
return (await forkSession(resumeToken)).sessionId;
|
|
9416
|
-
} catch (error) {
|
|
9417
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
9418
|
-
throw new Error(`Could not fork the Claude conversation ${resumeToken}: ${reason}`);
|
|
9419
|
-
}
|
|
10228
|
+
async forkConversation(resumeToken, opts) {
|
|
10229
|
+
return forkClaudeConversation(await (this.options.loadSdk ?? loadClaudeSdk)(), resumeToken, opts?.upTo);
|
|
9420
10230
|
}
|
|
9421
10231
|
utilityModel() {
|
|
9422
10232
|
return new ClaudeUtilityModel(() => this.executable());
|
|
@@ -9733,11 +10543,27 @@ async function startAppServer(exe, handlers, opts = {}) {
|
|
|
9733
10543
|
}
|
|
9734
10544
|
}
|
|
9735
10545
|
// ../../packages/providers/src/codex/fork.ts
|
|
10546
|
+
/** Turns listed per `thread/turns/list` page, and the most pages read to find a turn. */
|
|
10547
|
+
const TURNS_PAGE = 100;
|
|
10548
|
+
const MAX_PAGES$1 = 50;
|
|
10549
|
+
/**
|
|
10550
|
+
* How the app-server answers for a thread or turn it doesn't have. Codex 0.160 says
|
|
10551
|
+
* `no rollout found for thread id <id>` for a deleted thread and `turn not found: <id>` for a
|
|
10552
|
+
* missing `lastTurnId`. Other refusals (a turn still in progress, a malformed id) don't match.
|
|
10553
|
+
*/
|
|
10554
|
+
const MISSING = /\bno rollout found\b|\b(thread|turn|rollout) not found\b/i;
|
|
9736
10555
|
/**
|
|
9737
10556
|
* Copy a Codex thread with `thread/fork` through a short-lived `codex app-server` and return
|
|
9738
10557
|
* the new thread's id. The original thread is untouched; the session that resumes the copy
|
|
9739
10558
|
* sets its own cwd, policy and instructions (`thread/resume` overrides). The process is closed
|
|
9740
10559
|
* before returning, so it no longer holds the new thread when that session resumes it.
|
|
10560
|
+
*
|
|
10561
|
+
* With `upTo` (a turn id), the copy ends with that turn (`lastTurnId`). A thread that no
|
|
10562
|
+
* longer holds the turn, or no longer exists, throws `FORK_POINT_MISSING`: when the turn list
|
|
10563
|
+
* (`thread/turns/list`) shows it before anything is copied, or else when `thread/fork` answers
|
|
10564
|
+
* so. A short-lived app-server usually can't list a thread it hasn't loaded (Codex 0.160 says
|
|
10565
|
+
* `thread not loaded`), so `thread/fork`'s answer is what decides then. Any other failure, and
|
|
10566
|
+
* every failure of a whole copy, stays a plain error.
|
|
9741
10567
|
*/
|
|
9742
10568
|
async function forkCodexThread(exe, threadId, opts) {
|
|
9743
10569
|
const { rpc } = await startAppServer(exe, {
|
|
@@ -9746,17 +10572,47 @@ async function forkCodexThread(exe, threadId, opts) {
|
|
|
9746
10572
|
onExit: () => {}
|
|
9747
10573
|
}, { cwd: opts.cwd });
|
|
9748
10574
|
try {
|
|
9749
|
-
|
|
10575
|
+
if (opts.upTo && await holdsTurn(rpc, threadId, opts.upTo) === false) throw new ProviderSessionError(FORK_POINT_MISSING, "The thread no longer holds that turn (it was replaced or deleted), so it can't be forked there");
|
|
10576
|
+
const params = {
|
|
9750
10577
|
threadId,
|
|
9751
10578
|
cwd: opts.cwd,
|
|
9752
|
-
excludeTurns: true
|
|
9753
|
-
|
|
10579
|
+
excludeTurns: true,
|
|
10580
|
+
...opts.upTo ? { lastTurnId: opts.upTo } : {}
|
|
10581
|
+
};
|
|
10582
|
+
return (await rpc.request("thread/fork", params)).thread.id;
|
|
9754
10583
|
} catch (error) {
|
|
9755
|
-
|
|
10584
|
+
if (error instanceof ProviderSessionError) throw error;
|
|
10585
|
+
const message = error.message;
|
|
10586
|
+
if (opts.upTo && error instanceof CodexRpcError && MISSING.test(message)) throw new ProviderSessionError(FORK_POINT_MISSING, `The thread no longer holds that turn (${message}), so it can't be forked there`);
|
|
10587
|
+
throw new Error(`Could not fork Codex thread ${threadId}: ${message}`);
|
|
9756
10588
|
} finally {
|
|
9757
10589
|
await rpc.close();
|
|
9758
10590
|
}
|
|
9759
10591
|
}
|
|
10592
|
+
/**
|
|
10593
|
+
* Whether the thread still holds `turnId`, from its turn list. Undefined when the list can't be
|
|
10594
|
+
* read (a thread this app-server hasn't loaded, or an app-server without the method):
|
|
10595
|
+
* `thread/fork` then decides.
|
|
10596
|
+
*/
|
|
10597
|
+
async function holdsTurn(rpc, threadId, turnId) {
|
|
10598
|
+
let cursor = null;
|
|
10599
|
+
try {
|
|
10600
|
+
for (let page = 0; page < MAX_PAGES$1; page++) {
|
|
10601
|
+
const res = await rpc.request("thread/turns/list", {
|
|
10602
|
+
threadId,
|
|
10603
|
+
limit: TURNS_PAGE,
|
|
10604
|
+
itemsView: "notLoaded",
|
|
10605
|
+
...cursor ? { cursor } : {}
|
|
10606
|
+
});
|
|
10607
|
+
if (res.data.some((t) => t.id === turnId)) return true;
|
|
10608
|
+
cursor = res.nextCursor;
|
|
10609
|
+
if (!cursor) return false;
|
|
10610
|
+
}
|
|
10611
|
+
return;
|
|
10612
|
+
} catch {
|
|
10613
|
+
return;
|
|
10614
|
+
}
|
|
10615
|
+
}
|
|
9760
10616
|
/** `model/list` pages are followed up to this many (a guard against a looping cursor). */
|
|
9761
10617
|
const MAX_PAGES = 20;
|
|
9762
10618
|
/**
|
|
@@ -9893,78 +10749,369 @@ var CodexUtilityModel = class {
|
|
|
9893
10749
|
* same risk classifier as Claude's tool calls: what the level allows is approved by policy
|
|
9894
10750
|
* (recorded with `decidedBy: "policy"`), and only the rest reaches the user.
|
|
9895
10751
|
*/
|
|
9896
|
-
/**
|
|
10752
|
+
/**
|
|
10753
|
+
* True when the permission level allows an action the classifier assessed as `risk`. CodeDen's
|
|
10754
|
+
* own credential files are never allowed by policy. Commands Codex runs inside its sandbox
|
|
10755
|
+
* without asking (reads included) never reach this policy.
|
|
10756
|
+
*/
|
|
9897
10757
|
function levelAllows(autonomy, risk) {
|
|
9898
10758
|
if (autonomy === "auto") return !risk.risky;
|
|
9899
|
-
if (autonomy === "full") return !risk.risky || risk.action !== "outside_worktree";
|
|
10759
|
+
if (autonomy === "full") return !risk.risky || risk.action !== "outside_worktree" && !risk.codeden;
|
|
9900
10760
|
return false;
|
|
9901
10761
|
}
|
|
9902
|
-
/**
|
|
9903
|
-
|
|
9904
|
-
|
|
10762
|
+
/**
|
|
10763
|
+
* Risk of a command Codex asks to run: the command line as sent and each parsed action, so a
|
|
10764
|
+
* redirect or path the parsed form drops still counts. Here and below, CodeDen's own
|
|
10765
|
+
* credentials win over an earlier ordinary risk (`mostSevere`).
|
|
10766
|
+
*/
|
|
10767
|
+
function commandRisk(texts, ctx) {
|
|
10768
|
+
return mostSevere(texts.filter(Boolean).map((text) => classifyCommand(text, ctx)));
|
|
10769
|
+
}
|
|
10770
|
+
/**
|
|
10771
|
+
* Risk of a patch Codex asks to apply: each file it changes, and the root it asks to write
|
|
10772
|
+
* under for the rest of the session. Unknown paths can't be judged, so they need the user.
|
|
10773
|
+
*/
|
|
10774
|
+
function patchRisk(paths, grantRoot, ctx) {
|
|
10775
|
+
const targets = [...paths, ...grantRoot ? [grantRoot] : []];
|
|
10776
|
+
if (targets.length === 0) return {
|
|
10777
|
+
risky: true,
|
|
10778
|
+
action: "outside_worktree",
|
|
10779
|
+
reason: "Changes files Codex did not name"
|
|
10780
|
+
};
|
|
10781
|
+
return mostSevere(targets.map((target) => classifyWrite(target, ctx)));
|
|
10782
|
+
}
|
|
10783
|
+
/**
|
|
10784
|
+
* Risk of extra permissions Codex asks for: network access (not restricted, as for Claude,
|
|
10785
|
+
* while no host list applies) and folders to read or write. Patterns and special folders can't
|
|
10786
|
+
* be judged, so they need the user.
|
|
10787
|
+
*/
|
|
10788
|
+
function permissionsRisk(permissions, ctx) {
|
|
10789
|
+
const fs = permissions.fileSystem;
|
|
10790
|
+
if (!fs) return { risky: false };
|
|
10791
|
+
const unknown = {
|
|
10792
|
+
risky: true,
|
|
10793
|
+
action: "outside_worktree",
|
|
10794
|
+
reason: "Asks for access to folders by pattern"
|
|
10795
|
+
};
|
|
10796
|
+
return mostSevere([
|
|
10797
|
+
...(fs.write ?? []).map((p) => classifyWrite(p, ctx)),
|
|
10798
|
+
...(fs.read ?? []).map((p) => classifyRead(p, ctx)),
|
|
10799
|
+
...(fs.entries ?? []).map((entry) => {
|
|
10800
|
+
if (entry.path.type !== "path") return unknown;
|
|
10801
|
+
if (entry.access === "write") return classifyWrite(entry.path.path, ctx);
|
|
10802
|
+
return entry.access === "read" ? classifyRead(entry.path.path, ctx) : { risky: false };
|
|
10803
|
+
})
|
|
10804
|
+
]);
|
|
10805
|
+
}
|
|
10806
|
+
// ../../packages/providers/src/codex/approval-context.ts
|
|
10807
|
+
const decisionOf = (r) => r.kind === "approval" ? r.decision : "cancel";
|
|
10808
|
+
/**
|
|
10809
|
+
* Approve `item` by policy when the permission level allows `risk`: record it as decided by
|
|
10810
|
+
* policy (shown in the transcript, never waited on) and return true. Otherwise return false and
|
|
10811
|
+
* the caller asks the user, with the classifier's reason.
|
|
10812
|
+
*/
|
|
10813
|
+
function approvedByPolicy(ac, p, item, risk) {
|
|
10814
|
+
const autonomy = ac.autonomy?.() ?? "supervised";
|
|
10815
|
+
if (!levelAllows(autonomy, risk)) return false;
|
|
10816
|
+
ac.broker.record(ac.ctx(p.threadId, p.turnId), {
|
|
10817
|
+
...item,
|
|
10818
|
+
status: "completed",
|
|
10819
|
+
reason: `Allowed by the ${autonomy === "full" ? "Full" : "Auto"} permission level`,
|
|
10820
|
+
decision: "allow_once",
|
|
10821
|
+
decidedBy: "policy"
|
|
10822
|
+
});
|
|
10823
|
+
return true;
|
|
10824
|
+
}
|
|
10825
|
+
// ../../packages/providers/src/codex/elicitation-form.ts
|
|
10826
|
+
/** The option that leaves an optional field empty. */
|
|
10827
|
+
const LEAVE_EMPTY = "Leave empty";
|
|
10828
|
+
/** A form without fields is a confirmation: one question, accept or decline. */
|
|
10829
|
+
const CONFIRM_ID = "confirm";
|
|
10830
|
+
const ACCEPT = "Accept";
|
|
10831
|
+
const DECLINE = "Decline";
|
|
10832
|
+
function formFields(schema) {
|
|
10833
|
+
const required = new Set(schema.required ?? []);
|
|
10834
|
+
return Object.entries(schema.properties).flatMap(([name, prop], i) => prop ? [field(name, i, prop, required.has(name))] : []);
|
|
10835
|
+
}
|
|
10836
|
+
function field(name, index, s, required) {
|
|
10837
|
+
const description = s.description?.trim();
|
|
10838
|
+
const base = {
|
|
10839
|
+
name,
|
|
10840
|
+
id: name.length > 0 && name.length <= 200 ? name : `field-${index}`,
|
|
10841
|
+
label: s.title?.trim() || name,
|
|
10842
|
+
...description ? { description } : {},
|
|
10843
|
+
required,
|
|
10844
|
+
choices: []
|
|
10845
|
+
};
|
|
10846
|
+
if (s.type === "boolean") return {
|
|
10847
|
+
...base,
|
|
10848
|
+
type: "boolean"
|
|
10849
|
+
};
|
|
10850
|
+
if (s.type === "number" || s.type === "integer") return {
|
|
10851
|
+
...base,
|
|
10852
|
+
type: s.type
|
|
10853
|
+
};
|
|
10854
|
+
if (s.type === "array") {
|
|
10855
|
+
const items = s.items;
|
|
10856
|
+
const choices = "anyOf" in items ? items.anyOf.map((o) => ({
|
|
10857
|
+
value: o.const,
|
|
10858
|
+
label: o.title
|
|
10859
|
+
})) : items.enum.map((value) => ({
|
|
10860
|
+
value,
|
|
10861
|
+
label: value
|
|
10862
|
+
}));
|
|
10863
|
+
return {
|
|
10864
|
+
...base,
|
|
10865
|
+
type: "choices",
|
|
10866
|
+
choices: unique(choices)
|
|
10867
|
+
};
|
|
10868
|
+
}
|
|
10869
|
+
if ("oneOf" in s) return {
|
|
10870
|
+
...base,
|
|
10871
|
+
type: "choice",
|
|
10872
|
+
choices: unique(s.oneOf.map((o) => ({
|
|
10873
|
+
value: o.const,
|
|
10874
|
+
label: o.title
|
|
10875
|
+
})))
|
|
10876
|
+
};
|
|
10877
|
+
if ("enum" in s) {
|
|
10878
|
+
const names = "enumNames" in s ? s.enumNames : void 0;
|
|
10879
|
+
const choices = s.enum.map((value, i) => ({
|
|
10880
|
+
value,
|
|
10881
|
+
label: names?.[i] ?? value
|
|
10882
|
+
}));
|
|
10883
|
+
return {
|
|
10884
|
+
...base,
|
|
10885
|
+
type: "choice",
|
|
10886
|
+
choices: unique(choices)
|
|
10887
|
+
};
|
|
10888
|
+
}
|
|
10889
|
+
return {
|
|
10890
|
+
...base,
|
|
10891
|
+
type: "text"
|
|
10892
|
+
};
|
|
10893
|
+
}
|
|
10894
|
+
/**
|
|
10895
|
+
* Labels are how answers come back, so each must name exactly one value: a repeated label (or
|
|
10896
|
+
* one that reads as {@link LEAVE_EMPTY}) gets its value added, then a number, until it is free.
|
|
10897
|
+
*/
|
|
10898
|
+
function unique(choices) {
|
|
10899
|
+
const seen = /* @__PURE__ */ new Set([LEAVE_EMPTY]);
|
|
10900
|
+
return choices.map((c) => {
|
|
10901
|
+
const label = c.label.trim() || c.value;
|
|
10902
|
+
let free = seen.has(label) ? `${label} (${c.value})` : label;
|
|
10903
|
+
for (let n = 2; seen.has(free); n++) free = `${label} (${c.value}) ${n}`;
|
|
10904
|
+
seen.add(free);
|
|
10905
|
+
return {
|
|
10906
|
+
value: c.value,
|
|
10907
|
+
label: free
|
|
10908
|
+
};
|
|
10909
|
+
});
|
|
10910
|
+
}
|
|
10911
|
+
const HINTS = {
|
|
10912
|
+
integer: " (a whole number)",
|
|
10913
|
+
number: " (a number)"
|
|
10914
|
+
};
|
|
10915
|
+
/** The form as questions; the first one carries the server's message. */
|
|
10916
|
+
function formQuestions(server, message, fields) {
|
|
10917
|
+
const lead = `${server} asks: ${message.trim()}`;
|
|
10918
|
+
if (fields.length === 0) return [{
|
|
10919
|
+
id: CONFIRM_ID,
|
|
10920
|
+
header: server,
|
|
10921
|
+
question: lead,
|
|
10922
|
+
options: [{ label: ACCEPT }, { label: DECLINE }],
|
|
10923
|
+
multiSelect: false,
|
|
10924
|
+
allowFreeText: false
|
|
10925
|
+
}];
|
|
10926
|
+
return fields.map((f, i) => {
|
|
10927
|
+
const ask = `${f.description ?? f.label}${HINTS[f.type] ?? ""}`;
|
|
10928
|
+
const plain = fields.length === 1 && ask === f.label;
|
|
10929
|
+
const question = i > 0 ? ask : plain ? lead : `${lead} — ${ask}`;
|
|
10930
|
+
const options = f.type === "boolean" ? [{ label: "Yes" }, { label: "No" }] : f.choices.map((c) => ({ label: c.label }));
|
|
10931
|
+
return {
|
|
10932
|
+
id: f.id,
|
|
10933
|
+
header: f.label.length > 40 ? `${f.label.slice(0, 39)}…` : f.label,
|
|
10934
|
+
question,
|
|
10935
|
+
options: f.required ? options : [...options, { label: LEAVE_EMPTY }],
|
|
10936
|
+
multiSelect: f.type === "choices",
|
|
10937
|
+
allowFreeText: f.type === "text" || f.type === "number" || f.type === "integer"
|
|
10938
|
+
};
|
|
10939
|
+
});
|
|
9905
10940
|
}
|
|
9906
10941
|
/**
|
|
9907
|
-
*
|
|
9908
|
-
*
|
|
10942
|
+
* The form's content from the answers (keyed by question id), or undefined when a required
|
|
10943
|
+
* field has no answer or an answer doesn't fit its field.
|
|
9909
10944
|
*/
|
|
9910
|
-
function
|
|
9911
|
-
|
|
10945
|
+
function formContent(fields, answers) {
|
|
10946
|
+
const content = {};
|
|
10947
|
+
for (const f of fields) {
|
|
10948
|
+
const given = (answers[f.id] ?? []).map((a) => a.trim()).filter(Boolean);
|
|
10949
|
+
const values = f.required ? given : given.filter((a) => a !== LEAVE_EMPTY);
|
|
10950
|
+
if (values.length === 0) {
|
|
10951
|
+
if (f.required) return void 0;
|
|
10952
|
+
continue;
|
|
10953
|
+
}
|
|
10954
|
+
const value = parse$1(f, values);
|
|
10955
|
+
if (value === void 0) return void 0;
|
|
10956
|
+
content[f.name] = value;
|
|
10957
|
+
}
|
|
10958
|
+
return content;
|
|
10959
|
+
}
|
|
10960
|
+
function parse$1(f, values) {
|
|
10961
|
+
const [one] = values;
|
|
10962
|
+
switch (f.type) {
|
|
10963
|
+
case "text": return values.join("\n");
|
|
10964
|
+
case "number":
|
|
10965
|
+
case "integer": {
|
|
10966
|
+
const n = values.length === 1 ? Number(one) : NaN;
|
|
10967
|
+
return Number.isFinite(n) && (f.type === "number" || Number.isInteger(n)) ? n : void 0;
|
|
10968
|
+
}
|
|
10969
|
+
case "boolean": {
|
|
10970
|
+
const v = values.length === 1 ? one?.toLowerCase() : void 0;
|
|
10971
|
+
return v === "yes" || v === "true" ? true : v === "no" || v === "false" ? false : void 0;
|
|
10972
|
+
}
|
|
10973
|
+
case "choice": return values.length === 1 && one !== void 0 ? choice(f, one) : void 0;
|
|
10974
|
+
case "choices": {
|
|
10975
|
+
const picked = values.map((v) => choice(f, v));
|
|
10976
|
+
return picked.every((v) => v !== void 0) ? picked : void 0;
|
|
10977
|
+
}
|
|
10978
|
+
}
|
|
9912
10979
|
}
|
|
9913
10980
|
/**
|
|
9914
|
-
*
|
|
9915
|
-
*
|
|
10981
|
+
* The value of the option the user picked. The card sends labels, so labels are matched first,
|
|
10982
|
+
* across every option; a raw value (a PM may answer with one) only when no label matches.
|
|
9916
10983
|
*/
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
10984
|
+
const choice = (f, answer) => (f.choices.find((c) => c.label === answer) ?? f.choices.find((c) => c.value === answer))?.value;
|
|
10985
|
+
// ../../packages/providers/src/codex/elicitation.ts
|
|
10986
|
+
const accept = (content, meta = null) => ({
|
|
10987
|
+
action: "accept",
|
|
10988
|
+
content,
|
|
10989
|
+
_meta: meta
|
|
10990
|
+
});
|
|
10991
|
+
const DECLINED = {
|
|
10992
|
+
action: "decline",
|
|
10993
|
+
content: null,
|
|
10994
|
+
_meta: null
|
|
10995
|
+
};
|
|
10996
|
+
const CANCELLED = {
|
|
10997
|
+
action: "cancel",
|
|
10998
|
+
content: null,
|
|
10999
|
+
_meta: null
|
|
11000
|
+
};
|
|
11001
|
+
const READ_ONLY = "This session has read-only permissions: it can't run MCP tools that may change things.";
|
|
11002
|
+
async function handleElicitation(p, ac) {
|
|
11003
|
+
if (!p.turnId) return DECLINED;
|
|
11004
|
+
const where = {
|
|
11005
|
+
threadId: p.threadId,
|
|
11006
|
+
turnId: p.turnId
|
|
9923
11007
|
};
|
|
9924
|
-
|
|
11008
|
+
if (p.mode === "url") return openLink(p, where, ac);
|
|
11009
|
+
const meta = asMeta(p._meta);
|
|
11010
|
+
if (meta.codex_approval_kind === "mcp_tool_call") return toolApproval(p, meta, where, ac);
|
|
11011
|
+
if (p.mode === "form") return fillForm(p, where, ac);
|
|
11012
|
+
return DECLINED;
|
|
9925
11013
|
}
|
|
9926
11014
|
/**
|
|
9927
|
-
*
|
|
9928
|
-
*
|
|
9929
|
-
* be judged, so they need the user.
|
|
11015
|
+
* Codex asks before it runs one of the user's MCP tools that isn't marked read-only (its
|
|
11016
|
+
* `tool_call_mcp_elicitation` feature, on by default).
|
|
9930
11017
|
*/
|
|
9931
|
-
function
|
|
9932
|
-
const
|
|
9933
|
-
|
|
9934
|
-
const
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
11018
|
+
async function toolApproval(p, meta, where, ac) {
|
|
11019
|
+
const server = p.serverName;
|
|
11020
|
+
const tool = text$1(meta.tool_name) ?? text$1(meta.tool_title) ?? /run tool "([^"]+)"/.exec(p.message)?.[1];
|
|
11021
|
+
const persist = meta.persist;
|
|
11022
|
+
const offersSession = persist === "session" || Array.isArray(persist) && persist.includes("session");
|
|
11023
|
+
const requestId = newRequestId();
|
|
11024
|
+
const item = {
|
|
11025
|
+
id: `approval-${requestId}`,
|
|
11026
|
+
kind: "approval_request",
|
|
11027
|
+
status: "in_progress",
|
|
11028
|
+
requestId,
|
|
11029
|
+
action: "tool",
|
|
11030
|
+
title: tool ? `Use ${server}: ${shorten(tool, 80)}` : `Use a tool of ${server}`,
|
|
11031
|
+
...p.message.trim() ? { reason: p.message.trim() } : {},
|
|
11032
|
+
...tool ? { toolName: `mcp__${server}__${tool}` } : {},
|
|
11033
|
+
...meta.tool_params !== void 0 ? { toolInput: toJson(meta.tool_params) } : {},
|
|
11034
|
+
options: offersSession ? [
|
|
11035
|
+
"allow_once",
|
|
11036
|
+
"allow_session",
|
|
11037
|
+
"deny"
|
|
11038
|
+
] : ["allow_once", "deny"]
|
|
9938
11039
|
};
|
|
9939
|
-
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
|
|
11040
|
+
const ctx = ac.ctx(where.threadId, where.turnId);
|
|
11041
|
+
if ((ac.autonomy?.() ?? "supervised") === "read_only") {
|
|
11042
|
+
ac.broker.record(ctx, {
|
|
11043
|
+
...item,
|
|
11044
|
+
status: "declined",
|
|
11045
|
+
reason: READ_ONLY,
|
|
11046
|
+
decision: "deny",
|
|
11047
|
+
decidedBy: "policy"
|
|
11048
|
+
});
|
|
11049
|
+
return DECLINED;
|
|
11050
|
+
}
|
|
11051
|
+
if (!(meta.codex_sensitive_action === true || meta.codex_requires_user_input === true) && approvedByPolicy(ac, where, item, { risky: false })) return accept({});
|
|
11052
|
+
const decision = decisionOf(await ac.broker.request(ctx, item));
|
|
11053
|
+
if (decision === "allow_session" && offersSession) return accept({}, { persist: "session" });
|
|
11054
|
+
if (decision === "allow_once" || decision === "allow_session") return accept({});
|
|
11055
|
+
return decision === "deny" ? DECLINED : CANCELLED;
|
|
11056
|
+
}
|
|
11057
|
+
/** A server's form: a question card built from its schema, the answers sent as its content. */
|
|
11058
|
+
async function fillForm(p, where, ac) {
|
|
11059
|
+
const fields = formFields(p.requestedSchema);
|
|
11060
|
+
const requestId = newRequestId();
|
|
11061
|
+
const response = await ac.broker.request(ac.ctx(where.threadId, where.turnId), {
|
|
11062
|
+
id: `question-${requestId}`,
|
|
11063
|
+
kind: "user_question",
|
|
11064
|
+
status: "in_progress",
|
|
11065
|
+
requestId,
|
|
11066
|
+
mcpServer: p.serverName,
|
|
11067
|
+
questions: formQuestions(p.serverName, p.message, fields)
|
|
11068
|
+
});
|
|
11069
|
+
if (response.kind !== "answer") return CANCELLED;
|
|
11070
|
+
if (fields.length === 0) return response.answers["confirm"]?.includes("Accept") ? accept({}) : DECLINED;
|
|
11071
|
+
const content = formContent(fields, response.answers);
|
|
11072
|
+
return content ? accept(content) : DECLINED;
|
|
9948
11073
|
}
|
|
9949
|
-
// ../../packages/providers/src/codex/approvals.ts
|
|
9950
|
-
const decisionOf = (r) => r.kind === "approval" ? r.decision : "cancel";
|
|
9951
11074
|
/**
|
|
9952
|
-
*
|
|
9953
|
-
*
|
|
9954
|
-
* the caller asks the user, with the classifier's reason.
|
|
11075
|
+
* A server asks the user to open a link (to sign in, say). The card shows the link and the user
|
|
11076
|
+
* opens it: never decided by policy, and nothing opens it on the user's behalf.
|
|
9955
11077
|
*/
|
|
9956
|
-
function
|
|
9957
|
-
const
|
|
9958
|
-
if (!
|
|
9959
|
-
|
|
9960
|
-
|
|
9961
|
-
|
|
9962
|
-
|
|
9963
|
-
|
|
9964
|
-
|
|
11078
|
+
async function openLink(p, where, ac) {
|
|
11079
|
+
const url = webUrl(p.url);
|
|
11080
|
+
if (!url) return DECLINED;
|
|
11081
|
+
const requestId = newRequestId();
|
|
11082
|
+
const response = await ac.broker.request(ac.ctx(where.threadId, where.turnId), {
|
|
11083
|
+
id: `approval-${requestId}`,
|
|
11084
|
+
kind: "approval_request",
|
|
11085
|
+
status: "in_progress",
|
|
11086
|
+
requestId,
|
|
11087
|
+
action: "network",
|
|
11088
|
+
title: `${p.serverName} asks you to open ${url.host}`,
|
|
11089
|
+
...p.message.trim() ? { reason: p.message.trim() } : {},
|
|
11090
|
+
url: url.href,
|
|
11091
|
+
options: ["allow_once", "deny"]
|
|
9965
11092
|
});
|
|
9966
|
-
|
|
11093
|
+
const decision = decisionOf(response);
|
|
11094
|
+
if (decision === "allow_once" || decision === "allow_session") return accept(null);
|
|
11095
|
+
return decision === "deny" ? DECLINED : CANCELLED;
|
|
11096
|
+
}
|
|
11097
|
+
/** Elicitations carry no item id, and app-server request ids restart with each process. */
|
|
11098
|
+
const newRequestId = () => `mcp-${randomUUID()}`;
|
|
11099
|
+
function asMeta(value) {
|
|
11100
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
11101
|
+
}
|
|
11102
|
+
function text$1(value) {
|
|
11103
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
11104
|
+
}
|
|
11105
|
+
/** Web links only: a card never links to a script or a local file. */
|
|
11106
|
+
function webUrl(raw) {
|
|
11107
|
+
try {
|
|
11108
|
+
const url = new URL(raw);
|
|
11109
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url : void 0;
|
|
11110
|
+
} catch {
|
|
11111
|
+
return;
|
|
11112
|
+
}
|
|
9967
11113
|
}
|
|
11114
|
+
// ../../packages/providers/src/codex/approvals.ts
|
|
9968
11115
|
/** The item with the classifier's verdict, for the user to decide. */
|
|
9969
11116
|
function withRisk(item, risk) {
|
|
9970
11117
|
if (!risk.risky) return item;
|
|
@@ -10099,11 +11246,7 @@ async function handleServerRequest(req, ac) {
|
|
|
10099
11246
|
if (response.kind === "answer") for (const [id, values] of Object.entries(response.answers)) answers[id] = { answers: values };
|
|
10100
11247
|
return { answers };
|
|
10101
11248
|
}
|
|
10102
|
-
case "mcpServer/elicitation/request": return
|
|
10103
|
-
action: "decline",
|
|
10104
|
-
content: null,
|
|
10105
|
-
_meta: null
|
|
10106
|
-
};
|
|
11249
|
+
case "mcpServer/elicitation/request": return handleElicitation(req.params, ac);
|
|
10107
11250
|
case "execCommandApproval":
|
|
10108
11251
|
case "applyPatchApproval": return { decision: { denied: { rejection: "Unsupported approval request" } } };
|
|
10109
11252
|
default: throw new CodexRpcError(-32601, `CodeDen does not handle ${req.method}`);
|
|
@@ -10877,6 +12020,21 @@ function tokenUsage(u) {
|
|
|
10877
12020
|
if (u.modelContextWindow && u.modelContextWindow > 0) usage.contextWindow = Math.round(u.modelContextWindow);
|
|
10878
12021
|
return usage;
|
|
10879
12022
|
}
|
|
12023
|
+
/**
|
|
12024
|
+
* A sub-agent's tokens for its card (§7.3), from its thread's running `total`, in the same
|
|
12025
|
+
* terms as `tokenUsage`. `model` is the one its spawn asked for; absent, it runs the session's.
|
|
12026
|
+
*/
|
|
12027
|
+
function subagentUsage(u, model) {
|
|
12028
|
+
const t = tokenUsage(u);
|
|
12029
|
+
return {
|
|
12030
|
+
...model ? { model } : {},
|
|
12031
|
+
inputTokens: t.inputTokens ?? 0,
|
|
12032
|
+
outputTokens: t.outputTokens ?? 0,
|
|
12033
|
+
cacheReadTokens: t.cachedInputTokens ?? 0,
|
|
12034
|
+
cacheWriteTokens: t.cacheWriteTokens ?? 0,
|
|
12035
|
+
...t.reasoningTokens ? { reasoningTokens: t.reasoningTokens } : {}
|
|
12036
|
+
};
|
|
12037
|
+
}
|
|
10880
12038
|
function windowId(minutes, fallback) {
|
|
10881
12039
|
if (minutes === 300) return "5h";
|
|
10882
12040
|
if (minutes === 10080) return "weekly";
|
|
@@ -11005,7 +12163,7 @@ var CodexThreadMapper = class {
|
|
|
11005
12163
|
this.updatePlan(n.params.turnId, n.params.plan);
|
|
11006
12164
|
return;
|
|
11007
12165
|
case "thread/tokenUsage/updated": {
|
|
11008
|
-
this.o.onTokens?.(n.params.tokenUsage
|
|
12166
|
+
this.o.onTokens?.(n.params.tokenUsage);
|
|
11009
12167
|
const usage = this.turnUsage.add(n.params.turnId, n.params.tokenUsage);
|
|
11010
12168
|
if (!this.o.root) {
|
|
11011
12169
|
delete usage.contextTokens;
|
|
@@ -11115,10 +12273,16 @@ var CodexThreadMapper = class {
|
|
|
11115
12273
|
});
|
|
11116
12274
|
}
|
|
11117
12275
|
}
|
|
12276
|
+
/**
|
|
12277
|
+
* Codex ended a turn. On the session's own thread the turn's id is its fork point:
|
|
12278
|
+
* `thread/fork { lastTurnId }` keeps it and drops later turns. Turns CodeDen closes itself
|
|
12279
|
+
* (the process died, the session closed) report none, as Codex may not have finished them.
|
|
12280
|
+
*/
|
|
11118
12281
|
completeTurn(turn) {
|
|
11119
12282
|
this.beginTurn(turn.id);
|
|
11120
|
-
|
|
11121
|
-
|
|
12283
|
+
const forkPoint = this.o.root ? turn.id : void 0;
|
|
12284
|
+
if (turn.status === "completed") this.endTurn({ type: "completed" }, turn.id, forkPoint);
|
|
12285
|
+
else if (turn.status === "interrupted") this.endTurn({ type: "interrupted" }, turn.id, forkPoint);
|
|
11122
12286
|
else {
|
|
11123
12287
|
const info = turn.error?.codexErrorInfo;
|
|
11124
12288
|
const kind = classifyCodexError(info);
|
|
@@ -11130,13 +12294,17 @@ var CodexThreadMapper = class {
|
|
|
11130
12294
|
code: codexErrorCode(info),
|
|
11131
12295
|
kind,
|
|
11132
12296
|
retryAt
|
|
11133
|
-
}, turn.id);
|
|
12297
|
+
}, turn.id, forkPoint);
|
|
11134
12298
|
}
|
|
11135
12299
|
}
|
|
11136
12300
|
/** Close the running turn (also used when the process dies or the session closes). */
|
|
11137
|
-
endTurn(outcome, turnId = this.current) {
|
|
12301
|
+
endTurn(outcome, turnId = this.current, forkPoint) {
|
|
11138
12302
|
if (!turnId || turnId !== this.current) return;
|
|
11139
12303
|
const ctx = this.ctx(turnId);
|
|
12304
|
+
const end = {
|
|
12305
|
+
...ctx,
|
|
12306
|
+
...forkPoint ? { forkPoint } : {}
|
|
12307
|
+
};
|
|
11140
12308
|
const ok = outcome.type === "completed";
|
|
11141
12309
|
for (const item of this.open.values()) this.o.emit({
|
|
11142
12310
|
type: "item.completed",
|
|
@@ -11163,17 +12331,17 @@ var CodexThreadMapper = class {
|
|
|
11163
12331
|
this.current = void 0;
|
|
11164
12332
|
if (outcome.type === "completed") this.o.emit({
|
|
11165
12333
|
type: "turn.completed",
|
|
11166
|
-
...
|
|
12334
|
+
...end,
|
|
11167
12335
|
stopReason: "end_turn"
|
|
11168
12336
|
});
|
|
11169
12337
|
else if (outcome.type === "interrupted") this.o.emit({
|
|
11170
12338
|
type: "turn.interrupted",
|
|
11171
|
-
...
|
|
12339
|
+
...end,
|
|
11172
12340
|
reason: "user"
|
|
11173
12341
|
});
|
|
11174
12342
|
else this.o.emit({
|
|
11175
12343
|
type: "turn.failed",
|
|
11176
|
-
...
|
|
12344
|
+
...end,
|
|
11177
12345
|
error: {
|
|
11178
12346
|
code: outcome.code ?? "turn_failed",
|
|
11179
12347
|
message: outcome.message,
|
|
@@ -11248,6 +12416,7 @@ var CodexSubagents = class {
|
|
|
11248
12416
|
return false;
|
|
11249
12417
|
}
|
|
11250
12418
|
const run = this.claim(from, turnId, item.id, spawnDetails(item));
|
|
12419
|
+
if (item.model) run.model ??= item.model;
|
|
11251
12420
|
const child = item.receiverThreadIds[0];
|
|
11252
12421
|
if (child) this.bind(run, child);
|
|
11253
12422
|
if (phase !== "completed") return true;
|
|
@@ -11386,8 +12555,8 @@ var CodexSubagents = class {
|
|
|
11386
12555
|
prompt: item.input.text
|
|
11387
12556
|
};
|
|
11388
12557
|
},
|
|
11389
|
-
onTokens: (
|
|
11390
|
-
run.
|
|
12558
|
+
onTokens: (usage) => {
|
|
12559
|
+
run.tokens = usage;
|
|
11391
12560
|
}
|
|
11392
12561
|
});
|
|
11393
12562
|
run.thread = thread;
|
|
@@ -11488,9 +12657,10 @@ var CodexSubagents = class {
|
|
|
11488
12657
|
stats(run) {
|
|
11489
12658
|
const end = run.endedAt ?? this.o.now();
|
|
11490
12659
|
return {
|
|
11491
|
-
...run.
|
|
12660
|
+
...run.tokens ? { totalTokens: Math.max(0, Math.round(run.tokens.total.totalTokens)) } : {},
|
|
11492
12661
|
toolUses: run.toolCalls.size,
|
|
11493
|
-
durationMs: Math.max(0, Math.round(end - run.startedAt))
|
|
12662
|
+
durationMs: Math.max(0, Math.round(end - run.startedAt)),
|
|
12663
|
+
...run.tokens ? { usage: [subagentUsage(run.tokens, run.model)] } : {}
|
|
11494
12664
|
};
|
|
11495
12665
|
}
|
|
11496
12666
|
};
|
|
@@ -11837,7 +13007,8 @@ var CodexSession = class {
|
|
|
11837
13007
|
risk: {
|
|
11838
13008
|
cwd: this.opts.cwd,
|
|
11839
13009
|
extraRoots: this.opts.additionalDirectories,
|
|
11840
|
-
...this.opts.ownBranch ? { ownBranch: this.opts.ownBranch } : {}
|
|
13010
|
+
...this.opts.ownBranch ? { ownBranch: this.opts.ownBranch } : {},
|
|
13011
|
+
...this.opts.codedenHome ? { codedenHome: this.opts.codedenHome } : {}
|
|
11841
13012
|
},
|
|
11842
13013
|
autonomy: () => this.opts.readOnly ? "read_only" : this.autonomy,
|
|
11843
13014
|
ctx: (threadId, turnId) => this.mapper.requestCtx(threadId, turnId),
|
|
@@ -12054,7 +13225,10 @@ var CodexAdapter = class {
|
|
|
12054
13225
|
}
|
|
12055
13226
|
return session;
|
|
12056
13227
|
}
|
|
12057
|
-
/**
|
|
13228
|
+
/**
|
|
13229
|
+
* Copy a thread with `thread/fork` (see `forkCodexThread`); returns the new thread's id. With
|
|
13230
|
+
* `upTo` (a turn id), the copy ends with that turn.
|
|
13231
|
+
*/
|
|
12058
13232
|
async forkConversation(resumeToken, opts) {
|
|
12059
13233
|
const executable = this.executable();
|
|
12060
13234
|
if (!executable) throw new Error("Codex is not installed (no `codex` on PATH)");
|
|
@@ -12316,6 +13490,7 @@ var FakeSession = class {
|
|
|
12316
13490
|
}
|
|
12317
13491
|
async runTurn(turn, input) {
|
|
12318
13492
|
const { threadId, turnId } = turn;
|
|
13493
|
+
const forkPoint = turnId;
|
|
12319
13494
|
const prompt = input.text;
|
|
12320
13495
|
try {
|
|
12321
13496
|
this.emit({
|
|
@@ -12342,6 +13517,7 @@ var FakeSession = class {
|
|
|
12342
13517
|
type: "turn.failed",
|
|
12343
13518
|
threadId,
|
|
12344
13519
|
turnId,
|
|
13520
|
+
forkPoint,
|
|
12345
13521
|
error: {
|
|
12346
13522
|
code: "fake_failure",
|
|
12347
13523
|
message: "Scripted failure",
|
|
@@ -12357,6 +13533,7 @@ var FakeSession = class {
|
|
|
12357
13533
|
type: "turn.failed",
|
|
12358
13534
|
threadId,
|
|
12359
13535
|
turnId,
|
|
13536
|
+
forkPoint,
|
|
12360
13537
|
error: usage ? {
|
|
12361
13538
|
code: "usage_limit",
|
|
12362
13539
|
message: "Scripted usage limit",
|
|
@@ -12378,6 +13555,7 @@ var FakeSession = class {
|
|
|
12378
13555
|
type: "turn.completed",
|
|
12379
13556
|
threadId,
|
|
12380
13557
|
turnId,
|
|
13558
|
+
forkPoint,
|
|
12381
13559
|
stopReason: "end_turn"
|
|
12382
13560
|
});
|
|
12383
13561
|
return;
|
|
@@ -12426,6 +13604,7 @@ var FakeSession = class {
|
|
|
12426
13604
|
type: "turn.completed",
|
|
12427
13605
|
threadId,
|
|
12428
13606
|
turnId,
|
|
13607
|
+
forkPoint,
|
|
12429
13608
|
stopReason: "end_turn"
|
|
12430
13609
|
});
|
|
12431
13610
|
} catch (error) {
|
|
@@ -12434,6 +13613,7 @@ var FakeSession = class {
|
|
|
12434
13613
|
type: "turn.interrupted",
|
|
12435
13614
|
threadId,
|
|
12436
13615
|
turnId,
|
|
13616
|
+
forkPoint,
|
|
12437
13617
|
reason: "user"
|
|
12438
13618
|
});
|
|
12439
13619
|
return;
|
|
@@ -12443,6 +13623,7 @@ var FakeSession = class {
|
|
|
12443
13623
|
type: "turn.failed",
|
|
12444
13624
|
threadId,
|
|
12445
13625
|
turnId,
|
|
13626
|
+
forkPoint,
|
|
12446
13627
|
error: { message }
|
|
12447
13628
|
});
|
|
12448
13629
|
}
|
|
@@ -12843,10 +14024,13 @@ var FakeProvider = class {
|
|
|
12843
14024
|
async openSession(opts) {
|
|
12844
14025
|
return new FakeSession(await withFork(this, opts), this.options);
|
|
12845
14026
|
}
|
|
12846
|
-
/**
|
|
12847
|
-
|
|
14027
|
+
/**
|
|
14028
|
+
* A fork gets a conversation of its own, named after the one it copies and, with `upTo`,
|
|
14029
|
+
* the turn it ends with (`<token>:fork-<n>@<turn>`).
|
|
14030
|
+
*/
|
|
14031
|
+
async forkConversation(resumeToken, opts) {
|
|
12848
14032
|
this.forks += 1;
|
|
12849
|
-
return `${resumeToken}:fork-${this.forks}`;
|
|
14033
|
+
return `${resumeToken}:fork-${this.forks}${opts?.upTo ? `@${opts.upTo}` : ""}`;
|
|
12850
14034
|
}
|
|
12851
14035
|
forks = 0;
|
|
12852
14036
|
};
|
|
@@ -14525,25 +15709,32 @@ var OpenCodePolicy = class {
|
|
|
14525
15709
|
};
|
|
14526
15710
|
}
|
|
14527
15711
|
}
|
|
14528
|
-
/** Remember an `allow_session` decision: commands and paths match exactly. */
|
|
15712
|
+
/** Remember an `allow_session` decision: commands (in their directory) and paths match exactly. */
|
|
14529
15713
|
allowForSession(call) {
|
|
14530
15714
|
this.sessionAllow.add(this.key(call));
|
|
14531
15715
|
}
|
|
14532
15716
|
shell(call) {
|
|
14533
15717
|
const command = commandOf(call);
|
|
15718
|
+
const risk = classifyCommand(command, {
|
|
15719
|
+
...this.ctx,
|
|
15720
|
+
dir: this.workdir(call)
|
|
15721
|
+
});
|
|
14534
15722
|
if (this.isReadOnly) {
|
|
15723
|
+
if (touchesCodeden(risk)) return this.credentialsDenial();
|
|
14535
15724
|
const reason = readOnlyCommandDenial(command, { allowChecks: !this.readOnly });
|
|
14536
15725
|
return reason ? {
|
|
14537
15726
|
kind: "deny",
|
|
14538
15727
|
message: `This session is read-only: ${reason}.`
|
|
14539
15728
|
} : ALLOW;
|
|
14540
15729
|
}
|
|
14541
|
-
|
|
14542
|
-
|
|
14543
|
-
|
|
14544
|
-
|
|
14545
|
-
|
|
14546
|
-
|
|
15730
|
+
return this.gate(risk, "command");
|
|
15731
|
+
}
|
|
15732
|
+
/** Read-only sessions never touch CodeDen's own credential files. */
|
|
15733
|
+
credentialsDenial() {
|
|
15734
|
+
return {
|
|
15735
|
+
kind: "deny",
|
|
15736
|
+
message: this.readOnly ? "This session is read-only: CodeDen's own credential files are off limits. Act through CodeDen tools instead." : "This session has read-only permissions: CodeDen's own credential files are off limits. Report what you found instead."
|
|
15737
|
+
};
|
|
14547
15738
|
}
|
|
14548
15739
|
edit(paths) {
|
|
14549
15740
|
if (this.isReadOnly) return {
|
|
@@ -14552,19 +15743,19 @@ var OpenCodePolicy = class {
|
|
|
14552
15743
|
};
|
|
14553
15744
|
return this.gate(this.firstRisk(paths, classifyWrite), "file_change");
|
|
14554
15745
|
}
|
|
15746
|
+
/** The verdict for every target: CodeDen's own credentials first, then the first risky one. */
|
|
14555
15747
|
firstRisk(targets, classify) {
|
|
14556
|
-
|
|
14557
|
-
const risk = classify(target, this.ctx);
|
|
14558
|
-
if (risk.risky) return risk;
|
|
14559
|
-
}
|
|
14560
|
-
return { risky: false };
|
|
15748
|
+
return mostSevere(targets.map((target) => classify(target, this.ctx)));
|
|
14561
15749
|
}
|
|
14562
15750
|
/**
|
|
14563
15751
|
* The level's gate (the same as Claude's): edits and commands (`supervisedAction`) always
|
|
14564
15752
|
* ask in supervised mode; `alwaysRisky` asks for flagged calls even at full autonomy
|
|
14565
|
-
* (secrets reads).
|
|
15753
|
+
* (secrets reads). CodeDen's own credential files ask at every level and are refused for
|
|
15754
|
+
* read-only sessions.
|
|
14566
15755
|
*/
|
|
14567
15756
|
gate(risk, supervisedAction, alwaysRisky = false) {
|
|
15757
|
+
const codeden = touchesCodeden(risk);
|
|
15758
|
+
if (codeden && this.isReadOnly) return this.credentialsDenial();
|
|
14568
15759
|
if (this.autonomy === "supervised") {
|
|
14569
15760
|
if (risk.risky) return {
|
|
14570
15761
|
kind: "ask",
|
|
@@ -14579,18 +15770,28 @@ var OpenCodePolicy = class {
|
|
|
14579
15770
|
return ALLOW;
|
|
14580
15771
|
}
|
|
14581
15772
|
if (!risk.risky) return ALLOW;
|
|
14582
|
-
|
|
15773
|
+
const always = alwaysRisky || codeden;
|
|
15774
|
+
if (this.autonomy === "full" && risk.action !== "outside_worktree" && !always) return ALLOW;
|
|
14583
15775
|
return {
|
|
14584
15776
|
kind: "ask",
|
|
14585
15777
|
action: risk.action,
|
|
14586
15778
|
reason: risk.reason
|
|
14587
15779
|
};
|
|
14588
15780
|
}
|
|
15781
|
+
/**
|
|
15782
|
+
* What an approval for the session covers: a command in its working directory (the same
|
|
15783
|
+
* relative path elsewhere is another file), or the exact paths.
|
|
15784
|
+
*/
|
|
14589
15785
|
key(call) {
|
|
14590
15786
|
const { action, resources } = call.request;
|
|
14591
|
-
if (action === "shell") return `shell:${commandOf(call)}`;
|
|
15787
|
+
if (action === "shell") return `shell:${this.workdir(call)}\n${commandOf(call)}`;
|
|
14592
15788
|
return `${action}:${[...resources].sort().join("\n")}`;
|
|
14593
15789
|
}
|
|
15790
|
+
/** Where a shell call runs: its `workdir`, else the worktree. */
|
|
15791
|
+
workdir(call) {
|
|
15792
|
+
const workdir = str(call.toolInput, "workdir");
|
|
15793
|
+
return workdir ? resolveAgainst(workdir, this.ctx) : this.ctx.cwd;
|
|
15794
|
+
}
|
|
14594
15795
|
};
|
|
14595
15796
|
/** The command an OpenCode `shell` ask is about: the tool input, else its parsed parts. */
|
|
14596
15797
|
function commandOf(call) {
|
|
@@ -15677,7 +16878,8 @@ var OpenCodeSession = class {
|
|
|
15677
16878
|
this.broker = new ApprovalBroker(emit);
|
|
15678
16879
|
this.policy = new OpenCodePolicy(opts.autonomy, {
|
|
15679
16880
|
cwd: opts.cwd,
|
|
15680
|
-
extraRoots: opts.additionalDirectories
|
|
16881
|
+
extraRoots: opts.additionalDirectories,
|
|
16882
|
+
...opts.codedenHome ? { codedenHome: opts.codedenHome } : {}
|
|
15681
16883
|
}, opts.readOnly ?? false);
|
|
15682
16884
|
this.model = explicitModel(opts.model);
|
|
15683
16885
|
this.effort = opts.effort;
|
|
@@ -21832,6 +23034,7 @@ async function executeCommand(core, command) {
|
|
|
21832
23034
|
if (added.length > 0) core.log?.(`PATH: picked up ${added.join(", ")}`);
|
|
21833
23035
|
const adapters = command.providerId ? registry.list().filter((a) => a.id === command.providerId) : registry.list();
|
|
21834
23036
|
const statuses = await Promise.all(adapters.map((a) => a.detect()));
|
|
23037
|
+
if (command.refreshModels === false) return { statuses };
|
|
21835
23038
|
const refreshed = registry.refreshModels(adapters.map((a) => a.id));
|
|
21836
23039
|
if (!command.waitForModels) return { statuses };
|
|
21837
23040
|
await refreshed;
|
|
@@ -21858,9 +23061,12 @@ async function executeCommand(core, command) {
|
|
|
21858
23061
|
const adapter = registry.get(command.providerId);
|
|
21859
23062
|
if (!adapter?.capabilities().fork || !adapter.forkConversation) throw new CommandError("unsupported", `Provider ${command.providerId} cannot fork`);
|
|
21860
23063
|
try {
|
|
21861
|
-
return { resumeToken: await adapter.forkConversation(command.resumeToken, {
|
|
23064
|
+
return { resumeToken: await adapter.forkConversation(command.resumeToken, {
|
|
23065
|
+
cwd: command.cwd,
|
|
23066
|
+
...command.upTo ? { upTo: command.upTo } : {}
|
|
23067
|
+
}) };
|
|
21862
23068
|
} catch (error) {
|
|
21863
|
-
throw new CommandError("fork_failed", error.message);
|
|
23069
|
+
throw new CommandError(error.code === FORK_POINT_MISSING ? FORK_POINT_MISSING : "fork_failed", error.message);
|
|
21864
23070
|
}
|
|
21865
23071
|
}
|
|
21866
23072
|
case "workspace.prepare":
|
|
@@ -21943,7 +23149,7 @@ async function executeCommand(core, command) {
|
|
|
21943
23149
|
case "checkpoint.restoreFiles": return restoreFiles(command.cwd, command.sessionId, command.index, command.paths);
|
|
21944
23150
|
case "ports.list": return { ports: await listPorts(command.probe) };
|
|
21945
23151
|
case "mcp.test": {
|
|
21946
|
-
const { testMcpServer } = await import("./server-test-
|
|
23152
|
+
const { testMcpServer } = await import("./server-test-BwyCFssm.js");
|
|
21947
23153
|
return testMcpServer({
|
|
21948
23154
|
spec: command.spec,
|
|
21949
23155
|
cwd: command.cwd,
|
|
@@ -22004,21 +23210,44 @@ function removeOwnLock(lockFile) {
|
|
|
22004
23210
|
* monotonically increasing seq and kept until the server acks them, so nothing is lost
|
|
22005
23211
|
* while the server is away. `state.json` holds the stable runner id and seq counters.
|
|
22006
23212
|
*
|
|
22007
|
-
*
|
|
23213
|
+
* An ack only moves the acked position in memory. While acks flow, the journal persists at
|
|
23214
|
+
* most every `persistMs`: it writes `state.json`, then, when enough acked events have piled
|
|
23215
|
+
* up and they outweigh what is left, rewrites the file without them (compaction), so each
|
|
23216
|
+
* ack costs no more than its append did. State always goes first, so `lastSeq` outlives the
|
|
23217
|
+
* events a compaction drops. `close` persists and compacts at once. After a crash the journal
|
|
23218
|
+
* may still hold events the server already acked; the server's `resume` acks them again and
|
|
23219
|
+
* they are not replayed.
|
|
22008
23220
|
*/
|
|
22009
23221
|
var Journal = class {
|
|
22010
23222
|
file;
|
|
22011
23223
|
stateFile;
|
|
23224
|
+
compactEntries;
|
|
23225
|
+
compactBytes;
|
|
23226
|
+
persistMs;
|
|
22012
23227
|
state;
|
|
23228
|
+
/** Every event in `journal.jsonl`, oldest first; those before `head` are acked. */
|
|
22013
23229
|
entries = [];
|
|
22014
|
-
|
|
23230
|
+
head = 0;
|
|
23231
|
+
fileBytes = 0;
|
|
23232
|
+
ackedBytes = 0;
|
|
23233
|
+
persistTimer;
|
|
23234
|
+
constructor(dir, opts = {}) {
|
|
22015
23235
|
mkdirSync(dir, { recursive: true });
|
|
22016
23236
|
this.file = join(dir, "journal.jsonl");
|
|
22017
23237
|
this.stateFile = join(dir, "state.json");
|
|
23238
|
+
this.compactEntries = opts.compactEntries ?? 1e3;
|
|
23239
|
+
this.compactBytes = opts.compactBytes ?? 1048576;
|
|
23240
|
+
this.persistMs = opts.persistMs ?? 250;
|
|
22018
23241
|
this.state = this.loadState();
|
|
22019
|
-
|
|
22020
|
-
|
|
22021
|
-
this.
|
|
23242
|
+
const { entries, torn } = this.loadEntries();
|
|
23243
|
+
this.entries = entries;
|
|
23244
|
+
for (const e of entries) this.fileBytes += e.bytes;
|
|
23245
|
+
this.state.lastSeq = Math.max(this.state.lastSeq, entries.at(-1)?.msg.seq ?? 0);
|
|
23246
|
+
this.advanceHead(this.state.ackedSeq);
|
|
23247
|
+
if (torn) {
|
|
23248
|
+
this.writeState(this.state);
|
|
23249
|
+
this.compact();
|
|
23250
|
+
}
|
|
22022
23251
|
}
|
|
22023
23252
|
get runnerId() {
|
|
22024
23253
|
return this.state.runnerId;
|
|
@@ -22027,7 +23256,7 @@ var Journal = class {
|
|
|
22027
23256
|
return this.state.lastSeq;
|
|
22028
23257
|
}
|
|
22029
23258
|
get unacked() {
|
|
22030
|
-
return this.entries.length;
|
|
23259
|
+
return this.entries.length - this.head;
|
|
22031
23260
|
}
|
|
22032
23261
|
/** Assign the next seq, persist the event, and return the wire message. */
|
|
22033
23262
|
append(event) {
|
|
@@ -22038,20 +23267,84 @@ var Journal = class {
|
|
|
22038
23267
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22039
23268
|
event
|
|
22040
23269
|
};
|
|
22041
|
-
|
|
22042
|
-
this.
|
|
23270
|
+
const line = `${JSON.stringify(msg)}\n`;
|
|
23271
|
+
appendFileSync(this.file, line);
|
|
23272
|
+
const bytes = Buffer.byteLength(line);
|
|
23273
|
+
this.entries.push({
|
|
23274
|
+
msg,
|
|
23275
|
+
bytes
|
|
23276
|
+
});
|
|
23277
|
+
this.fileBytes += bytes;
|
|
22043
23278
|
return msg;
|
|
22044
23279
|
}
|
|
22045
|
-
/**
|
|
23280
|
+
/** Acknowledge everything with `seq <= seq` (cumulative ack). */
|
|
22046
23281
|
ack(seq) {
|
|
22047
|
-
|
|
22048
|
-
|
|
22049
|
-
this.
|
|
22050
|
-
this.
|
|
23282
|
+
const upTo = Math.min(seq, this.state.lastSeq);
|
|
23283
|
+
if (upTo <= this.state.ackedSeq) return;
|
|
23284
|
+
this.state.ackedSeq = upTo;
|
|
23285
|
+
this.advanceHead(upTo);
|
|
23286
|
+
this.schedulePersist();
|
|
22051
23287
|
}
|
|
22052
23288
|
/** Unacked events after `seq`, oldest first (for replay on resume). */
|
|
22053
23289
|
after(seq) {
|
|
22054
|
-
|
|
23290
|
+
const out = [];
|
|
23291
|
+
for (let i = this.head; i < this.entries.length; i++) {
|
|
23292
|
+
const msg = this.entries[i]?.msg;
|
|
23293
|
+
if (msg && msg.seq > seq) out.push(msg);
|
|
23294
|
+
}
|
|
23295
|
+
return out;
|
|
23296
|
+
}
|
|
23297
|
+
/** Persist the acked position and drop every acked event from the file (runner shutdown). */
|
|
23298
|
+
close() {
|
|
23299
|
+
this.persist(true);
|
|
23300
|
+
}
|
|
23301
|
+
advanceHead(ackedSeq) {
|
|
23302
|
+
for (;;) {
|
|
23303
|
+
const entry = this.entries[this.head];
|
|
23304
|
+
if (!entry || entry.msg.seq > ackedSeq) return;
|
|
23305
|
+
this.ackedBytes += entry.bytes;
|
|
23306
|
+
this.head += 1;
|
|
23307
|
+
}
|
|
23308
|
+
}
|
|
23309
|
+
/** Enough acked events piled up, and rewriting what is left costs no more than they do. */
|
|
23310
|
+
compactDue() {
|
|
23311
|
+
return (this.head >= this.compactEntries || this.ackedBytes >= this.compactBytes) && this.ackedBytes >= this.fileBytes - this.ackedBytes;
|
|
23312
|
+
}
|
|
23313
|
+
schedulePersist() {
|
|
23314
|
+
if (this.persistTimer) return;
|
|
23315
|
+
this.persistTimer = setTimeout(() => {
|
|
23316
|
+
this.persistTimer = void 0;
|
|
23317
|
+
try {
|
|
23318
|
+
this.persist(false);
|
|
23319
|
+
} catch {}
|
|
23320
|
+
}, this.persistMs);
|
|
23321
|
+
this.persistTimer.unref();
|
|
23322
|
+
}
|
|
23323
|
+
/**
|
|
23324
|
+
* Write the acked position, then compact when it is due (`all`: whenever anything is acked).
|
|
23325
|
+
* State first, so `lastSeq` outlives the events the compaction drops.
|
|
23326
|
+
*/
|
|
23327
|
+
persist(all) {
|
|
23328
|
+
if (this.persistTimer) clearTimeout(this.persistTimer);
|
|
23329
|
+
this.persistTimer = void 0;
|
|
23330
|
+
this.writeState(this.state);
|
|
23331
|
+
if (all ? this.head > 0 : this.compactDue()) this.compact();
|
|
23332
|
+
}
|
|
23333
|
+
/** Rewrite the file without its acked events. Callers write the state first. */
|
|
23334
|
+
compact() {
|
|
23335
|
+
const live = this.entries.slice(this.head);
|
|
23336
|
+
const tmp = `${this.file}.tmp`;
|
|
23337
|
+
writeFileSync(tmp, live.map((e) => `${JSON.stringify(e.msg)}\n`).join(""));
|
|
23338
|
+
renameSync(tmp, this.file);
|
|
23339
|
+
this.entries = live;
|
|
23340
|
+
this.head = 0;
|
|
23341
|
+
this.fileBytes -= this.ackedBytes;
|
|
23342
|
+
this.ackedBytes = 0;
|
|
23343
|
+
}
|
|
23344
|
+
writeState(state) {
|
|
23345
|
+
const tmp = `${this.stateFile}.tmp`;
|
|
23346
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
23347
|
+
renameSync(tmp, this.stateFile);
|
|
22055
23348
|
}
|
|
22056
23349
|
loadState() {
|
|
22057
23350
|
try {
|
|
@@ -22071,25 +23364,26 @@ var Journal = class {
|
|
|
22071
23364
|
return fresh;
|
|
22072
23365
|
}
|
|
22073
23366
|
loadEntries() {
|
|
22074
|
-
if (!existsSync(this.file)) return
|
|
22075
|
-
|
|
22076
|
-
|
|
23367
|
+
if (!existsSync(this.file)) return {
|
|
23368
|
+
entries: [],
|
|
23369
|
+
torn: false
|
|
23370
|
+
};
|
|
23371
|
+
const raw = readFileSync(this.file, "utf8");
|
|
23372
|
+
const entries = [];
|
|
23373
|
+
let torn = raw.length > 0 && !raw.endsWith("\n");
|
|
23374
|
+
for (const line of raw.split("\n")) {
|
|
22077
23375
|
if (!line.trim()) continue;
|
|
22078
23376
|
const parsed = RunnerEventMessage.safeParse(safeJson(line));
|
|
22079
|
-
if (parsed.success)
|
|
23377
|
+
if (parsed.success) entries.push({
|
|
23378
|
+
msg: parsed.data,
|
|
23379
|
+
bytes: Buffer.byteLength(line) + 1
|
|
23380
|
+
});
|
|
23381
|
+
else torn = true;
|
|
22080
23382
|
}
|
|
22081
|
-
return
|
|
22082
|
-
|
|
22083
|
-
|
|
22084
|
-
|
|
22085
|
-
writeFileSync(tmp, this.entries.map((e) => `${JSON.stringify(e)}\n`).join(""));
|
|
22086
|
-
renameSync(tmp, this.file);
|
|
22087
|
-
this.writeState(this.state);
|
|
22088
|
-
}
|
|
22089
|
-
writeState(state) {
|
|
22090
|
-
const tmp = `${this.stateFile}.tmp`;
|
|
22091
|
-
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
22092
|
-
renameSync(tmp, this.stateFile);
|
|
23383
|
+
return {
|
|
23384
|
+
entries: entries.sort((a, b) => a.msg.seq - b.msg.seq),
|
|
23385
|
+
torn
|
|
23386
|
+
};
|
|
22093
23387
|
}
|
|
22094
23388
|
};
|
|
22095
23389
|
function safeJson(line) {
|
|
@@ -22584,9 +23878,24 @@ async function click(session, ref, opts = {}) {
|
|
|
22584
23878
|
const options = opts.button ? { button: opts.button } : {};
|
|
22585
23879
|
await withRef(session.page(opts.tabId), ref, (locator) => opts.doubleClick ? locator.dblclick(options) : locator.click(options));
|
|
22586
23880
|
}
|
|
22587
|
-
/**
|
|
23881
|
+
/**
|
|
23882
|
+
* Replace the value of an editable element; `submit` presses Enter afterwards. Without a ref,
|
|
23883
|
+
* type the text as key presses into whatever has focus (a canvas editor, a game, a field
|
|
23884
|
+
* clicked at coordinates).
|
|
23885
|
+
*/
|
|
22588
23886
|
async function type(session, ref, text, opts = {}) {
|
|
22589
|
-
|
|
23887
|
+
const page = session.page(opts.tabId);
|
|
23888
|
+
if (ref === void 0) {
|
|
23889
|
+
if (text.length > 2e3) throw new CommandError("invalid_text", `Without a ref, type at most ${MAX_FOCUS_TYPE_CHARS} characters`);
|
|
23890
|
+
try {
|
|
23891
|
+
await page.keyboard.type(text);
|
|
23892
|
+
if (opts.submit) await page.keyboard.press("Enter");
|
|
23893
|
+
} catch (error) {
|
|
23894
|
+
throw new CommandError("action_failed", `Typing at the focused element failed: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
|
|
23895
|
+
}
|
|
23896
|
+
return;
|
|
23897
|
+
}
|
|
23898
|
+
await withRef(page, ref, async (locator) => {
|
|
22590
23899
|
await locator.fill(text);
|
|
22591
23900
|
if (opts.submit) await locator.press("Enter");
|
|
22592
23901
|
});
|
|
@@ -22681,6 +23990,150 @@ async function wait(session, opts) {
|
|
|
22681
23990
|
throw new CommandError("action_failed", error instanceof Error ? error.message : String(error));
|
|
22682
23991
|
}
|
|
22683
23992
|
}
|
|
23993
|
+
// ../runner/src/browser/pointer.ts
|
|
23994
|
+
/** Drags move in about one step per this many CSS pixels, so drag thresholds see motion. */
|
|
23995
|
+
const DRAG_STEP_PX = 20;
|
|
23996
|
+
const MIN_DRAG_STEPS = 5;
|
|
23997
|
+
const MAX_DRAG_STEPS = 50;
|
|
23998
|
+
/** A scroll returns once no scroll event came for this long (smooth scrolling has ended). */
|
|
23999
|
+
const SCROLL_QUIET_MS = 100;
|
|
24000
|
+
/** ... or after this long in the page, whatever happens. */
|
|
24001
|
+
const SCROLL_SETTLE_MAX_MS = 1e3;
|
|
24002
|
+
/** The runner stops waiting for the page itself after this (a busy or hidden page). */
|
|
24003
|
+
const SCROLL_SETTLE_TIMEOUT_MS = 1500;
|
|
24004
|
+
/**
|
|
24005
|
+
* Resolves once the page has had no `scroll` event (on any scroller: captured on `window`) for
|
|
24006
|
+
* `SCROLL_QUIET_MS`, at most `SCROLL_SETTLE_MAX_MS` (a timer too: hidden tabs get no animation
|
|
24007
|
+
* frames). A string, so it runs in the page's realm.
|
|
24008
|
+
*/
|
|
24009
|
+
const SCROLL_SETTLED = `new Promise((resolve) => {
|
|
24010
|
+
const start = performance.now();
|
|
24011
|
+
let last = start;
|
|
24012
|
+
const onScroll = () => { last = performance.now(); };
|
|
24013
|
+
const done = () => {
|
|
24014
|
+
removeEventListener("scroll", onScroll, { capture: true });
|
|
24015
|
+
clearTimeout(timer);
|
|
24016
|
+
resolve(true);
|
|
24017
|
+
};
|
|
24018
|
+
const timer = setTimeout(done, ${SCROLL_SETTLE_MAX_MS});
|
|
24019
|
+
addEventListener("scroll", onScroll, { capture: true, passive: true });
|
|
24020
|
+
const tick = () => {
|
|
24021
|
+
const now = performance.now();
|
|
24022
|
+
if (now - last < ${SCROLL_QUIET_MS} && now - start < ${SCROLL_SETTLE_MAX_MS}) {
|
|
24023
|
+
requestAnimationFrame(tick);
|
|
24024
|
+
} else done();
|
|
24025
|
+
};
|
|
24026
|
+
requestAnimationFrame(tick);
|
|
24027
|
+
})`;
|
|
24028
|
+
/** Intermediate moves for a drag: about one per `DRAG_STEP_PX`, within 5 to 50. */
|
|
24029
|
+
function dragSteps(from, to) {
|
|
24030
|
+
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
24031
|
+
return Math.min(Math.max(Math.round(distance / DRAG_STEP_PX), MIN_DRAG_STEPS), MAX_DRAG_STEPS);
|
|
24032
|
+
}
|
|
24033
|
+
const fmt = (n) => String(Math.round(n * 100) / 100);
|
|
24034
|
+
/** Refuse a point outside the viewport: it would hit nothing, or the wrong thing. */
|
|
24035
|
+
function assertInViewport(point, viewport) {
|
|
24036
|
+
if (point.x >= 0 && point.y >= 0 && point.x < viewport.width && point.y < viewport.height) return;
|
|
24037
|
+
throw new CommandError("out_of_bounds", `Point (${fmt(point.x)}, ${fmt(point.y)}) is outside the viewport (${fmt(viewport.width)}x${fmt(viewport.height)} CSS pixels). Take a new browser_screenshot.`);
|
|
24038
|
+
}
|
|
24039
|
+
/** The points an action touches: its point, and a drag's end. */
|
|
24040
|
+
function pointsOf(command) {
|
|
24041
|
+
const points = [{
|
|
24042
|
+
x: command.x,
|
|
24043
|
+
y: command.y
|
|
24044
|
+
}];
|
|
24045
|
+
if (command.action === "drag") points.push({
|
|
24046
|
+
x: command.toX ?? NaN,
|
|
24047
|
+
y: command.toY ?? NaN
|
|
24048
|
+
});
|
|
24049
|
+
return points;
|
|
24050
|
+
}
|
|
24051
|
+
/**
|
|
24052
|
+
* Run `action` with `modifiers` held, releasing every key it tried to press, also on failure.
|
|
24053
|
+
* A key counts as held before its press resolves: Playwright marks it down before dispatching,
|
|
24054
|
+
* so a press that fails can still leave it held for the next action in the shared browser.
|
|
24055
|
+
*/
|
|
24056
|
+
async function withModifiers(page, modifiers, action) {
|
|
24057
|
+
const held = [];
|
|
24058
|
+
try {
|
|
24059
|
+
for (const key of new Set(modifiers)) {
|
|
24060
|
+
held.push(key);
|
|
24061
|
+
await page.keyboard.down(key);
|
|
24062
|
+
}
|
|
24063
|
+
await action();
|
|
24064
|
+
} finally {
|
|
24065
|
+
for (const key of held.reverse()) await page.keyboard.up(key).catch(() => void 0);
|
|
24066
|
+
}
|
|
24067
|
+
}
|
|
24068
|
+
/**
|
|
24069
|
+
* Run steps that press `button`, releasing it if any of them fails, the press included (like
|
|
24070
|
+
* keys, Playwright marks a button held before the press is dispatched). The error is kept.
|
|
24071
|
+
*/
|
|
24072
|
+
async function releasingOnFailure(page, button, steps) {
|
|
24073
|
+
try {
|
|
24074
|
+
await steps();
|
|
24075
|
+
} catch (error) {
|
|
24076
|
+
await page.mouse.up({ button }).catch(() => void 0);
|
|
24077
|
+
throw error;
|
|
24078
|
+
}
|
|
24079
|
+
}
|
|
24080
|
+
async function drag(page, from, to, button) {
|
|
24081
|
+
await page.mouse.move(from.x, from.y);
|
|
24082
|
+
await releasingOnFailure(page, button, async () => {
|
|
24083
|
+
await page.mouse.down({ button });
|
|
24084
|
+
await page.mouse.move(to.x, to.y, { steps: dragSteps(from, to) });
|
|
24085
|
+
});
|
|
24086
|
+
await page.mouse.up({ button });
|
|
24087
|
+
}
|
|
24088
|
+
/** Wait until a scroll has been rendered, bounded; a page that never answers is not an error. */
|
|
24089
|
+
async function scrollSettled(page) {
|
|
24090
|
+
let timer;
|
|
24091
|
+
const timeout = new Promise((resolve) => {
|
|
24092
|
+
timer = setTimeout(resolve, SCROLL_SETTLE_TIMEOUT_MS);
|
|
24093
|
+
});
|
|
24094
|
+
try {
|
|
24095
|
+
await Promise.race([page.evaluate(SCROLL_SETTLED).catch(() => void 0), timeout]);
|
|
24096
|
+
} finally {
|
|
24097
|
+
clearTimeout(timer);
|
|
24098
|
+
}
|
|
24099
|
+
}
|
|
24100
|
+
/** Run one pointer action on a page whose viewport is `viewport` (CSS pixels). */
|
|
24101
|
+
async function mouseAction(page, command, viewport) {
|
|
24102
|
+
const points = pointsOf(command);
|
|
24103
|
+
for (const point of points) assertInViewport(point, viewport);
|
|
24104
|
+
const [at, end] = points;
|
|
24105
|
+
try {
|
|
24106
|
+
await withModifiers(page, command.modifiers ?? [], async () => {
|
|
24107
|
+
switch (command.action) {
|
|
24108
|
+
case "click":
|
|
24109
|
+
await releasingOnFailure(page, command.button, () => page.mouse.click(at.x, at.y, {
|
|
24110
|
+
button: command.button,
|
|
24111
|
+
clickCount: command.clickCount ?? 1
|
|
24112
|
+
}));
|
|
24113
|
+
return;
|
|
24114
|
+
case "hover":
|
|
24115
|
+
await page.mouse.move(at.x, at.y);
|
|
24116
|
+
return;
|
|
24117
|
+
case "drag":
|
|
24118
|
+
await drag(page, at, end ?? at, command.button);
|
|
24119
|
+
return;
|
|
24120
|
+
case "scroll":
|
|
24121
|
+
await page.mouse.move(at.x, at.y);
|
|
24122
|
+
await page.mouse.wheel(command.deltaX ?? 0, command.deltaY ?? 0);
|
|
24123
|
+
return;
|
|
24124
|
+
}
|
|
24125
|
+
});
|
|
24126
|
+
} catch (error) {
|
|
24127
|
+
if (error instanceof CommandError) throw error;
|
|
24128
|
+
const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
24129
|
+
throw new CommandError("action_failed", `Mouse ${command.action} at (${fmt(at.x)}, ${fmt(at.y)}) failed: ${message}`);
|
|
24130
|
+
}
|
|
24131
|
+
if (command.action === "scroll") await scrollSettled(page);
|
|
24132
|
+
}
|
|
24133
|
+
/** `browser.mouse`: act at coordinates in a session's tab, checked against its current viewport. */
|
|
24134
|
+
async function mouse(session, command) {
|
|
24135
|
+
await mouseAction(session.page(command.tabId), command, await session.currentViewport(command.tabId));
|
|
24136
|
+
}
|
|
22684
24137
|
// ../runner/src/browser/handlers.ts
|
|
22685
24138
|
/**
|
|
22686
24139
|
* Handlers for the `browser.*` runner commands (§4.3, §12.2). Wire `execute` into the
|
|
@@ -22771,6 +24224,10 @@ function createBrowserCommandHandlers(deps) {
|
|
|
22771
24224
|
await press(manager.get(c.sessionId), c.key, c.tabId);
|
|
22772
24225
|
return {};
|
|
22773
24226
|
},
|
|
24227
|
+
"browser.mouse": async ({ name: _name, sessionId, ...command }) => {
|
|
24228
|
+
await mouse(manager.get(sessionId), command);
|
|
24229
|
+
return {};
|
|
24230
|
+
},
|
|
22774
24231
|
"browser.screenshot": (c) => screenshot(manager.get(c.sessionId), c.path, {
|
|
22775
24232
|
tabId: c.tabId,
|
|
22776
24233
|
fullPage: c.fullPage,
|
|
@@ -23634,21 +25091,34 @@ var BrowserSession = class {
|
|
|
23634
25091
|
await (await this.cdp(state)).send("Emulation.setDeviceMetricsOverride", deviceMetricsParams(this.viewport));
|
|
23635
25092
|
} catch {}
|
|
23636
25093
|
}
|
|
23637
|
-
/**
|
|
25094
|
+
/**
|
|
25095
|
+
* A view's own size (CSS pixels and scale); undefined while it has none. Throws when the page
|
|
25096
|
+
* doesn't answer in time.
|
|
25097
|
+
*/
|
|
25098
|
+
async viewSize(page) {
|
|
25099
|
+
const [width, height, scale] = await withTimeout(page.evaluate("[window.innerWidth, window.innerHeight, window.devicePixelRatio]"), NATIVE_MEASURE_TIMEOUT_MS);
|
|
25100
|
+
return width && height ? {
|
|
25101
|
+
width,
|
|
25102
|
+
height,
|
|
25103
|
+
deviceScaleFactor: scale || 1
|
|
25104
|
+
} : void 0;
|
|
25105
|
+
}
|
|
25106
|
+
/** The shown native view's size, just measured: the session's viewport follows it. */
|
|
25107
|
+
adopt(next) {
|
|
25108
|
+
const changed = !sameViewport(next, this.viewport);
|
|
25109
|
+
this.viewport = next;
|
|
25110
|
+
this.measuredAt = Date.now();
|
|
25111
|
+
if (changed) this.screencast.setFrameSize(nativeFrame(), next);
|
|
25112
|
+
}
|
|
25113
|
+
/**
|
|
25114
|
+
* The shown native view's actual size, as the desktop panel laid it out. Tolerant, for viewer
|
|
25115
|
+
* input and viewport answers: a view that doesn't answer keeps its last size.
|
|
25116
|
+
*/
|
|
23638
25117
|
async measure() {
|
|
23639
25118
|
try {
|
|
23640
|
-
const
|
|
23641
|
-
if (
|
|
23642
|
-
|
|
23643
|
-
width,
|
|
23644
|
-
height,
|
|
23645
|
-
deviceScaleFactor: scale || 1
|
|
23646
|
-
};
|
|
23647
|
-
const changed = !sameViewport(next, this.viewport);
|
|
23648
|
-
this.viewport = next;
|
|
23649
|
-
if (changed) this.screencast.setFrameSize(nativeFrame(), next);
|
|
23650
|
-
}
|
|
23651
|
-
this.measuredAt = Date.now();
|
|
25119
|
+
const next = await this.viewSize(this.page());
|
|
25120
|
+
if (next) this.adopt(next);
|
|
25121
|
+
else this.measuredAt = Date.now();
|
|
23652
25122
|
} catch {}
|
|
23653
25123
|
return { ...this.viewport };
|
|
23654
25124
|
}
|
|
@@ -23659,6 +25129,22 @@ var BrowserSession = class {
|
|
|
23659
25129
|
async syncViewport() {
|
|
23660
25130
|
if (this.native) await this.measure();
|
|
23661
25131
|
}
|
|
25132
|
+
/**
|
|
25133
|
+
* The viewport of `tabId` (default: the active tab) that an agent's coordinates refer to, now:
|
|
25134
|
+
* the emulated size every tab shares, or a native view's own size measured fresh (agent actions
|
|
25135
|
+
* are few, and the panel may have just resized). A native view in the background can differ
|
|
25136
|
+
* from the shown one, so only the shown view's size updates the session's viewport. Strict,
|
|
25137
|
+
* unlike `measure`: a view that can't be measured (busy in a script, navigating) fails with a
|
|
25138
|
+
* retryable `viewport_unavailable` instead of borrowing another size, so no action is sent.
|
|
25139
|
+
*/
|
|
25140
|
+
async currentViewport(tabId) {
|
|
25141
|
+
const state = this.tabState(tabId);
|
|
25142
|
+
if (!this.native) return { ...this.viewport };
|
|
25143
|
+
const size = await this.viewSize(state.page).catch(() => void 0);
|
|
25144
|
+
if (!size) throw new CommandError("viewport_unavailable", `Could not measure the size of tab ${state.tabId} (the page may be busy or loading), so nothing was done. Try again in a moment.`);
|
|
25145
|
+
if (state.tabId === this.activeTabId) this.adopt(size);
|
|
25146
|
+
return { ...size };
|
|
25147
|
+
}
|
|
23662
25148
|
/** The native view's size for input, measured at most once a second (pointer moves are many). */
|
|
23663
25149
|
async nativeViewport() {
|
|
23664
25150
|
if (Date.now() - this.measuredAt > NATIVE_MEASURE_MS) return this.measure();
|
|
@@ -24047,6 +25533,68 @@ function connectionHandlers(core, register, unregister, onAttach) {
|
|
|
24047
25533
|
}
|
|
24048
25534
|
};
|
|
24049
25535
|
}
|
|
25536
|
+
/**
|
|
25537
|
+
* Batches streamed `item.delta` events on their way to the journal. Every event the runner
|
|
25538
|
+
* journals is also a send, a server transaction and an ack, and providers stream an answer a
|
|
25539
|
+
* few characters at a time. Per session, consecutive deltas that extend the same item the
|
|
25540
|
+
* same way (`mergeItemDeltas`) are merged and passed on as one: after `windowMs`, once the
|
|
25541
|
+
* batch holds `maxChars`, or right before any other event of that session, whoever emits it
|
|
25542
|
+
* (the session's provider, a checkpoint command, workspace setup). An event that belongs to
|
|
25543
|
+
* no session (a terminal exiting, a drain) passes every batch on first. So no event overtakes
|
|
25544
|
+
* a delta that came before it, and only deltas ever wait.
|
|
25545
|
+
*/
|
|
25546
|
+
var DeltaBatcher = class {
|
|
25547
|
+
out;
|
|
25548
|
+
opts;
|
|
25549
|
+
batches = /* @__PURE__ */ new Map();
|
|
25550
|
+
constructor(out, opts = {}) {
|
|
25551
|
+
this.out = out;
|
|
25552
|
+
this.opts = opts;
|
|
25553
|
+
}
|
|
25554
|
+
push(event) {
|
|
25555
|
+
if (event.kind === "agent" && event.event.type === "item.delta") {
|
|
25556
|
+
this.add(event.sessionId, event.event);
|
|
25557
|
+
return;
|
|
25558
|
+
}
|
|
25559
|
+
this.flush("sessionId" in event ? event.sessionId : void 0);
|
|
25560
|
+
this.out(event);
|
|
25561
|
+
}
|
|
25562
|
+
/** Pass on the pending batch of one session, or of every session. */
|
|
25563
|
+
flush(sessionId) {
|
|
25564
|
+
if (sessionId === void 0) {
|
|
25565
|
+
for (const id of [...this.batches.keys()]) this.flush(id);
|
|
25566
|
+
return;
|
|
25567
|
+
}
|
|
25568
|
+
const batch = this.batches.get(sessionId);
|
|
25569
|
+
if (!batch) return;
|
|
25570
|
+
clearTimeout(batch.timer);
|
|
25571
|
+
this.batches.delete(sessionId);
|
|
25572
|
+
this.out({
|
|
25573
|
+
kind: "agent",
|
|
25574
|
+
sessionId,
|
|
25575
|
+
event: batch.delta
|
|
25576
|
+
});
|
|
25577
|
+
}
|
|
25578
|
+
add(sessionId, delta) {
|
|
25579
|
+
const chars = itemDeltaText(delta.delta).length;
|
|
25580
|
+
let batch = this.batches.get(sessionId);
|
|
25581
|
+
const merged = batch && mergeItemDeltas(batch.delta, delta);
|
|
25582
|
+
if (batch && merged) {
|
|
25583
|
+
batch.delta = merged;
|
|
25584
|
+
batch.chars += chars;
|
|
25585
|
+
} else {
|
|
25586
|
+
this.flush(sessionId);
|
|
25587
|
+
const windowMs = this.opts.windowMs ?? 50;
|
|
25588
|
+
batch = {
|
|
25589
|
+
delta,
|
|
25590
|
+
chars,
|
|
25591
|
+
timer: setTimeout(() => this.flush(sessionId), windowMs)
|
|
25592
|
+
};
|
|
25593
|
+
this.batches.set(sessionId, batch);
|
|
25594
|
+
}
|
|
25595
|
+
if (batch.chars >= (this.opts.maxChars ?? 16384)) this.flush(sessionId);
|
|
25596
|
+
}
|
|
25597
|
+
};
|
|
24050
25598
|
// ../runner/src/session-host.ts
|
|
24051
25599
|
/**
|
|
24052
25600
|
* Owns live provider sessions on this runner and pumps their normalized events into the
|
|
@@ -24137,7 +25685,12 @@ var SessionHost = class {
|
|
|
24137
25685
|
async send(cmd) {
|
|
24138
25686
|
const hosted = this.get(cmd.sessionId);
|
|
24139
25687
|
await hosted.checkpointChain;
|
|
24140
|
-
|
|
25688
|
+
try {
|
|
25689
|
+
await hosted.session.send(cmd.input);
|
|
25690
|
+
} catch (error) {
|
|
25691
|
+
if (error instanceof ProviderSessionError && error.code === "turn_running") throw new CommandError("turn_running", error.message);
|
|
25692
|
+
throw error;
|
|
25693
|
+
}
|
|
24141
25694
|
}
|
|
24142
25695
|
async steer(cmd) {
|
|
24143
25696
|
const { session } = this.get(cmd.sessionId);
|
|
@@ -24346,11 +25899,15 @@ async function startRunner(opts) {
|
|
|
24346
25899
|
let mcpUpstream = opts.mcpUpstreamUrl ? new HttpMcpUpstream(opts.mcpUpstreamUrl) : void 0;
|
|
24347
25900
|
let port = 0;
|
|
24348
25901
|
let stopped = false;
|
|
24349
|
-
const
|
|
25902
|
+
const journalEvent = (event) => {
|
|
24350
25903
|
if (stopped) return;
|
|
24351
25904
|
const msg = journal.append(event);
|
|
24352
25905
|
attached?.deliver(msg);
|
|
24353
25906
|
};
|
|
25907
|
+
const deltas = new DeltaBatcher(journalEvent, opts.deltaBatching);
|
|
25908
|
+
const emit = (event) => {
|
|
25909
|
+
if (!stopped) deltas.push(event);
|
|
25910
|
+
};
|
|
24354
25911
|
const stream = (data) => {
|
|
24355
25912
|
attached?.deliver({
|
|
24356
25913
|
type: "stream",
|
|
@@ -24481,7 +26038,13 @@ async function startRunner(opts) {
|
|
|
24481
26038
|
if ("closeAllConnections" in server) server.closeAllConnections();
|
|
24482
26039
|
});
|
|
24483
26040
|
process.off("exit", removeLock);
|
|
26041
|
+
deltas.flush();
|
|
24484
26042
|
stopped = true;
|
|
26043
|
+
try {
|
|
26044
|
+
journal.close();
|
|
26045
|
+
} catch (error) {
|
|
26046
|
+
log(`journal: ${String(error)}`);
|
|
26047
|
+
}
|
|
24485
26048
|
log("runner stopped");
|
|
24486
26049
|
})();
|
|
24487
26050
|
return closing;
|
|
@@ -24494,6 +26057,6 @@ async function startRunner(opts) {
|
|
|
24494
26057
|
close
|
|
24495
26058
|
};
|
|
24496
26059
|
}
|
|
24497
|
-
export {
|
|
26060
|
+
export { findExecutable as A, installClaudeSdk as C, acquireInstallLock as D, resolveNpm as E, ownNodeEnv as M, import_websocket_server as N, isSecretPath as O, wrapper_default as P, importClaudeSdkFrom as S, runNpm as T, claudeMemoryDir as _, bufferToFormData as a, claudeSdkSource as b, refreshProcessPath as c, ImageEngineRegistry as d, createImageEngineRegistry as f, ImageGenerationError as g, resolveImageOutput as h, tryDecodeURIComponent as i, isElectronRuntime as j, brokenInstallMessage as k, defaultShell as l, imageInfo as m, readLock as n, createAdaptorServer as o, IMAGE_ASPECT_RATIOS as p, Hono as r, upgradeWebSocket as s, startRunner as t, shellCommandLine as u, findGitRoot as v, isClaudeSdkInstalled as w, CLAUDE_SDK_VERSION as x, mainRepoRoot as y };
|
|
24498
26061
|
|
|
24499
|
-
//# sourceMappingURL=runner-
|
|
26062
|
+
//# sourceMappingURL=runner-CQYh087M.js.map
|