cowork-harness 2.2.0 → 2.4.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/.claude/skills/cowork-harness/SKILL.md +43 -17
- package/.claude/skills/cowork-harness/references/ci-recipe.md +15 -13
- package/.claude/skills/cowork-harness/references/critique.md +1 -1
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
- package/.claude/skills/cowork-harness/references/scenario-schema.md +14 -12
- package/.claude/skills/cowork-harness/references/task-recipes.md +2 -2
- package/.claude/skills/cowork-harness/scripts/assertion-keys.json +1 -0
- package/.claude/skills/cowork-harness/scripts/scenario.py +32 -1
- package/CHANGELOG.md +366 -0
- package/DESIGN.md +3 -3
- package/README.md +41 -11
- package/baselines/desktop-1.37937.1.json +816 -0
- package/baselines/prompts/cowork-system-prompt-fingerprints.json +27 -0
- package/dist/assert.js +105 -3
- package/dist/cli.js +15 -4
- package/dist/hostloop/workspace-handler.js +12 -2
- package/dist/run/cassette.js +304 -78
- package/dist/run/execute.js +135 -8
- package/dist/run/trace-view.js +39 -1
- package/dist/runtime/container.js +53 -2
- package/dist/runtime/hostloop.js +52 -8
- package/dist/sync/cowork-sync.js +233 -16
- package/dist/types.js +40 -10
- package/docs/README.md +3 -0
- package/docs/cassette.md +36 -11
- package/docs/debugging.md +25 -0
- package/docs/fidelity-gaps.md +133 -6
- package/docs/maintenance.md +15 -1
- package/docs/scenario.md +66 -17
- package/examples/replays/README.md +1 -1
- package/examples/replays/example-multiselect-gate.cassette.json +41 -46
- package/examples/replays/example-pdf-skill.cassette.json +164 -115
- package/examples/replays/hostloop-computer-links.cassette.json +62 -68
- package/examples/scenarios/example-pdf-skill.yaml +8 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/python/test_scenario_lint.py +39 -0
- package/schema/scenario.schema.json +29 -10
|
@@ -335,6 +335,33 @@
|
|
|
335
335
|
"working_with_user_files"
|
|
336
336
|
],
|
|
337
337
|
"note": "Cowork system prompt BYTE-IDENTICAL to 1.32352.0 on both the raw and the decoded hash (38505 raw / 37811 decoded code points, 43 tags, same 10 placeholders); only the minifier-assigned constant id rotated umt -> Zlt. Raw retained locally per localRawRetention."
|
|
338
|
+
},
|
|
339
|
+
"1.34493.1": {
|
|
340
|
+
"sha256": "e62bd9fa1eefc153ddcbd7e56cc6406a2950965e691b96b8d79dd0dbd7032f07",
|
|
341
|
+
"codePoints": 38505,
|
|
342
|
+
"decodedSha256": "a14592804dfc6728e855d3be17eeb40cef654aeb0bc6457487f5963dbedc7fdf",
|
|
343
|
+
"decodedCodePoints": 37811,
|
|
344
|
+
"sectionTags": 43,
|
|
345
|
+
"constantId": "tpt",
|
|
346
|
+
"note": "BACK-FILLED 2026-08-26 from the retained asar backup — the entry was owed at the 1.34493.1 pass and was never written, which is why the raw was also missing from ~/cowork-agent-backup/desktop-asar/1.34493.1/ until now. Cowork system prompt BYTE-IDENTICAL to 1.32885.1 on both the raw and the decoded hash (38505 raw / 37811 decoded code points, 43 tags, same 10 placeholders); only the minifier-assigned constant id rotated Zlt -> tpt."
|
|
347
|
+
},
|
|
348
|
+
"1.37937.0": {
|
|
349
|
+
"sha256": "e62bd9fa1eefc153ddcbd7e56cc6406a2950965e691b96b8d79dd0dbd7032f07",
|
|
350
|
+
"codePoints": 38505,
|
|
351
|
+
"decodedSha256": "a14592804dfc6728e855d3be17eeb40cef654aeb0bc6457487f5963dbedc7fdf",
|
|
352
|
+
"decodedCodePoints": 37811,
|
|
353
|
+
"sectionTags": 43,
|
|
354
|
+
"constantId": "Qgt",
|
|
355
|
+
"note": "Cowork system prompt BYTE-IDENTICAL to 1.34493.1; only the constant id rotated tpt -> Qgt. Corroborated by a SECOND source rather than by re-running the same extractor: the retained raw files for 1.32885.1, 1.34493.1, 1.37937.0 and 1.37937.1 are byte-identical to each other on disk (cmp), so the prompt has not moved since 1.20186.0."
|
|
356
|
+
},
|
|
357
|
+
"1.37937.1": {
|
|
358
|
+
"sha256": "e62bd9fa1eefc153ddcbd7e56cc6406a2950965e691b96b8d79dd0dbd7032f07",
|
|
359
|
+
"codePoints": 38505,
|
|
360
|
+
"decodedSha256": "a14592804dfc6728e855d3be17eeb40cef654aeb0bc6457487f5963dbedc7fdf",
|
|
361
|
+
"decodedCodePoints": 37811,
|
|
362
|
+
"sectionTags": 43,
|
|
363
|
+
"constantId": "Qgt",
|
|
364
|
+
"note": "Desktop self-updated 1.37937.0 -> 1.37937.1 mid-pass. Prompt BYTE-IDENTICAL to 1.37937.0, same constant id Qgt. Recorded separately because provenance.asarFingerprint is the SAME 16-char value on both builds (it hashes only the cowork slices), so the baseline's own fingerprint cannot distinguish them and this file is where that distinction is kept."
|
|
338
365
|
}
|
|
339
366
|
},
|
|
340
367
|
"subagentAppendMethod": "Independent of the single-text `versions` fingerprints above: the sub-agent environment append is a TWO-BRANCH generator selected on hostLoopMode. In the SINGLE bundle module that references buildSubagentEnvironmentPrompt AND contains both branch discriminators, slice each branch's raw backtick-template body by scanning outward from its discriminator fragment (escaped backticks are decoded to bare backticks; other escapes left intact); DECODE escapes (same decoder as decodedSha256 -- 1.32352.0's codegen escape change moved BOTH branch fingerprints while the rendered branch text was byte-identical), then normalize every ${...} interpolation to the literal token ${} ; sha256 the UTF-8 bytes; keep the first 16 hex chars. The hl (host-loop) branch is discriminated by the fragment 'on the user's machine' (anchored to the occurrence immediately preceding the vm branch, since the phrase also occurs in unrelated prose), the vm (sandbox) branch by 'exist only in the sandbox'. BOTH fingerprints are mandatory per version entry. Consumed by checkSubagentPromptFacts in src/sync/cowork-sync.ts; re-derive with readMainBundleFiles + extractSubagentBranchSlices + subagentBranchFingerprint (see docs/maintenance.md). Raw texts retained locally only (see localRawRetention).",
|
package/dist/assert.js
CHANGED
|
@@ -48,6 +48,30 @@ function resolveContainedManifestPath(workRoot, p) {
|
|
|
48
48
|
* scenario asserting any of these CANNOT take. A hand-copied list there would rot exactly the way the
|
|
49
49
|
* `--out`-outside-the-repo advice it replaces did. `test/hostloop-only-keys.test.ts` pins this array
|
|
50
50
|
* against the `hostloopOnly("…")` call sites by scanning this file's source. */
|
|
51
|
+
/** The founder-visible strings of one gate, as SEPARATE fields: its question label, then every option's
|
|
52
|
+
* label and description. The evidence behind `question_context`.
|
|
53
|
+
*
|
|
54
|
+
* Sourced from RunRecord.gateOptions, i.e. the ASK-TIME AskUserQuestion payload the model emitted — never
|
|
55
|
+
* from a tool_result. That distinction is the key's whole value: a skill's producer script typically also
|
|
56
|
+
* writes the same sentence into its own gate-state file, so a `tool_result_matches` on that phrase grades
|
|
57
|
+
* true whether or not the model ever surfaced it. This function can only ever see what was actually
|
|
58
|
+
* offered.
|
|
59
|
+
*
|
|
60
|
+
* RETURNS A LIST, and the evaluator tests each entry separately, so a pattern can never match text spanning
|
|
61
|
+
* two fields. An earlier version returned one newline-joined string and claimed the newline prevented that.
|
|
62
|
+
* It did not, and it was measured doing the opposite: on the committed multiselect example,
|
|
63
|
+
* `invoicing[\s\S]*Audit logging` stitched one option's DESCRIPTION to the next option's LABEL and passed —
|
|
64
|
+
* a "sentence" no one was ever shown. The neighbouring transcript keys' docs actively teach `[\s\S]` for
|
|
65
|
+
* spanning turns, so that is the habit an author brings to this key. Structure beats a documented caveat. */
|
|
66
|
+
export function gateVisibleFields(g) {
|
|
67
|
+
const parts = [g.question];
|
|
68
|
+
for (const o of g.options) {
|
|
69
|
+
parts.push(o.label);
|
|
70
|
+
if (o.description !== undefined)
|
|
71
|
+
parts.push(o.description);
|
|
72
|
+
}
|
|
73
|
+
return parts;
|
|
74
|
+
}
|
|
51
75
|
/** Order-insensitive multiset equality over option labels — `question_options` with `order: "any"`.
|
|
52
76
|
* A multiset, not a Set: a gate that offered the same label twice is a different offer from one that
|
|
53
77
|
* offered it once, and collapsing them would green a duplicated option. */
|
|
@@ -349,7 +373,7 @@ function capForJudge(text, cap) {
|
|
|
349
373
|
return text;
|
|
350
374
|
return `${text.slice(0, cap)}\n…[${text.length - cap} chars truncated for the judge input budget — evidence beyond this point was NOT shown; do not infer absence from this cut]`;
|
|
351
375
|
}
|
|
352
|
-
function buildJudgedDocument(ctx, includeSubagentText = false) {
|
|
376
|
+
export function buildJudgedDocument(ctx, includeSubagentText = false) {
|
|
353
377
|
// SCRUB BEFORE CAP: scrub is exact-string replacement, so a secret straddling a cap boundary would
|
|
354
378
|
// be truncated mid-token and slip past scrub into the doc sent to the (external) judge. Scrub each raw
|
|
355
379
|
// section FIRST, then cap the already-redacted text — capping redacted content can never re-expose a secret.
|
|
@@ -381,8 +405,33 @@ function buildJudgedDocument(ctx, includeSubagentText = false) {
|
|
|
381
405
|
parts.push(`## Sub-agent output: ${s(label)}\n${capForJudge(s(text), JUDGE_SUBAGENT_CAP)}`);
|
|
382
406
|
}
|
|
383
407
|
}
|
|
384
|
-
|
|
385
|
-
|
|
408
|
+
// AUTHORED is not DELIVERED. The capture deliberately includes the scratchpad (the run DID write those
|
|
409
|
+
// files, and production's sandbox would too), but production DISCARDS anything outside `mnt/` — its own
|
|
410
|
+
// sub-agent prompt: "never reaches the user or your file tools". Unlabelled, a rubric like "the report
|
|
411
|
+
// was written" grades TRUE on a file the user never receives — the harness's own false-green, inside the
|
|
412
|
+
// one evaluator that reads free-form prose and cannot infer the convention.
|
|
413
|
+
//
|
|
414
|
+
// The distinction is already carried in the path: the scratchpad walk emits a synthetic `scratchpad/`
|
|
415
|
+
// prefix (run/artifacts.ts). This only makes it legible to the judge, rather than restructuring the set.
|
|
416
|
+
// `scratchpad` is NOT in RESERVED_MOUNT_NAMES, so a user may connect a folder with that exact name.
|
|
417
|
+
// Its files then arrive workRoot-relative as `scratchpad/…` — indistinguishable by prefix from the
|
|
418
|
+
// synthetic walk. Labelling those would hand the judge a FALSE statement ("NOT delivered" about a file
|
|
419
|
+
// the user does receive) and false-RED a "was the report delivered?" rubric. A wrong claim is worse
|
|
420
|
+
// than a missing one, so the collision disables the label rather than guessing.
|
|
421
|
+
// `?? []` because a partial AssertContext (tests, and any caller building one by hand) may omit this;
|
|
422
|
+
// an absent prefix list means "nothing is user-visible", which keeps the label ON — the safe direction.
|
|
423
|
+
const scratchIsUserVisible = (ctx.userVisiblePrefixes ?? []).some((p) => p === "scratchpad" || p === SCRATCHPAD_PREFIX);
|
|
424
|
+
let sawScratch = false;
|
|
425
|
+
for (const f of ctx.authoredFiles ?? []) {
|
|
426
|
+
const scratch = !scratchIsUserVisible && f.path.startsWith(SCRATCHPAD_PREFIX);
|
|
427
|
+
sawScratch ||= scratch;
|
|
428
|
+
const tag = scratch ? " — SCRATCH, NOT delivered to the user" : "";
|
|
429
|
+
parts.push(`## Authored file: ${s(f.path)}${tag}${f.truncated ? " (truncated)" : ""}\n${s(f.content)}`);
|
|
430
|
+
}
|
|
431
|
+
if (sawScratch)
|
|
432
|
+
parts.push("## Note on scratch files\nFiles marked SCRATCH were written to the session's scratch area, which is " +
|
|
433
|
+
"discarded and never reaches the user. Treat them as working intermediates: they are evidence of what " +
|
|
434
|
+
"the run DID, and are NOT evidence that anything was delivered, saved, shared, or produced for the user.");
|
|
386
435
|
// Surface authored-file incompleteness to the judge so it never reads an omitted/unreadable file's
|
|
387
436
|
// ABSENCE as evidence the skill didn't produce it (#14/#16). The verdict is separately forced to
|
|
388
437
|
// evidence-unavailable in the semantic_matches check; this note keeps a still-produced grade honest.
|
|
@@ -1516,6 +1565,59 @@ function check(a, ctx) {
|
|
|
1516
1565
|
results.push(ctx.questions.some((q) => c.re.test(q)) ? ok() : fail(`no question matched: ${a.question_asked}`));
|
|
1517
1566
|
}
|
|
1518
1567
|
}
|
|
1568
|
+
if (a.question_context !== undefined) {
|
|
1569
|
+
const qc = a.question_context;
|
|
1570
|
+
// Fail CLOSED on every "we cannot see the gates" path, exactly as question_options does: an absent or
|
|
1571
|
+
// partial gate payload must never satisfy a key whose whole job is to prove what a person was shown.
|
|
1572
|
+
if (ctx.gateOptionsMissing || ctx.gateOptions === undefined) {
|
|
1573
|
+
results.push(fail("evidence unavailable: gate-option evidence absent for this run — cannot evaluate question_context"));
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
const c = compileUserRegex(qc.matches);
|
|
1577
|
+
if ("error" in c) {
|
|
1578
|
+
results.push(fail(`question_context: bad regex "${qc.matches}": ${c.error}`));
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
const all = ctx.gateOptions;
|
|
1582
|
+
let pool = all;
|
|
1583
|
+
let selector = "";
|
|
1584
|
+
let badSelector = false;
|
|
1585
|
+
if (qc.when_question !== undefined) {
|
|
1586
|
+
const w = compileUserRegex(qc.when_question);
|
|
1587
|
+
if ("error" in w) {
|
|
1588
|
+
results.push(fail(`question_context: bad regex "${qc.when_question}": ${w.error}`));
|
|
1589
|
+
badSelector = true;
|
|
1590
|
+
}
|
|
1591
|
+
else {
|
|
1592
|
+
pool = all.filter((g) => w.re.test(g.question));
|
|
1593
|
+
selector = ` matching /${qc.when_question}/i`;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
// NOTE: deliberately NO ambiguity refusal here, unlike question_options. That key pins WHICH gate
|
|
1597
|
+
// offered WHICH set, so silently taking the first would make it depend on gate order. This key asks
|
|
1598
|
+
// whether the founder was shown a phrase at a decision point at all, for which "some selected gate's
|
|
1599
|
+
// payload matches" is the whole semantic — there is nothing to disambiguate. Mirroring the refusal
|
|
1600
|
+
// would red a multi-gate run on which the founder WAS told (measured: the deck-review fixture fires
|
|
1601
|
+
// 5 sub-questions across 2 gates).
|
|
1602
|
+
if (badSelector) {
|
|
1603
|
+
/* already reported */
|
|
1604
|
+
}
|
|
1605
|
+
else if (pool.length === 0) {
|
|
1606
|
+
results.push(fail(`question_context: no question${selector} was asked (${all.length} gate(s) recorded)`));
|
|
1607
|
+
}
|
|
1608
|
+
else {
|
|
1609
|
+
// The founder-visible payload, in the order it is presented: question label, then each option's
|
|
1610
|
+
// label and description. Joined with newlines so a regex cannot straddle two fields by accident.
|
|
1611
|
+
const hit = pool.find((g) => gateVisibleFields(g).some((f) => c.re.test(f)));
|
|
1612
|
+
results.push(hit
|
|
1613
|
+
? ok(`question_context: gate ${JSON.stringify(hit.question)} showed text matching /${qc.matches}/i`)
|
|
1614
|
+
: fail(`question_context: no gate${selector} showed text matching /${qc.matches}/i (searched ${pool.length} gate(s): ${pool
|
|
1615
|
+
.map((g) => JSON.stringify(g.question))
|
|
1616
|
+
.join(", ")})`));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1519
1621
|
if (a.question_options !== undefined) {
|
|
1520
1622
|
const qo = a.question_options;
|
|
1521
1623
|
// Fail CLOSED on every "we cannot see the gates" path. An empty/absent list must never satisfy a
|
package/dist/cli.js
CHANGED
|
@@ -436,7 +436,7 @@ const SUBCOMMAND_USAGE = {
|
|
|
436
436
|
// flags any physical line naming an artifact next to something that looks like a path-join call).
|
|
437
437
|
trace: `usage: trace <run-id | run-dir | events.jsonl> [--view ${TRACE_VIEWS.join("|")}] [--translate-paths] [--full-results] [--output-format json]
|
|
438
438
|
--view tools tool call / result rows
|
|
439
|
-
--view questions gate lifecycle (question → answer → delivered)
|
|
439
|
+
--view questions gate lifecycle (question → offered options, incl. each option's description → answer → delivered)
|
|
440
440
|
--view dispatches sub-agent dispatch tree + dispatch_count_max
|
|
441
441
|
--view tool-durations per-tool call-count/timing table, folded from the sibling timeline.jsonl ({} when the run has no timing data)
|
|
442
442
|
--view tool-errors one row per errored tool call, with the full command + full multi-line stderr (each capped at 4KB); the tools view shows only the first 120 chars
|
|
@@ -3660,7 +3660,7 @@ async function cmdVerifyRun(args) {
|
|
|
3660
3660
|
// asserting option order with no scripted answers (`on_unanswered: first`, an LLM-decided gate, a
|
|
3661
3661
|
// post-hoc check on a kept run) would silently reach the evaluator with no evidence at all. Parsed only
|
|
3662
3662
|
// when the key is asserted — a full events.jsonl read is not free on a long run.
|
|
3663
|
-
const wantsGateOptions = scenario.assert.some((a) => a.question_options !== undefined);
|
|
3663
|
+
const wantsGateOptions = scenario.assert.some((a) => a.question_options !== undefined || a.question_context !== undefined);
|
|
3664
3664
|
const parsedGates = wantsGateOptions ? parseGatesFromEvents(join(runDir, "events.jsonl")) : undefined;
|
|
3665
3665
|
// Absent file OR any unparseable frame ⇒ evidence-missing, never a partial set graded as complete:
|
|
3666
3666
|
// a present-but-corrupt events.jsonl is otherwise indistinguishable from "these were all the gates".
|
|
@@ -4384,8 +4384,19 @@ function cmdDiff(args) {
|
|
|
4384
4384
|
if (!["tools", "transcript", "artifacts", "meta", "all"].includes(view))
|
|
4385
4385
|
return void fail("diff", "usage", `--view must be one of tools|transcript|artifacts|meta|all (got "${view}")`, undefined, json);
|
|
4386
4386
|
const allPositionals = positionals(args, ["--output-format", "--view"]);
|
|
4387
|
-
if (allPositionals.length !== 2)
|
|
4388
|
-
|
|
4387
|
+
if (allPositionals.length !== 2) {
|
|
4388
|
+
// A single positional is the common typo AND a reasonable intent ("compare this cassette to the
|
|
4389
|
+
// committed one"). That intent is not implementable as a default: `diff` is polymorphic over
|
|
4390
|
+
// baselines, run dirs and cassettes, so a one-arg form needs type dispatch plus a defined source for
|
|
4391
|
+
// "the committed version" (git HEAD, tracked path, inside a repo). Until that exists, say what the
|
|
4392
|
+
// missing operand is instead of printing bare usage at someone who gave us most of what we needed.
|
|
4393
|
+
const hint = allPositionals.length === 1
|
|
4394
|
+
? `diff needs TWO things to compare; got one (${allPositionals[0]}). Pass the other side explicitly — ` +
|
|
4395
|
+
`e.g. the previously committed cassette (\`git show HEAD:<path> > /tmp/before.cassette.json\`), the ` +
|
|
4396
|
+
`other run dir, or the other baseline. There is no one-argument form.\n\n`
|
|
4397
|
+
: "";
|
|
4398
|
+
return void fail("diff", "usage", hint + SUBCOMMAND_USAGE.diff, undefined, json);
|
|
4399
|
+
}
|
|
4389
4400
|
const [aName, bName] = allPositionals;
|
|
4390
4401
|
const aKind = detectDiffKind(aName);
|
|
4391
4402
|
const bKind = detectDiffKind(bName);
|
|
@@ -228,10 +228,12 @@ const defaultRawFetch = async (url, pinnedAddresses) => {
|
|
|
228
228
|
const BASH_DESC = "Run a shell command in the session's isolated Linux workspace. Your connected folders are mounted under {{mnt}}/ — the Shell access section of your system prompt lists the exact path for each folder. Each bash call is independent (no cwd/env carryover). Use absolute paths.";
|
|
229
229
|
const FETCH_DESC = "Fetch a URL from the session network (subject to the egress allowlist). web_fetch can only retrieve URLs that appeared in a user message or a prior result.";
|
|
230
230
|
export function makeWorkspaceHandler(opts) {
|
|
231
|
-
const { containerName, vmMnt, runner = "docker", webFetchAllow = [
|
|
231
|
+
const { containerName, vmMnt, runner = "docker", webFetchAllow = [], // DENY-ALL when unset — see the option doc; an open default was a real hole
|
|
232
|
+
onEgress, onInfraError, provenanceRef, dedup, rawFetch = defaultRawFetch, resolve = defaultResolver, execCwd = vmMnt, } = opts;
|
|
232
233
|
// Per-handler (per-spawn) latch for the provenance-unenforced warning — was module-level, which
|
|
233
234
|
// silenced the gap after the first run in a long-lived process. Each fresh handler warns once.
|
|
234
235
|
const provWarned = { value: false };
|
|
236
|
+
const exposed = opts.tools ?? ["bash", "web_fetch"];
|
|
235
237
|
const tools = [
|
|
236
238
|
{
|
|
237
239
|
name: "bash",
|
|
@@ -243,7 +245,7 @@ export function makeWorkspaceHandler(opts) {
|
|
|
243
245
|
description: FETCH_DESC,
|
|
244
246
|
inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
|
|
245
247
|
},
|
|
246
|
-
];
|
|
248
|
+
].filter((td) => exposed.includes(td.name));
|
|
247
249
|
return async (_server, jr) => {
|
|
248
250
|
const method = jr.method;
|
|
249
251
|
if (method === "initialize")
|
|
@@ -259,6 +261,12 @@ export function makeWorkspaceHandler(opts) {
|
|
|
259
261
|
if (method === "tools/call") {
|
|
260
262
|
const name = jr.params?.name;
|
|
261
263
|
const a = jr.params?.arguments ?? {};
|
|
264
|
+
// Gate the DISPATCH on the same set as the advertisement, not just the tools/list response. An
|
|
265
|
+
// unadvertised tool that still executes when named is a real hole: the VM-loop registration exposes
|
|
266
|
+
// web_fetch ONLY, and a `bash` call arriving there must be refused, not quietly exec'd into the
|
|
267
|
+
// container.
|
|
268
|
+
if (!exposed.includes(name))
|
|
269
|
+
return { result: textResult(`error: unknown tool "${String(name)}"`, true) };
|
|
262
270
|
if (name === "bash")
|
|
263
271
|
return {
|
|
264
272
|
result: await execInContainer(runner, containerName, execCwd, String(a.command ?? ""), clampTimeout(a.timeout_ms), onInfraError),
|
|
@@ -267,6 +275,8 @@ export function makeWorkspaceHandler(opts) {
|
|
|
267
275
|
return {
|
|
268
276
|
result: await fetchViaHost(String(a.url ?? ""), webFetchAllow, onEgress, provenanceRef?.current, provWarned, rawFetch, resolve, dedup),
|
|
269
277
|
};
|
|
278
|
+
// Unreachable: the dispatch gate above rejects every name outside `exposed`, and `exposed` is a
|
|
279
|
+
// subset of the two handled here. Kept as a total-function backstop if that gate is ever loosened.
|
|
270
280
|
return { error: { code: -32602, message: `unknown tool: ${name}` } };
|
|
271
281
|
}
|
|
272
282
|
return { result: {} }; // ping / notifications
|