@sema-agent/core 7.39.1 → 7.40.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/core/a2a.js +1 -0
  3. package/dist/core/ask-origin.d.ts +19 -6
  4. package/dist/core/ask-origin.js +6 -0
  5. package/dist/core/gate-fold.js +5 -0
  6. package/dist/core/gate-lanes.js +8 -4
  7. package/dist/core/hands-band.d.ts +0 -12
  8. package/dist/core/hooks.d.ts +3 -0
  9. package/dist/core/hooks.js +4 -1
  10. package/dist/core/mcp.js +1 -1
  11. package/dist/core/mode-policy.d.ts +17 -2
  12. package/dist/core/mode-policy.js +13 -6
  13. package/dist/core/permission-rule-model.d.ts +29 -0
  14. package/dist/core/permission-rule-model.js +15 -2
  15. package/dist/core/permission-rules.d.ts +2 -10
  16. package/dist/core/permission-rules.js +22 -19
  17. package/dist/core/runner/branch-writeback.d.ts +38 -0
  18. package/dist/core/runner/branch-writeback.js +58 -0
  19. package/dist/core/runner/denial-limit-arms.js +1 -1
  20. package/dist/core/runner/gate-exit.d.ts +5 -1
  21. package/dist/core/runner/permission-rule-lanes.js +7 -5
  22. package/dist/core/runner/persisted-path-rows.js +3 -3
  23. package/dist/core/runner/prepare-acquire-reconcile.d.ts +9 -2
  24. package/dist/core/runner/prepare-acquire-reconcile.js +16 -30
  25. package/dist/core/runner/prepare-file-history.d.ts +2 -2
  26. package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
  27. package/dist/core/runner/prepare-gate-stations.js +2 -0
  28. package/dist/core/runner/prepare-hands-readface.d.ts +1 -1
  29. package/dist/core/runner/prepare-hands-readface.js +0 -1
  30. package/dist/core/runner/prepare-policy-chain.js +11 -7
  31. package/dist/core/runner/prepare-task.js +12 -9
  32. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  33. package/dist/core/runner/prepare-wiring-manifest.js +10 -5
  34. package/dist/core/runner/retired-runner-deps.js +1 -0
  35. package/dist/core/tool-policy.d.ts +14 -0
  36. package/dist/core/tool-policy.js +15 -6
  37. package/dist/core/tool-spec.d.ts +10 -0
  38. package/dist/core/tools.js +1 -0
  39. package/dist/engine/loop/types.d.ts +6 -0
  40. package/dist/orchestration/workflow.js +1 -1
  41. package/dist/stores/file/index.d.ts +4 -3
  42. package/dist/stores/file/session-policy-store.d.ts +19 -11
  43. package/dist/stores/file/session-policy-store.js +13 -16
  44. package/dist/stores/file/usage-window-store.d.ts +5 -5
  45. package/dist/tools/fs/fs-read.d.ts +1 -1
  46. package/dist/tools/fs/fs-read.js +3 -5
  47. package/dist/tools/fs/fs-shared.d.ts +0 -27
  48. package/dist/tools/fs/fs-shared.js +0 -3
  49. package/dist/tools/fs/index.d.ts +1 -9
  50. package/dist/tools/fs/index.js +1 -1
  51. package/package.json +1 -1
  52. package/wire-changes/core@7.33.6.json +55 -6
  53. package/wire-changes/core@7.39.2.json +102 -0
  54. package/wire-changes/core@7.40.0.json +376 -0
@@ -1,7 +1,7 @@
1
1
  import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
3
  import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
4
- import { PATH_RULE_BASE_LABEL, READ_RULE_TOOL, isUsablePathBase, parseRuleText, pathRuleHeadOf, pathRuleReachOf, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js";
4
+ import { PATH_RULE_BASE_LABEL, PATH_RULE_FACES, callPathFaceOf, isUsablePathBase, parseRuleText, pathRuleHeadOf, pathRuleReachOf, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js";
5
5
  import { pathFamilyOf, pathIdentityOf, spellingOfIdentity } from "../tools/fs/safety.js";
6
6
  import { effectivePathTargetOf } from "./effective-path-target.js";
7
7
  import { protectivePathTargetOf } from "./tool-registry.js";
@@ -82,7 +82,6 @@ export const fileToolParamVocabulary = (canonicalTool) => {
82
82
  const face = catalogRuleFaceOf(canonicalTool);
83
83
  return face?.params === undefined || pathTargetOf(canonicalTool) === undefined ? undefined : new Set(face.params);
84
84
  };
85
- export const PATH_DENY_RULE_FACES = Object.freeze({ [READ_RULE_TOOL]: ["read", "write"], Edit: ["write"] });
86
85
  export function pathRuleDenyRowOf(rule, bases) {
87
86
  const family = pathFamilyOf(bases);
88
87
  const tree = bases.root ?? bases.cwd;
@@ -99,10 +98,6 @@ export function pathRuleDenyRowOf(rule, bases) {
99
98
  ...(head !== undefined ? { head: spellingOfIdentity(head, family) } : {}),
100
99
  };
101
100
  }
102
- function callPathFaceOf(req) {
103
- const target = protectivePathTargetOf(req);
104
- return target === undefined ? undefined : target.access === "read" ? "read" : "write";
105
- }
106
101
  function countStars(s) {
107
102
  let n = 0;
108
103
  for (const c of s)
@@ -218,7 +213,7 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
218
213
  }
219
214
  if (issues.length > 0 && issues[issues.length - 1].rule === text && issues[issues.length - 1].code === "unsupported.path_base")
220
215
  continue;
221
- const faces = r.behavior === "deny" ? PATH_DENY_RULE_FACES[toolName] : undefined;
216
+ const faces = PATH_RULE_FACES[toolName]?.[r.behavior];
222
217
  entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}), ...(faces !== undefined ? { faces } : {}) });
223
218
  byTool.set(toolName, entry);
224
219
  continue;
@@ -340,8 +335,15 @@ export function createPermissionRulePolicy(rules, opts) {
340
335
  }
341
336
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
342
337
  const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
343
- const faceDenyRules = [...byTool.values()].flatMap((e) => e.content.deny.filter((r) => r.faces !== undefined));
344
- const pathDenyRules = faceDenyRules.map((r) => pathRuleDenyRowOf({ parsed: r.parsed, ruleText: r.ruleText, faces: r.faces }, pathBases));
338
+ const faceRules = {
339
+ deny: [...byTool.values()].flatMap((e) => e.content.deny.filter((r) => r.faces !== undefined)),
340
+ ask: [...byTool.values()].flatMap((e) => e.content.ask.filter((r) => r.faces !== undefined)),
341
+ };
342
+ const pathDenyRules = faceRules.deny.map((r) => pathRuleDenyRowOf({ parsed: r.parsed, ruleText: r.ruleText, faces: r.faces }, pathBases));
343
+ const contentLaneOf = (behavior, entry, callFace) => [
344
+ ...(entry?.content[behavior].filter((r) => r.faces === undefined) ?? []),
345
+ ...(callFace !== undefined ? faceRules[behavior].filter((r) => r.faces.includes(callFace)) : []),
346
+ ];
345
347
  const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
346
348
  const contentHit = (lane, req) => {
347
349
  if (lane.length === 0)
@@ -410,8 +412,8 @@ export function createPermissionRulePolicy(rules, opts) {
410
412
  if (coveringDeny)
411
413
  return { action: "deny", message: ruleDenial(req, coveringDeny.ruleText, coveringDeny.source) };
412
414
  }
413
- const callFace = faceDenyRules.length > 0 ? callPathFaceOf(req) : undefined;
414
- const denyLane = [...(entry?.content.deny.filter((r) => r.faces === undefined) ?? []), ...(callFace !== undefined ? faceDenyRules.filter((r) => r.faces.includes(callFace)) : [])];
415
+ const callFace = faceRules.deny.length > 0 || faceRules.ask.length > 0 ? callPathFaceOf(req) : undefined;
416
+ const denyLane = contentLaneOf("deny", entry, callFace);
415
417
  const contentDeny = denyLane.length > 0 ? contentHit(denyLane, req) : undefined;
416
418
  if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
417
419
  return { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) };
@@ -434,23 +436,24 @@ export function createPermissionRulePolicy(rules, opts) {
434
436
  if (coveringAsk)
435
437
  return { action: "ask", message: ruleAskMessage(coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
436
438
  }
439
+ const askLane = contentLaneOf("ask", entry, callFace);
440
+ const contentAsk = askLane.length > 0 ? contentHit(askLane, req) : undefined;
441
+ if (contentAsk !== undefined) {
442
+ if (contentAsk.unreadable !== undefined)
443
+ return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, "content_ask", req);
444
+ return { action: "ask", message: ruleAskMessage(contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
445
+ }
437
446
  if (entry) {
438
- const contentAsk = contentHit(entry.content.ask, req);
439
- if (contentAsk !== undefined) {
440
- if (contentAsk.unreadable !== undefined)
441
- return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, "content_ask", req);
442
- return { action: "ask", message: ruleAskMessage(contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
443
- }
444
447
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
445
448
  if (paramAsk) {
446
449
  return { action: "ask", message: ruleAskMessage(paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
447
450
  }
448
451
  if (entry.bare.allow) {
449
- return { action: "allow" };
452
+ return { action: "allow", decisionReason: "rule" };
450
453
  }
451
454
  }
452
455
  if (covering && coveringHit(namespacedCovering.allow, req.toolName)) {
453
- return { action: "allow" };
456
+ return { action: "allow", decisionReason: "rule" };
454
457
  }
455
458
  if (defaultAction === "allow")
456
459
  return { action: "allow" };
@@ -0,0 +1,38 @@
1
+ import type { SessionStorage } from "../session.js";
2
+ /** The entries this prepare appended through the watched storage — recorded once a branch is armed (`ids` set). */
3
+ export interface OwnAppends {
4
+ ids: Set<string> | undefined;
5
+ }
6
+ /** The branch a `resumeAt` acquire wrote: the watched storage, the leaf the request found, the leaf it set, and the own appends since. */
7
+ export interface ResumeAtBranch {
8
+ storage: SessionStorage;
9
+ leafBefore: string | null;
10
+ leafSet: string | null;
11
+ own: OwnAppends;
12
+ }
13
+ /**
14
+ * Wrap a storage so a `SessionError("conflict")` from any append/leaf write flips `ref.hit`, and every append
15
+ * that lands is recorded on `own` while a branch is armed. The harness flattens a mid-run storage failure into
16
+ * a generic error (and, since a lost CAS makes every subsequent append fail, even its failure-reporting append
17
+ * fails) — so the conflict code is gone by the time the task result is assembled. Catching it here, at the
18
+ * storage boundary, keeps it recoverable as `TaskResult.errorCode = "conflict"`. Per-task and non-mutating (a
19
+ * Proxy over the shared storage), so it never stacks across tasks on a cached session.
20
+ */
21
+ export declare function detectConflicts(storage: SessionStorage, ref: {
22
+ hit: boolean;
23
+ }, own: OwnAppends): SessionStorage;
24
+ /**
25
+ * Put a refused prepare's `resumeAt` branch back: the leaf returns to `leafBefore` when it still sits on the
26
+ * branch leaf or on this prepare's own appends above it, and the write is conditional on the leaf just
27
+ * verified. A no-op when the leaf is already there. Throws a sentence naming both leaves when the leaf is
28
+ * not this request's to take back or the write cannot land. `signal` aborted before the write starts ⇒ no
29
+ * write (the caller's bound expired and the view is being dropped); a storage write already in flight cannot
30
+ * be called back — the storage contract carries no cancellation.
31
+ */
32
+ export declare function writeBranchBack(branch: ResumeAtBranch, sessionId: string, signal?: AbortSignal): Promise<void>;
33
+ /**
34
+ * The session cleanup's write-back leg: {@link writeBranchBack} bounded like every other cleanup leg (a throw or
35
+ * a timeout goes to `report`, never to the caller). A write-back still pending when the bound expires does not
36
+ * START its write afterwards — the view it would write through is being dropped.
37
+ */
38
+ export declare function settleBranchWriteBack(branch: ResumeAtBranch, sessionId: string, label: string, report: (err: Error) => void): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { isSessionConflict } from "../session.js";
2
+ import { thrownText } from "../thrown-value.js";
3
+ import { settleTeardownLeg } from "./teardown-bounded.js";
4
+ export function detectConflicts(storage, ref, own) {
5
+ const guard = (fn, landed) => async (...args) => {
6
+ try {
7
+ const out = await fn(...args);
8
+ landed(args);
9
+ return out;
10
+ }
11
+ catch (err) {
12
+ if (isSessionConflict(err)) {
13
+ ref.hit = true;
14
+ }
15
+ throw err;
16
+ }
17
+ };
18
+ return new Proxy(storage, {
19
+ get(target, prop, receiver) {
20
+ const value = Reflect.get(target, prop, receiver);
21
+ if (typeof value !== "function") {
22
+ return value;
23
+ }
24
+ const bound = value.bind(target);
25
+ if (prop === "appendEntry")
26
+ return guard(bound, (args) => own.ids?.add(args[0].id));
27
+ if (prop === "setLeafId")
28
+ return guard(bound, () => undefined);
29
+ return bound;
30
+ },
31
+ });
32
+ }
33
+ export async function writeBranchBack(branch, sessionId, signal) {
34
+ let now;
35
+ try {
36
+ now = await branch.storage.getLeafId();
37
+ if (now === branch.leafBefore)
38
+ return;
39
+ const ids = branch.own.ids ?? new Set();
40
+ for (let at = now, steps = 0; at !== branch.leafSet; steps++) {
41
+ if (at === null || steps > ids.size || !ids.has(at))
42
+ throw new Error("another writer moved the leaf past the entries this request wrote");
43
+ at = (await branch.storage.getEntry(at))?.parentId ?? null;
44
+ }
45
+ if (signal?.aborted === true)
46
+ return;
47
+ await branch.storage.setLeafId(branch.leafBefore, { expectedLeafId: now });
48
+ }
49
+ catch (err) {
50
+ const shown = (id) => (id === undefined ? "(unreadable)" : id === null ? "(empty)" : `"${id}"`);
51
+ throw new Error(`resumeAt: prepare refused this request but could not put session "${sessionId}" back — its leaf stays at ${shown(now)} (the request found it at ${shown(branch.leafBefore)}), so the next turn continues from there: ${thrownText(err)}`);
52
+ }
53
+ }
54
+ export async function settleBranchWriteBack(branch, sessionId, label, report) {
55
+ const ac = new AbortController();
56
+ await settleTeardownLeg(() => writeBranchBack(branch, sessionId, ac.signal), label, report);
57
+ ac.abort();
58
+ }
@@ -103,7 +103,7 @@ export async function judgeInheritedClassifier(opts) {
103
103
  const attributed = (d) => (verdict.classifier !== undefined ? attestClassifierRound(d, verdict.classifier, req) : d);
104
104
  if (verdict.kind === "allow") {
105
105
  autoMode.denialTracking?.recordAllow();
106
- return { kind: "allow", result: attributed({ action: "allow", ...(opts.allowRewrite !== undefined ? { updatedInput: opts.allowRewrite } : {}) }) };
106
+ return { kind: "allow", result: attributed({ action: "allow", decisionReason: "classifier", ...(opts.allowRewrite !== undefined ? { updatedInput: opts.allowRewrite } : {}) }) };
107
107
  }
108
108
  if (verdict.kind === "unavailable") {
109
109
  const arm = classifierUnavailableArm({ ask, req, cause: verdict.cause, seat: opts.unavailableSeat, aborted: opts.signal.aborted });
@@ -214,6 +214,10 @@ export interface GatePass {
214
214
  hookAsk: Extract<PermissionResult, {
215
215
  action: "ask";
216
216
  }> | undefined;
217
+ /** borrowed-mutable — the phase-1 hook answered its own `allow` (the exact word): a statement about this call, read by
218
+ * the fold's last station only while the policy left the arguments as the hook saw them. Writer: the dispatch (phase 1).
219
+ * Reader: the fold's unstated station. */
220
+ hookAllowed?: true;
217
221
  /** borrowed-mutable — set by a park attempt that FAILED (see `ParkAttemptFailed`). Read at the single deny
218
222
  * exit — whatever the fallback chain then decides to say, it says it knowing the park was tried first.
219
223
  * Writers: the three park sites. */
@@ -256,7 +260,7 @@ export interface GatePass {
256
260
  * policy's answer; then rewritten by every station that changes the verdict (fold, tightens, org, rule
257
261
  * lane, stamp, classifier, sandbox, content lane, resolution, edit chain). */
258
262
  decision: PermissionResult;
259
- /** borrowed-mutable — which of the gate's OWN post-fold tightens minted the standing ask, if one did — an
263
+ /** borrowed-mutable — which of the gate's OWN post-fold stations (the tightens and the unstated station) minted the standing ask, if one did — an
260
264
  * input of the ask-origin derivation (`AskOriginFacts.tightened`). Written at most once per pass: every
261
265
  * tighten fires only over a surviving `allow`, and the first one to fire leaves an `ask` the others do not
262
266
  * touch. Recorded by the gate rather than read off `decisionReason` because that member is a policy's to
@@ -1,6 +1,6 @@
1
1
  import { persistedRuleMandateOf, ruleClearanceOf } from "../ask-origin.js";
2
2
  import {} from "../hooks.js";
3
- import { adjudicatePersistedPathRules, adjudicatePersistedRules, laneToolNamesOf, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
+ import { adjudicatePersistedPathRules, adjudicatePersistedRules, callPathFaceOf, faceAskSpeaksFor, laneToolNamesOf, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
4
4
  import { isAbsolutePathForm } from "../../tools/fs/safety.js";
5
5
  import { effectivePathTargetOf } from "../effective-path-target.js";
6
6
  import { declaredPathTargetOf, protectivePathTargetOf } from "../tool-registry.js";
@@ -45,7 +45,7 @@ export function pathRuleLaneAnswer(table, req, ctx) {
45
45
  const allow = effectivePathTargetOf(req, declaredPathTargetOf(req), bases);
46
46
  if (tighten === undefined && allow === undefined)
47
47
  return undefined;
48
- return persistedRuleAnswerOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
48
+ return persistedRuleAnswerOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId, face: callPathFaceOf(req) }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
49
49
  }
50
50
  export function orgRuleLane(cfg) {
51
51
  return {
@@ -127,8 +127,10 @@ export function createPermissionRuleLanes(cfg) {
127
127
  const personalAdjudicate = async (req) => {
128
128
  if (anonymous && !cfg.localOwnerDeclared)
129
129
  return undefined;
130
- const laneNames = laneToolNamesOf(req);
131
- if (laneNames.length === 0)
130
+ const judged = laneToolNamesOf(req).map((n) => [n, ruleToolGrammarOf(n)]);
131
+ if (!judged.some(([, g]) => g === "path") && faceAskSpeaksFor(callPathFaceOf(req)))
132
+ judged.push([req.toolName, "path"]);
133
+ if (judged.length === 0)
132
134
  return undefined;
133
135
  let view;
134
136
  try {
@@ -140,7 +142,7 @@ export function createPermissionRuleLanes(cfg) {
140
142
  if (view.unreadable !== undefined)
141
143
  skipped(view.unreadable, view.unreadableSources);
142
144
  try {
143
- const answer = strongestAnswer(laneNames.map((n) => judgeAgainst(view.rules, { ...req, toolName: n }, ruleToolGrammarOf(n))));
145
+ const answer = strongestAnswer(judged.map(([n, grammar]) => judgeAgainst(view.rules, { ...req, toolName: n }, grammar)));
144
146
  return view.unreadable !== undefined && (answer === undefined || "segmentCoverage" in answer) ? { unreadable: true } : answer;
145
147
  }
146
148
  catch (err) {
@@ -1,7 +1,7 @@
1
1
  import { ownerStoreOf } from "../permission-rule-provider.js";
2
- import { READ_RULE_TOOL, eligiblePersisted, isUsablePathBase, ruleBasesNeeded } from "../permission-rule-model.js";
2
+ import { PATH_RULE_FACES, READ_RULE_TOOL, eligiblePersisted, isUsablePathBase, ruleBasesNeeded } from "../permission-rule-model.js";
3
3
  import { isAbsolutePathForm, pathFamilyOf } from "../../tools/fs/safety.js";
4
- import { PATH_DENY_RULE_FACES, pathRuleDenyRowOf } from "../permission-rules.js";
4
+ import { pathRuleDenyRowOf } from "../permission-rules.js";
5
5
  import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
7
7
  import { thrownText } from "../thrown-value.js";
@@ -34,7 +34,7 @@ export async function persistedPathRowsOf(cfg) {
34
34
  readDirectories.push(rule.command);
35
35
  continue;
36
36
  }
37
- const faces = PATH_DENY_RULE_FACES[rule.tool];
37
+ const faces = PATH_RULE_FACES[rule.tool]?.deny;
38
38
  if (rule.behavior !== "deny" || faces === undefined || (rule.match !== "path" && rule.match !== "subpath") || !eligiblePersisted(rule, { ...call, tool: rule.tool }, "deny"))
39
39
  continue;
40
40
  const missing = ruleBasesNeeded(rule).filter((b) => !isUsablePathBase(b === "cwd" ? bases.root : bases[b], family));
@@ -16,13 +16,15 @@
16
16
  *
17
17
  * Throws pass through unchanged: `resume.session_not_found` (requireExisting fail-loud mapping),
18
18
  * the `resume_at.*` target-validation refusals, and — after the bounded re-wake budget — the
19
- * session conflict itself.
19
+ * session conflict itself. A throw after a `resumeAt` branch landed first writes that branch back
20
+ * (`settleBranchWriteBack`); on success the branch is handed over with the session.
20
21
  */
21
22
  import { StoredSession } from "../session.js";
22
23
  import type { AcquiredSession, SessionStore } from "../session.js";
23
24
  import { type RecoveredOrphan } from "../session-reconcile.js";
24
25
  import type { TaskSpec, ToolEffect } from "../types.js";
25
26
  import type { PrepareResume } from "./contracts.js";
27
+ import { type ResumeAtBranch } from "./branch-writeback.js";
26
28
  export interface PrepareAcquireReconcileInput {
27
29
  /** borrowed-mutable (service port) — the deployment's session store. This phase's verbs on it are
28
30
  * the acquire/forget half of the session resource protocol: `acquire` per attempt, `forget` to
@@ -52,8 +54,10 @@ export interface PrepareAcquireReconcileInput {
52
54
  * absent seat; nothing in the phase's control flow depends on it. Computed by the driver (which
53
55
  * holds `deps`) rather than here, keeping this slice's input the narrow Pick its contract says. */
54
56
  durableParkGap?: string;
57
+ /** borrowed-readonly — the incident channel for this phase's own cleanup legs: a `resumeAt` branch it could not write back, a forget that did not settle. */
58
+ report?: (err: Error, sessionId: string) => void;
55
59
  }
56
- /** The phase's outputs (four-class form). All five are fresh bindings —
60
+ /** The phase's outputs (four-class form). All six are fresh bindings —
57
61
  * the driver destructures them into consts, so a consumer moved ahead of this call is a lexical
58
62
  * error, which is the static guarantee the extraction keeps. */
59
63
  export interface PrepareAcquireReconcileResult {
@@ -75,6 +79,9 @@ export interface PrepareAcquireReconcileResult {
75
79
  * SUCCEEDS (a conflict-losing attempt re-wakes and re-derives the whole set from the fresh
76
80
  * branch), and stays empty for a fresh session / a clean one with nothing to reconcile. */
77
81
  wakeRecovered: RecoveredOrphan[];
82
+ /** owned (handed over with the session) — the `resumeAt` branch this phase wrote, absent when it wrote
83
+ * none; the driver's throw cleanup writes it back on any later prepare refusal. */
84
+ branch: ResumeAtBranch | undefined;
78
85
  }
79
86
  /** The B-3 phase body — the acquire+reconcile slice, verbatim (see the module header for the contract). */
80
87
  export declare function prepareAcquireReconcile(input: PrepareAcquireReconcileInput): Promise<PrepareAcquireReconcileResult>;
@@ -1,37 +1,15 @@
1
1
  import { StoredSession, isSessionConflict } from "../session.js";
2
2
  import { reconcileInterruptedSession } from "../session-reconcile.js";
3
+ import { detectConflicts, settleBranchWriteBack } from "./branch-writeback.js";
4
+ import { settleTeardownLeg } from "./teardown-bounded.js";
3
5
  const RECONCILE_MAX_RETRIES = 3;
4
- function detectConflicts(storage, ref) {
5
- const guard = (fn) => async (...args) => {
6
- try {
7
- return await fn(...args);
8
- }
9
- catch (err) {
10
- if (isSessionConflict(err)) {
11
- ref.hit = true;
12
- }
13
- throw err;
14
- }
15
- };
16
- return new Proxy(storage, {
17
- get(target, prop, receiver) {
18
- const value = Reflect.get(target, prop, receiver);
19
- if (typeof value !== "function") {
20
- return value;
21
- }
22
- const bound = value.bind(target);
23
- return prop === "appendEntry" || prop === "setLeafId"
24
- ? guard(bound)
25
- : bound;
26
- },
27
- });
28
- }
29
6
  export async function prepareAcquireReconcile(input) {
30
7
  const { sessions, spec, resume, toolEffects } = input;
31
8
  let acquired;
32
9
  let session;
33
10
  let conflictRef;
34
11
  let wakeRecovered = [];
12
+ let branch;
35
13
  for (let attempt = 0;; attempt++) {
36
14
  try {
37
15
  acquired = await sessions.acquire(spec.sessionId, spec.requireExistingSession ? { requireExisting: true } : input.placement !== undefined ? { placement: input.placement } : undefined);
@@ -46,7 +24,8 @@ export async function prepareAcquireReconcile(input) {
46
24
  throw err;
47
25
  }
48
26
  conflictRef = { hit: false };
49
- session = new StoredSession(detectConflicts(acquired.session.getStorage(), conflictRef));
27
+ const own = { ids: undefined };
28
+ session = new StoredSession(detectConflicts(acquired.session.getStorage(), conflictRef, own));
50
29
  if (!spec.sessionId) {
51
30
  break;
52
31
  }
@@ -105,7 +84,10 @@ export async function prepareAcquireReconcile(input) {
105
84
  throw e;
106
85
  }
107
86
  }
108
- await session.getStorage().setLeafId(spec.resumeAtMode === "before" ? entry.parentId : spec.resumeAt);
87
+ const storage = session.getStorage(), leafBefore = await storage.getLeafId(), leafSet = spec.resumeAtMode === "before" ? entry.parentId : spec.resumeAt;
88
+ await storage.setLeafId(leafSet);
89
+ own.ids = new Set();
90
+ branch = { storage, leafBefore, leafSet, own };
109
91
  }
110
92
  wakeRecovered = (await reconcileInterruptedSession(session, toolEffects, resume?.suspendedBatch)).recovered;
111
93
  break;
@@ -113,13 +95,17 @@ export async function prepareAcquireReconcile(input) {
113
95
  catch (err) {
114
96
  if (isSessionConflict(err) && attempt < RECONCILE_MAX_RETRIES) {
115
97
  if (sessions.forget) {
116
- await Promise.resolve(sessions.forget(spec.sessionId)).catch(() => undefined);
98
+ const forget = sessions.forget.bind(sessions), id = spec.sessionId;
99
+ await settleTeardownLeg(() => Promise.resolve(forget(id)).catch(() => undefined), "forget (acquire-reconcile re-wake leg)", (e) => input.report?.(e, id));
117
100
  }
101
+ branch = undefined;
118
102
  continue;
119
103
  }
120
- await Promise.resolve(sessions.forget ? sessions.forget(acquired.sessionId) : undefined).catch(() => undefined);
104
+ if (branch !== undefined)
105
+ await settleBranchWriteBack(branch, acquired.sessionId, "resumeAt branch write-back (acquire-reconcile failure leg)", (e) => input.report?.(e, acquired.sessionId));
106
+ await settleTeardownLeg(() => Promise.resolve(sessions.forget ? sessions.forget(acquired.sessionId) : undefined).catch(() => undefined), "forget (acquire-reconcile failure leg)", (e) => input.report?.(e, acquired.sessionId));
121
107
  throw err;
122
108
  }
123
109
  }
124
- return { acquired, session, conflictRef, wakeRecovered };
110
+ return { acquired, session, conflictRef, wakeRecovered, branch };
125
111
  }
@@ -8,8 +8,8 @@
8
8
  * boundary seat runtask drives.
9
9
  *
10
10
  * Runs INSIDE the driver's prepare try-block, after the workspace root is FINAL (`taskRootFinal`) and the
11
- * env is resolved: every refusal here throws through to the driver's prepare-throw catch exactly as
12
- * before the move (a minted ownedEnv is destroyed there, the session view forgotten). The phase acquires
11
+ * env is resolved: every refusal here throws through to the driver's prepare-throw catch (a minted
12
+ * ownedEnv is destroyed there, a resumeAt branch written back, the session view forgotten). The phase acquires
13
13
  * no resource of its own — the two seats it returns are closures over the store the deployment owns.
14
14
  *
15
15
  * The request door (the bad-value doors and every refusal the request decides alone) lives in
@@ -25,7 +25,7 @@
25
25
  import type { AgentHarness, AgentTool } from "../../internal/harness.js";
26
26
  import type { AskClass } from "../ask-class.js";
27
27
  import type { Arbitration, ToolMarkSource } from "../ask-origin.js";
28
- import type { PermissionModeController } from "../mode-policy.js";
28
+ import { type PermissionModeController } from "../mode-policy.js";
29
29
  import type { AutoModeDecider, AutoModeDenialTracker } from "../auto-mode.js";
30
30
  import { type ComplianceCapability } from "../compliance.js";
31
31
  import { type HookEnvCapabilities, type HookInvocationIdentity, type Hooks } from "../hooks.js";
@@ -1,4 +1,5 @@
1
1
  import { SEND_MESSAGE_TOOL_NAME } from "../../agents/send-message-tool.js";
2
+ import { mountedToolPermissionSeat } from "../mode-policy.js";
2
3
  import { complianceCallDenial } from "../compliance.js";
3
4
  import { cloneObserverInput, formatHookFeedback, hookSeatExpiredError, runHookSeat, runToolGate } from "../hooks.js";
4
5
  import { screenGateOutcome } from "../gate-outcome.js";
@@ -137,6 +138,7 @@ export function prepareGateStations(input) {
137
138
  ...(autoModeDecider.current !== undefined ? { autoMode: { decider: autoModeDecider.current, ...(autoModeDenialTracking.current !== undefined ? { denialTracking: autoModeDenialTracking.current } : {}), unavailable: classifierUnavailableSeat({ station: "leg", ...(autoModeDecider.current.timeoutMs !== undefined ? { budgetMs: autoModeDecider.current.timeoutMs } : {}), approvalSeat: askReachesPerson(askRoute), onNotice: deps.onNotice, sessionId, runId }) } } : {}),
138
139
  permissionMode: () => permissionMode.current,
139
140
  ...(markSources.size > 0 ? { toolMarkSources: markSources } : {}),
141
+ ...(mountedToolPermissionSeat(tools, e.toolName) === "passthrough" ? { permissionSeat: "passthrough" } : {}),
140
142
  ...(a2aIsolatePeers === true ? { a2aIsolatePeers: true } : {}),
141
143
  onModeReleased,
142
144
  ancestorArbitrationOf: (toolCallId) => ancestorArbitrations.get(toolCallId),
@@ -149,7 +149,7 @@ export interface PrepareHandsReadFaceInput {
149
149
  reversibilityProbes: ReversibilityProbes;
150
150
  /** borrowed-readonly — the run's reminder provenance mark (minted/adopted
151
151
  * by the driver's adoption ladder); threaded into the hands band so the fs-side engine mints
152
- * (the Read cyber/dedup/offset/empty reminders, the gh rate-limit hint) stamp the same mark the
152
+ * (the Read dedup/offset/empty reminders, the gh rate-limit hint) stamp the same mark the
153
153
  * system-prompt declaration names. */
154
154
  reminderMark: string;
155
155
  /** borrowed-mutable — the leg's disclosure trigger counters; the hands
@@ -366,7 +366,6 @@ export async function prepareHandsMount(input) {
366
366
  ...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}),
367
367
  ...(deps.hands?.readImageDownsampler !== undefined ? { readImageDownsampler: deps.hands.readImageDownsampler } : {}),
368
368
  ...(deps.hands?.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: deps.hands.autoBackgroundOnTimeout } : {}),
369
- ...(deps.hands?.readCyberReminder !== undefined ? { readCyberReminder: deps.hands.readCyberReminder } : {}),
370
369
  beforeWrite: async (w) => {
371
370
  const deploymentGate = deps.hands?.beforeWrite;
372
371
  if (deploymentGate) {
@@ -364,7 +364,8 @@ export async function preparePolicyChain(input) {
364
364
  if (re.action === "deny")
365
365
  return vetoed(re);
366
366
  if (re.action === "allow") {
367
- const out = { action: "allow", updatedInput: re.updatedInput !== undefined ? re.updatedInput : editArgs };
367
+ const stated = re.updatedInput === undefined ? "mode" : re.decisionReason;
368
+ const out = { action: "allow", updatedInput: re.updatedInput !== undefined ? re.updatedInput : editArgs, ...(stated !== undefined ? { decisionReason: stated } : {}) };
368
369
  carryEngineAttestations(re, out);
369
370
  carryPrecedingSettlement(settledBy, out);
370
371
  return out;
@@ -384,7 +385,7 @@ export async function preparePolicyChain(input) {
384
385
  re = editAsk;
385
386
  const editArbitrated = arbitrateInherited(re, creq.toolCallId, judged);
386
387
  if (editArbitrated !== undefined) {
387
- const out = editArbitrated.action === "allow" ? { action: "allow", updatedInput: editArgs } : editArbitrated;
388
+ const out = editArbitrated.action === "allow" ? { action: "allow", updatedInput: editArgs, decisionReason: "mode" } : editArbitrated;
388
389
  carryPrecedingSettlement(settledBy, out);
389
390
  return out;
390
391
  }
@@ -408,7 +409,7 @@ export async function preparePolicyChain(input) {
408
409
  return rr;
409
410
  settledBy = rr;
410
411
  if (rr.updatedInput === undefined) {
411
- const out = { action: "allow", updatedInput: editArgs };
412
+ const out = { action: "allow", updatedInput: editArgs, decisionReason: "mode" };
412
413
  carryPrecedingSettlement(rr, out);
413
414
  return out;
414
415
  }
@@ -461,7 +462,7 @@ export async function preparePolicyChain(input) {
461
462
  const { ask: inheritedAsk, fallback, mintedHere } = judged;
462
463
  if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq) && judged.classification.row.sandboxAdmission) {
463
464
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
464
- return { action: "allow" };
465
+ return { action: "allow", decisionReason: "sandbox" };
465
466
  }
466
467
  const arbitrated = arbitrateInherited(inheritedAsk, creq.toolCallId, judged);
467
468
  if (arbitrated !== undefined)
@@ -496,7 +497,7 @@ export async function preparePolicyChain(input) {
496
497
  return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, resolved, csignal, pc.autoMode);
497
498
  }
498
499
  recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved, askClassMembersOf(judged.classification).classes);
499
- return { action: "allow" };
500
+ return { action: "allow", decisionReason: "mode" };
500
501
  },
501
502
  }
502
503
  : wrapToolPolicy(pc.policy, async (creq, csignal) => {
@@ -535,7 +536,7 @@ export async function preparePolicyChain(input) {
535
536
  const { ask: inheritedAsk, fallback, mintedHere } = judged;
536
537
  if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq) && judged.classification.row.sandboxAdmission) {
537
538
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
538
- return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput } : { action: "allow" };
539
+ return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput, decisionReason: "sandbox" } : { action: "allow", decisionReason: "sandbox" };
539
540
  }
540
541
  const arbitrated = arbitrateInherited(inheritedAsk.action === "ask" ? { ...inheritedAsk, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) } : inheritedAsk, creq.toolCallId, judged);
541
542
  if (arbitrated !== undefined)
@@ -635,6 +636,7 @@ export async function preparePolicyChain(input) {
635
636
  const finalReq = { ...req, args: decision.updatedInput };
636
637
  let asked;
637
638
  const revalidated = {};
639
+ let restated;
638
640
  try {
639
641
  for (const layer of recheckLayers) {
640
642
  const d = refuseOutOfContractDecision(await layer.check(finalReq, signal));
@@ -653,6 +655,8 @@ export async function preparePolicyChain(input) {
653
655
  }
654
656
  if (d.action === "ask" && asked === undefined)
655
657
  asked = d;
658
+ if (d.action === "allow" && restated === undefined && d.decisionReason !== undefined)
659
+ restated = d.decisionReason;
656
660
  carryEngineAttestations(d, revalidated);
657
661
  }
658
662
  }
@@ -665,7 +669,7 @@ export async function preparePolicyChain(input) {
665
669
  return surfaced;
666
670
  }
667
671
  if (decision.action === "allow") {
668
- const owned = { ...decision };
672
+ const owned = { ...decision, ...(decision.decisionReason === undefined && restated !== undefined ? { decisionReason: restated } : {}) };
669
673
  carryEngineAttestations(decision, owned);
670
674
  carryEngineAttestations(revalidated, owned);
671
675
  return owned;
@@ -38,6 +38,7 @@ import { preparePromptAssembly } from "./prepare-prompt-assembly.js";
38
38
  import { limitConfigError, prepareConfigDoors } from "./prepare-config-doors.js";
39
39
  import { prepareSafetyScan } from "./prepare-safety-scan.js";
40
40
  import { prepareAcquireReconcile } from "./prepare-acquire-reconcile.js";
41
+ import { settleBranchWriteBack } from "./branch-writeback.js";
41
42
  import { prepareHandsMount, resolveHandsLessReadFace } from "./prepare-hands-readface.js";
42
43
  import { prepareWorkspaceRestore } from "./prepare-workspace-restore.js";
43
44
  import { parkContaminationMarker } from "./park-commit.js";
@@ -291,8 +292,15 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
291
292
  let shellGatedTools = new Set();
292
293
  refuseRequireExistingWithoutSession(spec);
293
294
  refuseRegistryScopeMismatch(spec, internals);
295
+ if (model.extraBody !== undefined && Object.prototype.hasOwnProperty.call(model.extraBody, "max_completion_tokens")) {
296
+ const e = new Error(`model.extraBody supplies "max_completion_tokens" — this spelling is engine-owned (the overflow retry ` +
297
+ `adjusts the per-request output cap, and a constant extraBody value would override that adjustment on ` +
298
+ `every retry). Set the cap via limits.maxOutputTokens (per request) or model.maxTokens instead.`);
299
+ e.code = "config.extra_body_output_cap";
300
+ throw e;
301
+ }
294
302
  const fileHistoryRequest = resolveFileHistoryRequest(spec, deps);
295
- const { acquired, session, conflictRef, wakeRecovered } = await prepareAcquireReconcile({ sessions, spec, resume, toolEffects, ...(internals?.sessionPlacement !== undefined ? { placement: internals.sessionPlacement } : {}), ...(() => { const g = durableParkGapFor(deps, spec); return g !== undefined ? { durableParkGap: g } : {}; })() });
303
+ const { acquired, session, conflictRef, wakeRecovered, branch } = await prepareAcquireReconcile({ sessions, spec, resume, toolEffects, report: (e, id) => deps.onError?.(e, { phase: "config", sessionId: id }), ...(internals?.sessionPlacement !== undefined ? { placement: internals.sessionPlacement } : {}), ...(() => { const g = durableParkGapFor(deps, spec); return g !== undefined ? { durableParkGap: g } : {}; })() });
296
304
  const sessionId = acquired.sessionId;
297
305
  await holdSessionLock?.(sessionId);
298
306
  const onceLedger = await loadAnnounceOnceLedger(session, resume?.seed.announcedListings, deps.onError);
@@ -320,18 +328,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
320
328
  deps.onError?.(new Error(`offload/tool-result namespace note: this run mounts in registry domain "${internals.registryScope}" but its offloaded tool results live under the TRUST identity "${offloadScope}" (principal${spec.principal === undefined ? " absent → default" : ""}). Before 2.13.0 the registry domain named this namespace too; if a deployment used registryScope as its ref-tenancy boundary, that boundary is now the principal — pass one, or read refs under "${offloadScope}".`), { phase: "config", sessionId });
321
329
  }
322
330
  defaultTaskRegistry.maybeGc();
323
- const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
331
+ const forgetOnThrow = () => branch === undefined
332
+ ? forgetQuietly(sessions, sessionId)
333
+ : settleBranchWriteBack(branch, sessionId, "resumeAt branch write-back (prepare refusal)", (e) => deps.onError?.(e, { phase: "config", sessionId })).then(() => forgetQuietly(sessions, sessionId));
324
334
  const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
325
335
  if (extraBodyCollisions.length > 0) {
326
336
  deps.onError?.(new Error(`model.extraBody contains reserved key(s) [${extraBodyCollisions.join(", ")}] that core owns and sets itself — they are ignored. Remove them from extraBody.`), { phase: "config", sessionId });
327
337
  }
328
- if (model.extraBody !== undefined && Object.prototype.hasOwnProperty.call(model.extraBody, "max_completion_tokens")) {
329
- const e = new Error(`model.extraBody supplies "max_completion_tokens" — this spelling is engine-owned (the overflow retry ` +
330
- `adjusts the per-request output cap, and a constant extraBody value would override that adjustment on ` +
331
- `every retry). Set the cap via limits.maxOutputTokens (per request) or model.maxTokens instead.`);
332
- e.code = "config.extra_body_output_cap";
333
- throw e;
334
- }
335
338
  const { nestedStats, reportUsage, offloadStore, offloadReachableToolsRef, maybeOffload, firstPartyOffload, remoteToolOffload } = prepareOffloadWrappers({ spec, deps, resume, offloadScope, sessionId });
336
339
  if (offloadStore && (spec.tools ?? []).some((t) => t.name === OFFLOAD_TOOL_NAME)) {
337
340
  const e = new Error(`Tool name "${OFFLOAD_TOOL_NAME}" is reserved when large-result offload is enabled.`);
@@ -33,7 +33,7 @@ import { type LegWriteProtection } from "./write-protection-station.js";
33
33
  import { type UsageWindow } from "../usage-window-store.js";
34
34
  import type { ToolRoster } from "../tool-roster.js";
35
35
  import { type AutoModeArmFact, type WiringManifest, type WiringManifestMcpEntry } from "../wiring-manifest.js";
36
- import type { PermissionModeController } from "../mode-policy.js";
36
+ import { type PermissionModeController } from "../mode-policy.js";
37
37
  import type { AnnounceOnceSink, Prepared, PrepareResume, RunInternals, UsageGovernance } from "./contracts.js";
38
38
  export interface PrepareWiringManifestInput {
39
39
  /** borrowed-readonly — the REBOUND spec. Read: `onAsk` (the own seat), `onQuestion` (the question seam), `handsReadOnly` / `tools` (the ungated scan),