@sema-agent/core 5.21.1 → 5.22.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/CHANGELOG.md +56 -0
- package/dist/agents/send-message-tool.js +6 -3
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +45 -4
- package/dist/brain/errors.d.ts +20 -0
- package/dist/brain/errors.js +40 -0
- package/dist/brain/retry.d.ts +16 -2
- package/dist/brain/retry.js +3 -2
- package/dist/brain/status-sink.d.ts +9 -2
- package/dist/brain/stream-engine.d.ts +22 -0
- package/dist/brain/stream-engine.js +41 -10
- package/dist/core/ask-class.d.ts +48 -0
- package/dist/core/ask-class.js +33 -0
- package/dist/core/checkpoint-store.d.ts +103 -10
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/governance-codes.d.ts +38 -0
- package/dist/core/governance-codes.js +11 -0
- package/dist/core/hooks.d.ts +39 -0
- package/dist/core/hooks.js +26 -2
- package/dist/core/locked-config.d.ts +7 -1
- package/dist/core/locked-config.js +2 -1
- package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
- package/dist/core/memory-engine/delegation-provenance.js +26 -0
- package/dist/core/memory-engine/engine.d.ts +67 -1
- package/dist/core/memory-engine/engine.js +270 -12
- package/dist/core/memory-engine/header-hints.d.ts +30 -0
- package/dist/core/memory-engine/header-hints.js +41 -0
- package/dist/core/memory-engine/index.d.ts +3 -2
- package/dist/core/memory-engine/index.js +3 -2
- package/dist/core/memory-engine/layout.d.ts +166 -0
- package/dist/core/memory-engine/layout.js +399 -0
- package/dist/core/memory-engine/tools.d.ts +30 -0
- package/dist/core/memory-engine/tools.js +108 -17
- package/dist/core/permission-rule-consent.d.ts +25 -9
- package/dist/core/permission-rule-consent.js +91 -20
- package/dist/core/permission-rule-model.d.ts +9 -1
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rule-org.d.ts +161 -0
- package/dist/core/permission-rule-org.js +211 -0
- package/dist/core/permission-rule-store.d.ts +249 -6
- package/dist/core/permission-rule-store.js +313 -3
- package/dist/core/permission-rule-sync.d.ts +131 -0
- package/dist/core/permission-rule-sync.js +314 -0
- package/dist/core/runner/prepare-memory.js +35 -8
- package/dist/core/runner/prepare-task.d.ts +54 -1
- package/dist/core/runner/prepare-task.js +246 -27
- package/dist/core/runner/runtask.js +147 -6
- package/dist/core/shared-memory/contract.js +19 -4
- package/dist/core/shared-memory/normalize.d.ts +3 -1
- package/dist/core/shared-memory/tools.js +73 -17
- package/dist/core/shared-memory/types.d.ts +27 -1
- package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
- package/dist/core/task-notification.d.ts +5 -2
- package/dist/core/task-registry-agent.d.ts +1 -1
- package/dist/core/task-registry-agent.js +6 -2
- package/dist/core/task-registry-shared.d.ts +9 -2
- package/dist/core/task-registry.d.ts +9 -3
- package/dist/core/task-registry.js +2 -0
- package/dist/core/tool-policy.d.ts +120 -2
- package/dist/core/tool-policy.js +116 -6
- package/dist/core/trace.d.ts +32 -1
- package/dist/core/types.d.ts +56 -3
- package/dist/index.d.ts +12 -7
- package/dist/index.js +10 -5
- package/dist/stores/file/checkpoint-store.d.ts +4 -0
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
- package/dist/stores/file/permission-rule-adopt.js +95 -0
- package/dist/stores/file/permission-rule-store.d.ts +80 -2
- package/dist/stores/file/permission-rule-store.js +189 -46
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.22.0 — 2026-08-09
|
|
4
|
+
|
|
5
|
+
### BREAKING
|
|
6
|
+
|
|
7
|
+
- **Checkpoint version 6 → 7.** A checkpoint row that carries the new inheritance-chain constraint
|
|
8
|
+
snapshot or delegation-provenance state is stamped v7; a pre-5.22 worker refuses it up front
|
|
9
|
+
(`checkpoint.unsupported_version`) instead of resuming with the ancestors' gate silently dropped.
|
|
10
|
+
Rows without the new faces keep their old stamp and resume everywhere.
|
|
11
|
+
- **Durable re-supply contract: content digest replaces the count check.** Resuming a suspended task
|
|
12
|
+
under inherited constraints now verifies a `cpv1:` sha256 digest of the persisted constraint
|
|
13
|
+
projection (topology + decision-chain metadata: autoMode armed, durable/content mandates) — a
|
|
14
|
+
same-length substitute chain, or one that dropped the ancestor's classifier, is refused
|
|
15
|
+
(`resume.parent_constraint_mismatch`). Old checkpoints without a digest fall back to the count check.
|
|
16
|
+
- **Approver-edited args re-adjudicate BEFORE the resolve CAS, and a refusal keeps the park pending.**
|
|
17
|
+
Previously the edit recheck ran after the checkpoint was consumed; a refusal now surfaces as
|
|
18
|
+
`resume.constraint_rejected` / `resume.constraint_unprojectable` with the checkpoint still
|
|
19
|
+
`pending` and re-decidable (new reopen reason `constraint_rejected`). Deployments that pinned the
|
|
20
|
+
old "resume completes, denial injected" shape must re-pin.
|
|
21
|
+
- **`basePolicyForResumeEdit` absent no longer skips the edit recheck.** The knob becomes an
|
|
22
|
+
override; absence falls back to the deployment's locked preflight policy, so an approver edit is
|
|
23
|
+
never executed with zero re-adjudication. Configured deployments are unchanged.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Inheritance-chain decision-chain integrity (F-012).** Ancestor deny verdicts freeze into the
|
|
28
|
+
chain as a canonical projection; the ancestor's auto-mode classifier now travels with the chain
|
|
29
|
+
(both wrapper arms and the approved-edit recheck) — a delegated child no longer executes a call the
|
|
30
|
+
ancestor's own gate would have classified into a block. The budget ledger is documented as
|
|
31
|
+
task-scoped (it does not follow the chain), and the edit-recheck path now sees a real budget
|
|
32
|
+
snapshot instead of an absent one.
|
|
33
|
+
- **Sandbox admission (opt-in, narrow form).** Under an execution environment that declares
|
|
34
|
+
`capabilities.isolation`, surviving engine-minted local asks on engine hand tools admit
|
|
35
|
+
automatically with a durable `permission.sandbox_admitted` disclosure (`decisionReason:"sandbox"`)
|
|
36
|
+
instead of parking. Caller-registered in-process tools never auto-admit — the isolation capability
|
|
37
|
+
only vouches for what runs inside that environment. Enabling the mode requires durable parking and
|
|
38
|
+
a checkpoint store that declares re-decision capability; otherwise preparation refuses loudly.
|
|
39
|
+
- **Deterministic connect refusals take a short retry lane (#107).** ECONNREFUSED/ENOTFOUND-class
|
|
40
|
+
failures retry 2× with a 1s backoff cap instead of consuming the full transient budget
|
|
41
|
+
(measured 186s → 1.8s); `BrainStatus` and `brain.retry` telemetry carry a closed `errClass` and
|
|
42
|
+
the in-force lane budget. Consumers pinning the `reconnecting` detail text or reading
|
|
43
|
+
`maxRetries` as the engine-wide budget are affected.
|
|
44
|
+
- **Delegation attestation persists across processes (design/180 R-4 half).** The three-value
|
|
45
|
+
delegation-provenance aggregate rides the durable checkpoint; a resumed leg missing the state
|
|
46
|
+
reads as `unknown` (static floor), never as `clean`.
|
|
47
|
+
- **`buildToolResultRef` exported from the package root** — a host wiring an HTTP tool-result read
|
|
48
|
+
face binds an incoming ref to its owning task by prefix recomputation.
|
|
49
|
+
- **Content mandate split from the approval mandate (#94).** A delegated child's question routes on
|
|
50
|
+
the QUESTION seat, not the approver seat. Behavior widening, called out explicitly: under
|
|
51
|
+
`durableApproval` with a live `onQuestion`, an absent or string approver seat used to leave child
|
|
52
|
+
questions undeliverable — they now reach the live question face. The permission leg is unchanged.
|
|
53
|
+
- **Background-agent revival honesty (#84).** `resumable` answers the real revival ladder;
|
|
54
|
+
the revive lane distinguishes a transient `recycling` window (`resume.row_recycling`, retry) from
|
|
55
|
+
`row_gone` (terminal), checks access before disclosing row lifecycle, and
|
|
56
|
+
`TaskRegistry.reviveBackgroundAgent`'s refusal union gains the `recycling` member — exhaustive
|
|
57
|
+
consumers add one arm.
|
|
58
|
+
|
|
3
59
|
## 5.21.1 — 2026-08-09
|
|
4
60
|
|
|
5
61
|
- **Fix: every `hands: none` deployment failed at the door on 5.21.0** (P0). `HAND_TOOL_EFFECTS`
|
|
@@ -709,9 +709,12 @@ export function createSendMessageTool(opts) {
|
|
|
709
709
|
: code === "steering.still_running"
|
|
710
710
|
?
|
|
711
711
|
`${who} (or a prior follow-up to it) is still running — wait for its completion notification. ${DEDUP_RETRY_NOTE}`
|
|
712
|
-
: code === "resume.
|
|
713
|
-
?
|
|
714
|
-
|
|
712
|
+
: code === "resume.row_recycling"
|
|
713
|
+
?
|
|
714
|
+
`${who}'s registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — send again in a moment. ${DEDUP_RETRY_NOTE}`
|
|
715
|
+
: code === "resume.row_gone"
|
|
716
|
+
? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
|
|
717
|
+
: `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
|
|
715
718
|
return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
|
|
716
719
|
}
|
|
717
720
|
});
|
|
@@ -293,6 +293,12 @@ export declare function completedAgentCard(child: {
|
|
|
293
293
|
* flagged (or no review was armed), so a consumer must not read presence as a verdict source for
|
|
294
294
|
* anything else. */
|
|
295
295
|
handbackWarning?: string;
|
|
296
|
+
/** design/180 half A — the child's delegation runtime-provenance attestation, reduced from its
|
|
297
|
+
* recorder aggregate at delivery. Present only when the parent chain armed the recorder; the
|
|
298
|
+
* parent-side judgment (the content-origin wrap) reads it off this card — `clean` retires the
|
|
299
|
+
* static face verdict for this call, `external` marks with the observed-event reason, `unknown`
|
|
300
|
+
* (or absence) keeps the static floor. Deliberate whitelist admission (see the boundary note). */
|
|
301
|
+
attestation?: import("../core/memory-engine/delegation-provenance.js").DelegationAttestation;
|
|
296
302
|
}): Record<string, unknown>;
|
|
297
303
|
/**
|
|
298
304
|
* ruled 2026-08-04 — the DELEGATION-REPORT vocabulary for a child that durably paused at an approval
|
package/dist/agents/subagent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
3
|
import { withDelegationProvenance } from "../core/tool-policy.js";
|
|
4
|
+
import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from "../core/memory-engine/delegation-provenance.js";
|
|
4
5
|
import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
|
|
5
6
|
import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
|
|
6
7
|
import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
|
|
@@ -311,6 +312,13 @@ const ccElapsedTag = (ms) => {
|
|
|
311
312
|
return sec >= 60 ? ` \u00b7 ${Math.floor(sec / 60)}m${sec % 60}s` : ` \u00b7 ${sec}s`;
|
|
312
313
|
};
|
|
313
314
|
const ccCompletionText = (desc, settled, rawStatus, elapsedMs) => `Agent "${desc}" ${settled === "killed" ? "stopped" : settled === "completed" ? "finished" : rawStatus}${ccElapsedTag(elapsedMs)}`;
|
|
315
|
+
function resumabilityClaim(f) {
|
|
316
|
+
if (f.status === "killed" && f.stoppedBy === "user")
|
|
317
|
+
return false;
|
|
318
|
+
if (f.liveRetainPresent)
|
|
319
|
+
return f.liveRetainHasBudget;
|
|
320
|
+
return f.durableRow && f.named;
|
|
321
|
+
}
|
|
314
322
|
const BG_AGENT_COLLATERAL_REAP_REASON = "its parent run ended";
|
|
315
323
|
const RESERVED_AGENT_NAMES = new Set([OUTPUT_TOOL_NAME, TOOL_SEARCH_NAME, OFFLOAD_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME, DEFAULT_SUBAGENT_TOOL_NAME]);
|
|
316
324
|
export const REPORT_FIELD_MAX = 300;
|
|
@@ -341,6 +349,7 @@ export function classifySubagentError(child) {
|
|
|
341
349
|
export function completedAgentCard(child, extras) {
|
|
342
350
|
return {
|
|
343
351
|
type: "agent",
|
|
352
|
+
...(extras.attestation !== undefined ? { attestation: extras.attestation } : {}),
|
|
344
353
|
...(extras.subagentType !== undefined ? { subagent_type: extras.subagentType } : {}),
|
|
345
354
|
status: child.status,
|
|
346
355
|
taskId: child.taskId,
|
|
@@ -522,7 +531,9 @@ export function createSubagentResume(deps) {
|
|
|
522
531
|
throw configError(revived.reason === "still_running"
|
|
523
532
|
?
|
|
524
533
|
"resume unavailable: the agent's registry row is not available for a resume right now (it is running, or another revival claimed it) — wait for its completion notification and send again."
|
|
525
|
-
:
|
|
534
|
+
: revived.reason === "recycling"
|
|
535
|
+
? "resume unavailable: the agent's registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — this clears on its own; send again in a moment."
|
|
536
|
+
: "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : revived.reason === "recycling" ? "resume.row_recycling" : "resume.row_gone");
|
|
526
537
|
}
|
|
527
538
|
reviveCycle = revived.cycle;
|
|
528
539
|
if (deps.bgSink !== undefined) {
|
|
@@ -682,6 +693,16 @@ export function createSubagentResume(deps) {
|
|
|
682
693
|
const reviveTerminalSummary = failReasonRevive !== undefined
|
|
683
694
|
? `Agent "${reviveName}" (resumed) failed: ${failReasonRevive}${ccElapsedTag(Date.now() - reviveStartedAt)}`.slice(0, 300) + errorKindClause(errClassRevive)
|
|
684
695
|
: `Agent "${reviveName}" (resumed) ${status === "killed" ? "stopped" : child.status === "completed" ? "finished" : String(child.status)}${ccElapsedTag(Date.now() - reviveStartedAt)}`;
|
|
696
|
+
const reviveDurableProbe = deps.registry !== undefined && deps.taskId !== undefined ? deps.registry.durableAgentRowProbe(deps.taskId) : undefined;
|
|
697
|
+
const reviveDurableRow = reviveDurableProbe !== undefined ? await reviveDurableProbe().catch(() => false) : false;
|
|
698
|
+
const resumableRevive = resumabilityClaim({
|
|
699
|
+
status,
|
|
700
|
+
...(stoppedByRevive !== undefined ? { stoppedBy: stoppedByRevive } : {}),
|
|
701
|
+
liveRetainPresent: ledger.get(deps.parentToolCallId) === entry,
|
|
702
|
+
liveRetainHasBudget: entry.resumeCount < SUBAGENT_RESUME_CAP,
|
|
703
|
+
durableRow: reviveDurableRow,
|
|
704
|
+
named: deps.rowName !== undefined,
|
|
705
|
+
});
|
|
685
706
|
const ownsTerminalFacesRevive = deps.registry !== undefined && deps.taskId !== undefined ? deps.registry.claimAgentTerminalNotify(deps.taskId) : true;
|
|
686
707
|
if (ownsTerminalFacesRevive)
|
|
687
708
|
reviveEmit?.({
|
|
@@ -696,7 +717,7 @@ export function createSubagentResume(deps) {
|
|
|
696
717
|
...(stoppedByRevive !== undefined ? { stoppedBy: stoppedByRevive } : {}),
|
|
697
718
|
summary: reviveTerminalSummary,
|
|
698
719
|
...resumeResidual(),
|
|
699
|
-
resumable:
|
|
720
|
+
resumable: resumableRevive,
|
|
700
721
|
usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
|
|
701
722
|
...(completionIdRevive !== undefined ? { completionId: completionIdRevive } : {}),
|
|
702
723
|
});
|
|
@@ -714,7 +735,7 @@ export function createSubagentResume(deps) {
|
|
|
714
735
|
...(resumeHandbackResult ? { result: notifyResultField(resumeHandbackResult) } : {}),
|
|
715
736
|
...(status === "killed" && child.result ? { partial: true } : {}),
|
|
716
737
|
...resumeResidual(),
|
|
717
|
-
resumable:
|
|
738
|
+
resumable: resumableRevive,
|
|
718
739
|
usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
|
|
719
740
|
...(completionIdRevive !== undefined ? { completionId: completionIdRevive } : {}),
|
|
720
741
|
};
|
|
@@ -1589,8 +1610,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1589
1610
|
return { ...(recentSteps ? { recentSteps } : {}), ...(editedFiles ? { editedFiles } : {}) };
|
|
1590
1611
|
};
|
|
1591
1612
|
const treeScope = reviveClaim?.row.scope ?? ctx.principal ?? opts.background?.scope;
|
|
1613
|
+
const provenanceRequest = ctx.delegationProvenanceForChildren?.();
|
|
1614
|
+
const childProvenanceRef = provenanceRequest !== undefined ? { current: newDelegationProvenanceAggregate() } : undefined;
|
|
1615
|
+
const childAttestation = (status) => childProvenanceRef !== undefined ? reduceDelegationAttestation(childProvenanceRef.current, { completed: status === "completed" }) : undefined;
|
|
1592
1616
|
const childInternals = {
|
|
1593
1617
|
...(inheritedManifestScope ? { inheritedManifestScope } : {}),
|
|
1618
|
+
...(childProvenanceRef !== undefined && provenanceRequest !== undefined ? { delegationProvenance: { ref: childProvenanceRef, contentSafety: provenanceRequest } } : {}),
|
|
1594
1619
|
...(treeScope !== undefined ? { registryScope: treeScope } : {}),
|
|
1595
1620
|
...(ctx.inheritedGateForChildren ? { inheritedGate: ctx.inheritedGateForChildren() } : {}),
|
|
1596
1621
|
ownOrgAdmissionRef: { current: reviveClaim !== undefined ? readDurableOrgAdmission(reviveClaim.row) : undefined },
|
|
@@ -2274,6 +2299,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2274
2299
|
details: completedAgentCard(forkChild, {
|
|
2275
2300
|
subagentType: FORK_SUBAGENT_TYPE,
|
|
2276
2301
|
error: forkErr,
|
|
2302
|
+
...(() => {
|
|
2303
|
+
const a = childAttestation(forkChild.status);
|
|
2304
|
+
return a !== undefined ? { attestation: a } : {};
|
|
2305
|
+
})(),
|
|
2277
2306
|
...(forkHandbackWarning !== undefined ? { handbackWarning: forkHandbackWarning } : {}),
|
|
2278
2307
|
...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
|
|
2279
2308
|
...(() => {
|
|
@@ -2795,7 +2824,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2795
2824
|
const completionIdBg = bg.registry.getCompletionId(taskId);
|
|
2796
2825
|
const observerNote = observerNoteFor(await startBoundedObserverDrain());
|
|
2797
2826
|
const residual = residualFields();
|
|
2798
|
-
const
|
|
2827
|
+
const bgDurableRow = await bgRowConfirmed();
|
|
2828
|
+
const resumableBg = resumabilityClaim({
|
|
2829
|
+
status: settled,
|
|
2830
|
+
...(stoppedBy !== undefined ? { stoppedBy } : {}),
|
|
2831
|
+
liveRetainPresent: settled !== "killed" && bgRetain !== undefined && bgRetainLedger?.get(ctx.toolCallId) === bgRetain,
|
|
2832
|
+
liveRetainHasBudget: bgRetain !== undefined && bgRetain.resumeCount < SUBAGENT_RESUME_CAP,
|
|
2833
|
+
durableRow: bgDurableRow,
|
|
2834
|
+
named: agentName !== undefined,
|
|
2835
|
+
});
|
|
2799
2836
|
const failReasonBg = settled === "failed" ? unparkedPauseReason ?? child.errorMessage : undefined;
|
|
2800
2837
|
const bgTerminalSummary = failReasonBg !== undefined
|
|
2801
2838
|
? `Agent "${shortDesc}" failed: ${failReasonBg}${ccElapsedTag(Date.now() - bgStartedAt)}${observerNote}`.slice(0, 300) + errorKindClause(errClassBg)
|
|
@@ -3106,6 +3143,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3106
3143
|
details: completedAgentCard(child, {
|
|
3107
3144
|
...(def !== undefined ? { subagentType: def.name } : {}),
|
|
3108
3145
|
error: errClass,
|
|
3146
|
+
...(() => {
|
|
3147
|
+
const a = childAttestation(child.status);
|
|
3148
|
+
return a !== undefined ? { attestation: a } : {};
|
|
3149
|
+
})(),
|
|
3109
3150
|
...(handbackWarning !== undefined ? { handbackWarning } : {}),
|
|
3110
3151
|
...(modelFallback !== undefined ? { modelFallback } : {}),
|
|
3111
3152
|
...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
|
package/dist/brain/errors.d.ts
CHANGED
|
@@ -45,6 +45,26 @@ export declare function extractErrorCode(errorMessage: string | undefined): Brai
|
|
|
45
45
|
* human text. Pairs with {@link extractErrorCode} so a caller can lift the code and clean the message
|
|
46
46
|
* without re-hardcoding the code list (the Runner does both when assembling `TaskResult`). */
|
|
47
47
|
export declare function stripErrorCodePrefix(errorMessage: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
|
|
50
|
+
* or gives no such verdict. Classification is by `code` along the error graph — never by message text,
|
|
51
|
+
* which providers and runtimes reword freely.
|
|
52
|
+
*
|
|
53
|
+
* The graph, not just the chain: undici nests the actionable error under `cause`, and a dual-stack
|
|
54
|
+
* connect nests one `AggregateError` whose `errors[]` hold the per-family results. A code outside the
|
|
55
|
+
* set ANYWHERE in the graph returns `"indeterminate"`, and every AGGREGATE LEG must reach a
|
|
56
|
+
* deterministic verdict of its own — so a hostname whose IPv4 leg is refused while its IPv6 leg timed
|
|
57
|
+
* out, or whose second leg says nothing this classifier can read, keeps the full ladder. Position
|
|
58
|
+
* decides how silence is read: a WRAPPER with no code (the `TypeError("fetch failed")` shell, an
|
|
59
|
+
* `AbortSignal.timeout` DOMException whose `code` is a number) is structural and defers to what it
|
|
60
|
+
* wraps, while a LEG with nothing readable is an outcome nobody accounted for and blocks the verdict.
|
|
61
|
+
*
|
|
62
|
+
* Fail-safe by construction — every uncertain outcome (empty aggregate, an unreadable leg, a graph
|
|
63
|
+
* larger than the node budget, no deterministic code found at all) answers `"indeterminate"`, i.e. the
|
|
64
|
+
* pre-existing behavior.
|
|
65
|
+
*/
|
|
66
|
+
export type ConnectFailureClass = "deterministic" | "indeterminate";
|
|
67
|
+
export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
|
|
48
68
|
/** RB-386③ ([2090]) — render a transport-level error WITHOUT folding away its cause chain. undici's
|
|
49
69
|
* `fetch` rejects with `TypeError("fetch failed", { cause })` where the CAUSE carries the actually
|
|
50
70
|
* actionable fact (`connect ECONNREFUSED …` / `getaddrinfo ENOTFOUND …` / `SocketError: other side
|
package/dist/brain/errors.js
CHANGED
|
@@ -61,6 +61,46 @@ function hasStaleConnectionCode(e) {
|
|
|
61
61
|
}
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
|
+
const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND"]);
|
|
65
|
+
const CONNECT_GRAPH_NODE_BUDGET = 16;
|
|
66
|
+
function walkConnectFailure(node, budget) {
|
|
67
|
+
if (budget.left <= 0)
|
|
68
|
+
return "indeterminate";
|
|
69
|
+
budget.left--;
|
|
70
|
+
if (node === undefined || node === null)
|
|
71
|
+
return "silent";
|
|
72
|
+
let verdict = "silent";
|
|
73
|
+
const code = node.code;
|
|
74
|
+
if (typeof code === "string") {
|
|
75
|
+
if (!DETERMINISTIC_CONNECT_CODES.has(code))
|
|
76
|
+
return "indeterminate";
|
|
77
|
+
verdict = "deterministic";
|
|
78
|
+
}
|
|
79
|
+
if (!(node instanceof Error))
|
|
80
|
+
return verdict;
|
|
81
|
+
const members = node.errors;
|
|
82
|
+
if (Array.isArray(members)) {
|
|
83
|
+
if (members.length === 0)
|
|
84
|
+
return "indeterminate";
|
|
85
|
+
for (const member of members) {
|
|
86
|
+
if (walkConnectFailure(member, budget) !== "deterministic")
|
|
87
|
+
return "indeterminate";
|
|
88
|
+
}
|
|
89
|
+
verdict = "deterministic";
|
|
90
|
+
}
|
|
91
|
+
const cause = node.cause;
|
|
92
|
+
if (cause !== undefined && cause !== null) {
|
|
93
|
+
const below = walkConnectFailure(cause, budget);
|
|
94
|
+
if (below === "indeterminate")
|
|
95
|
+
return "indeterminate";
|
|
96
|
+
if (below === "deterministic")
|
|
97
|
+
verdict = "deterministic";
|
|
98
|
+
}
|
|
99
|
+
return verdict;
|
|
100
|
+
}
|
|
101
|
+
export function classifyConnectFailure(e) {
|
|
102
|
+
return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
|
|
103
|
+
}
|
|
64
104
|
export function describeNetworkError(e) {
|
|
65
105
|
const top = e instanceof Error ? e.message : String(e);
|
|
66
106
|
const parts = [];
|
package/dist/brain/retry.d.ts
CHANGED
|
@@ -46,9 +46,23 @@ export interface ProviderWaitHint {
|
|
|
46
46
|
* ask as if nothing had been truncated at all, even though the pair's honored wait is identical either
|
|
47
47
|
* way. */
|
|
48
48
|
export declare function providerWaitHint(res: Response | undefined): ProviderWaitHint | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Ceiling on the exponential term for the SHORT lane that serves deterministic connect failures
|
|
51
|
+
* (`ECONNREFUSED` / a name with no address — see `errors.ts` `classifyConnectFailure`).
|
|
52
|
+
*
|
|
53
|
+
* That lane exists because waiting does not change the answer: the only thing that can is the peer
|
|
54
|
+
* itself coming up. So its ladder is sized to cover a service that is already mid-restart, not to
|
|
55
|
+
* out-wait an overloaded provider — which is what the 32s ceiling above is for. The two are separate
|
|
56
|
+
* constants rather than one parameter because they answer different questions, and collapsing them
|
|
57
|
+
* would make either lane's number look arbitrary.
|
|
58
|
+
*/
|
|
59
|
+
export declare const FAST_MAX_BACKOFF_MS = 1000;
|
|
49
60
|
/**
|
|
50
61
|
* Backoff (ms) for retry `attempt` (0-based) of a transient failure: an increasing capped exponential
|
|
51
62
|
* plus ≤25% additive jitter, floored by the response's provider wait hints (see the module note).
|
|
52
|
-
* `rand` is injectable for deterministic tests.
|
|
63
|
+
* `rand` is injectable for deterministic tests. `maxBackoffMs` overrides the exponential ceiling for a
|
|
64
|
+
* caller running a deliberately shorter lane; it bounds only the COMPUTED term — a provider wait hint
|
|
65
|
+
* still floors the result, because a lane choosing to be short is not a licence to ignore a server that
|
|
66
|
+
* said when to come back (and such a lane's failures carry no response to read a hint off anyway).
|
|
53
67
|
*/
|
|
54
|
-
export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number): number;
|
|
68
|
+
export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number, maxBackoffMs?: number): number;
|
package/dist/brain/retry.js
CHANGED
|
@@ -48,8 +48,9 @@ export function providerWaitHint(res) {
|
|
|
48
48
|
function providerWaitHintMs(res) {
|
|
49
49
|
return providerWaitHint(res)?.ms;
|
|
50
50
|
}
|
|
51
|
-
export
|
|
52
|
-
|
|
51
|
+
export const FAST_MAX_BACKOFF_MS = 1_000;
|
|
52
|
+
export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random, maxBackoffMs = MAX_BACKOFF_MS) {
|
|
53
|
+
const exp = Math.min(maxBackoffMs, baseDelayMs * 2 ** attempt);
|
|
53
54
|
const computed = Math.round(exp + rand() * JITTER_FRACTION * exp);
|
|
54
55
|
const hint = providerWaitHintMs(res);
|
|
55
56
|
return hint === undefined ? computed : Math.max(hint, computed);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BrainStatus } from "../core/types.js";
|
|
1
|
+
import type { BrainRetryErrClass, BrainStatus } from "../core/types.js";
|
|
2
2
|
/** Run `fn` with a per-task brain-status sink in scope. ALS propagates it through the async brain calls
|
|
3
3
|
* inside `fn` (the harness's prompt → brain.stream → connect/retry loop), so {@link emitBrainStatus}
|
|
4
4
|
* reaches THIS task's sink and nothing else. */
|
|
@@ -34,10 +34,17 @@ export type BrainTelemetry = {
|
|
|
34
34
|
key: string;
|
|
35
35
|
phase: "open" | "half-open" | "closed";
|
|
36
36
|
failures: number;
|
|
37
|
-
}
|
|
37
|
+
}
|
|
38
|
+
/** A retried provider call. `errClass` is the same closed bucket the user-facing status frame carries
|
|
39
|
+
* (so an operator reading traces and a user reading a progress line agree on why); `nextDelayMs` is
|
|
40
|
+
* the backoff actually about to be slept — 0 for the immediate re-sends that take no backoff. Both
|
|
41
|
+
* optional so a caller that has no verdict to report is not forced to invent one. */
|
|
42
|
+
| {
|
|
38
43
|
kind: "retry";
|
|
39
44
|
attempt: number;
|
|
40
45
|
phase: "connect" | "midstream";
|
|
46
|
+
errClass?: BrainRetryErrClass;
|
|
47
|
+
nextDelayMs?: number;
|
|
41
48
|
} | {
|
|
42
49
|
kind: "vision_placeholder";
|
|
43
50
|
count: number;
|
|
@@ -25,6 +25,21 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
|
|
|
25
25
|
maxRetries?: number;
|
|
26
26
|
/** First-retry backoff ms; each further attempt doubles it (capped, +≤25% jitter). Default 500. */
|
|
27
27
|
retryDelayMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Retries allowed for a DETERMINISTIC connect failure — one whose error codes say the target itself
|
|
30
|
+
* is not there (`ECONNREFUSED`, or a name with no address; see `errors.ts` `classifyConnectFailure`).
|
|
31
|
+
* Absent ⇒ {@link DEFAULT_DETERMINISTIC_CONNECT_RETRIES}.
|
|
32
|
+
*
|
|
33
|
+
* These retries exist for one case only: a peer that is mid-restart and about to accept. Waiting
|
|
34
|
+
* longer cannot change any other outcome — re-sending at an address where nothing listens gets the
|
|
35
|
+
* same refusal in milliseconds — so spending the full transient budget on it buys nothing and costs
|
|
36
|
+
* an unattended caller minutes of silence.
|
|
37
|
+
*
|
|
38
|
+
* The value is capped by {@link maxRetries} (this lane can only ever be the SHORTER of the two).
|
|
39
|
+
* Setting it AT or ABOVE `maxRetries` turns the lane off entirely: budget and backoff both revert to
|
|
40
|
+
* the shared transient ladder, which is the opt-out for a deployment that wants the old behavior.
|
|
41
|
+
*/
|
|
42
|
+
deterministicConnectRetries?: number;
|
|
28
43
|
}
|
|
29
44
|
/**
|
|
30
45
|
* Resolve the transient-failure retry budget — explicit config > `SEMA_MAX_RETRIES` env > default 10
|
|
@@ -32,6 +47,13 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
|
|
|
32
47
|
* value is ignored rather than silently meaning "zero retries"; an oversized one is clamped.
|
|
33
48
|
*/
|
|
34
49
|
export declare function resolveMaxRetries(configured: number | undefined): number;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the deterministic-connect retry budget. Same posture as {@link resolveMaxRetries}: a chosen
|
|
52
|
+
* NUMBER is honored, a non-finite or negative one falls back to the default rather than silently
|
|
53
|
+
* meaning something (a NaN here would compare false against every budget gate and disable the lane by
|
|
54
|
+
* accident, which is the sentinel-inversion shape CLS-A-8 pinned for the shared budget).
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveDeterministicConnectRetries(configured: number | undefined): number;
|
|
35
57
|
/**
|
|
36
58
|
* Is `rebuilt` still THE SAME REQUEST as `original`, differing only in the output cap and the values
|
|
37
59
|
* that follow from it (ruled 2026-08-05)?
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createAssistantMessageEventStream, } from "../internal/llm.js";
|
|
2
2
|
import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
|
|
3
|
-
import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
|
|
4
|
-
import { providerWaitHint, retryBackoffMs } from "./retry.js";
|
|
3
|
+
import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError } from "./errors.js";
|
|
4
|
+
import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
|
|
5
5
|
import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
|
|
6
6
|
import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
|
|
7
7
|
const DEFAULT_MAX_RETRIES = 10;
|
|
@@ -18,6 +18,12 @@ export function resolveMaxRetries(configured) {
|
|
|
18
18
|
}
|
|
19
19
|
return DEFAULT_MAX_RETRIES;
|
|
20
20
|
}
|
|
21
|
+
const DEFAULT_DETERMINISTIC_CONNECT_RETRIES = 2;
|
|
22
|
+
export function resolveDeterministicConnectRetries(configured) {
|
|
23
|
+
if (configured !== undefined && Number.isFinite(configured) && configured >= 0)
|
|
24
|
+
return Math.floor(configured);
|
|
25
|
+
return DEFAULT_DETERMINISTIC_CONNECT_RETRIES;
|
|
26
|
+
}
|
|
21
27
|
const SHOULD_RETRY_HEADER = "x-should-retry";
|
|
22
28
|
function sameHeaders(a, b) {
|
|
23
29
|
const aKeys = Object.keys(a);
|
|
@@ -272,6 +278,8 @@ export function runStreamingBrain(args) {
|
|
|
272
278
|
let maxOutputTokensOverride;
|
|
273
279
|
const maxRetries = resolveMaxRetries(config.maxRetries);
|
|
274
280
|
const baseDelay = config.retryDelayMs ?? 500;
|
|
281
|
+
const deterministicConnectBudget = Math.min(resolveDeterministicConnectRetries(config.deterministicConnectRetries), maxRetries);
|
|
282
|
+
const deterministicLaneActive = deterministicConnectBudget < maxRetries;
|
|
275
283
|
const firstTokenTimeoutMs = resolveStallTimeoutMs(config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs, "firstTokenTimeoutMs");
|
|
276
284
|
const idleTimeoutMs = resolveStallTimeoutMs(config.idleTimeoutMs ?? stallTimeouts?.idleMs, "idleTimeoutMs");
|
|
277
285
|
const connectTimeoutMs = resolveStallTimeoutMs(config.connectTimeoutMs ?? stallTimeouts?.connectMs, "connectTimeoutMs");
|
|
@@ -342,13 +350,14 @@ export function runStreamingBrain(args) {
|
|
|
342
350
|
maxOutputTokensOverride = plan.maxOutputTokens;
|
|
343
351
|
req = rebuilt;
|
|
344
352
|
req.onCommitted?.();
|
|
345
|
-
emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
|
|
353
|
+
emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass: "output_cap", nextDelayMs: 0 });
|
|
346
354
|
announcedRetry = true;
|
|
347
355
|
emitBrainStatus({
|
|
348
356
|
phase: "retrying",
|
|
349
357
|
detail: `output limit lowered from ${previousCap} to ${plan.maxOutputTokens} tokens, retrying`,
|
|
350
358
|
attempt: attempt + 1,
|
|
351
359
|
maxRetries,
|
|
360
|
+
errClass: "output_cap",
|
|
352
361
|
});
|
|
353
362
|
continue;
|
|
354
363
|
}
|
|
@@ -370,14 +379,33 @@ export function runStreamingBrain(args) {
|
|
|
370
379
|
const retryableByStatus = netErr !== undefined ||
|
|
371
380
|
(r !== undefined && (r.status >= 500 || r.status === 429 || r.status === 408 || r.status === 409));
|
|
372
381
|
const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
|
|
373
|
-
|
|
374
|
-
|
|
382
|
+
const deterministicConnect = netErr !== undefined && classifyConnectFailure(netErr) === "deterministic";
|
|
383
|
+
const fastLane = deterministicConnect && deterministicLaneActive;
|
|
384
|
+
const laneMaxRetries = fastLane ? deterministicConnectBudget : maxRetries;
|
|
385
|
+
const errClass = netErr !== undefined
|
|
386
|
+
? deterministicConnect
|
|
387
|
+
? "connect_refused"
|
|
388
|
+
: "transport"
|
|
389
|
+
: r?.status === 429
|
|
390
|
+
? "rate_limit"
|
|
391
|
+
: r !== undefined && (r.status >= 500 || r.status === 408 || r.status === 409)
|
|
392
|
+
? "server"
|
|
393
|
+
: "http";
|
|
394
|
+
if (retryable && attempt < laneMaxRetries) {
|
|
395
|
+
const delayMs = fastLane
|
|
396
|
+
? retryBackoffMs(baseDelay, attempt, r, Math.random, FAST_MAX_BACKOFF_MS)
|
|
397
|
+
: retryBackoffMs(baseDelay, attempt, r);
|
|
375
398
|
const waitHint = providerWaitHint(r);
|
|
376
399
|
const truncationNote = waitHint?.rawMs !== undefined
|
|
377
400
|
? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
|
|
378
401
|
: "";
|
|
379
402
|
const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
|
|
380
|
-
|
|
403
|
+
const reconnectDetail = errClass !== "connect_refused"
|
|
404
|
+
? "connection lost, reconnecting"
|
|
405
|
+
: fastLane
|
|
406
|
+
? "no endpoint answering, retrying briefly"
|
|
407
|
+
: "no endpoint answering, retrying";
|
|
408
|
+
emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass, nextDelayMs: delayMs });
|
|
381
409
|
announcedRetry = true;
|
|
382
410
|
discardResponseBody(r);
|
|
383
411
|
cc.abort();
|
|
@@ -387,12 +415,13 @@ export function runStreamingBrain(args) {
|
|
|
387
415
|
detail: statusPhase === "rate_limited"
|
|
388
416
|
? `rate limited, backing off${truncationNote}`
|
|
389
417
|
: statusPhase === "reconnecting"
|
|
390
|
-
?
|
|
418
|
+
? reconnectDetail
|
|
391
419
|
: `transient error, retrying${truncationNote}`,
|
|
392
420
|
retryInSec: Math.ceil(remainingMs / 1000),
|
|
393
421
|
retryInMs: remainingMs,
|
|
394
422
|
attempt: attempt + 1,
|
|
395
|
-
maxRetries,
|
|
423
|
+
maxRetries: laneMaxRetries,
|
|
424
|
+
errClass,
|
|
396
425
|
}));
|
|
397
426
|
continue;
|
|
398
427
|
}
|
|
@@ -561,7 +590,7 @@ export function runStreamingBrain(args) {
|
|
|
561
590
|
thinkingRetries++;
|
|
562
591
|
parser.sealForRetry();
|
|
563
592
|
const delayMs = 100 * thinkingRetries;
|
|
564
|
-
emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream" });
|
|
593
|
+
emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
|
|
565
594
|
announcedRetry = true;
|
|
566
595
|
await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
|
|
567
596
|
phase: "reconnecting",
|
|
@@ -570,6 +599,7 @@ export function runStreamingBrain(args) {
|
|
|
570
599
|
retryInMs: remainingMs,
|
|
571
600
|
attempt: thinkingRetries,
|
|
572
601
|
maxRetries: THINKING_RETRY_BUDGET,
|
|
602
|
+
errClass: "transport",
|
|
573
603
|
}));
|
|
574
604
|
continue;
|
|
575
605
|
}
|
|
@@ -577,7 +607,7 @@ export function runStreamingBrain(args) {
|
|
|
577
607
|
throw failure.err;
|
|
578
608
|
const delayMs = retryBackoffMs(baseDelay, attempt);
|
|
579
609
|
attempt++;
|
|
580
|
-
emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream" });
|
|
610
|
+
emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
|
|
581
611
|
announcedRetry = true;
|
|
582
612
|
await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
|
|
583
613
|
phase: "reconnecting",
|
|
@@ -586,6 +616,7 @@ export function runStreamingBrain(args) {
|
|
|
586
616
|
retryInMs: remainingMs,
|
|
587
617
|
attempt,
|
|
588
618
|
maxRetries,
|
|
619
|
+
errClass: "transport",
|
|
589
620
|
}));
|
|
590
621
|
}
|
|
591
622
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-012 L2 — the ENGINE-INTERNAL ask-class envelope.
|
|
3
|
+
*
|
|
4
|
+
* An `ask` verdict's SEMANTIC CLASS decides whether a deployment-declared sandbox may auto-admit it
|
|
5
|
+
* (clay's ruling: a declared sandbox admits everything inside it — the boundary crossings park to a
|
|
6
|
+
* human, escape prevention belongs to the sandbox, not to engine re-policing). The class deliberately
|
|
7
|
+
* lives OUTSIDE the public `PermissionResult`: a decision object is caller-supplied data, and a
|
|
8
|
+
* self-reported "this ask is sandbox-local" field would be the same trust hole as the
|
|
9
|
+
* `decisionReason:"safety"` self-declaration this codebase already refuses. Instead, BUILT-IN policy
|
|
10
|
+
* factories register their instances here (private WeakMap brand — nothing a deployment policy can
|
|
11
|
+
* forge by shaping its return value), and everything unregistered reads as `unknown`.
|
|
12
|
+
*
|
|
13
|
+
* Classes and their dominance (STRICT — merge keeps the strongest; only an all-`sandbox_local` set
|
|
14
|
+
* may auto-admit):
|
|
15
|
+
* - `sandbox_local` — an engine-authored "confirm a potentially destructive LOCAL action" ask
|
|
16
|
+
* (name/pattern confirmation lists). Sandbox containment answers it.
|
|
17
|
+
* - `external_authority` — an ask whose whole point is real judgment (the safety-rule policies with
|
|
18
|
+
* `requiresRealApproval`); containment answers nothing about it.
|
|
19
|
+
* - `hook` — a screening-face ask (a deployment's PreToolUse verdict folded to policy
|
|
20
|
+
* form); the face's authority is the deployment's, not the sandbox's.
|
|
21
|
+
* - `mandate` — an ask bound to durable-park semantics (inherited durable mandate /
|
|
22
|
+
* approver-unavailable float); it must reach the park untouched.
|
|
23
|
+
* - `unknown` — everything unregistered, every deployment policy. Fail-closed: never
|
|
24
|
+
* admitted.
|
|
25
|
+
*/
|
|
26
|
+
export type AskClass = "sandbox_local" | "external_authority" | "hook" | "mandate" | "unknown";
|
|
27
|
+
/**
|
|
28
|
+
* Brand a built-in policy instance with its ask class (engine factories only — not exported from the
|
|
29
|
+
* package). The `check` binding is LOCKED as a non-writable, non-configurable DATA property before the
|
|
30
|
+
* policy is returned (codex adversarial round 4, HIGH): reading `policy.check` and INVOKING it must
|
|
31
|
+
* name the same callable, atomically. A plain `===` re-read was TOCTOU-able by an accessor — a getter
|
|
32
|
+
* could return the branded function to `policyAskClassOf` and a deployment ask to the gate's
|
|
33
|
+
* invocation. Locking the property makes the callable un-swappable and un-redefinable, so the class
|
|
34
|
+
* travels with the exact decision function. An `check` that is ALREADY accessor-backed (or a frozen
|
|
35
|
+
* object we cannot lock) is refused branding — it reads back `unknown` (fail-closed), never trusted.
|
|
36
|
+
*/
|
|
37
|
+
export declare function brandPolicyAskClass<T extends {
|
|
38
|
+
check: unknown;
|
|
39
|
+
}>(policy: T, cls: AskClass): T;
|
|
40
|
+
/** The registered class of a policy instance — `unknown` for anything unregistered, whose `check` is
|
|
41
|
+
* no longer the locked data value, or whose `check` became accessor-backed (fail-closed: a
|
|
42
|
+
* replaced/decorated/getter-shadowed decision function is not the branded one). Reads the property
|
|
43
|
+
* DESCRIPTOR's value (never invokes a getter), so the check is atomic with what the gate will call. */
|
|
44
|
+
export declare function policyAskClassOf(policy: (object & {
|
|
45
|
+
check?: unknown;
|
|
46
|
+
}) | null | undefined): AskClass;
|
|
47
|
+
/** The strongest class in a set (strict dominance — any non-`sandbox_local` member dominates). */
|
|
48
|
+
export declare function dominantAskClass(classes: readonly AskClass[]): AskClass;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const RANK = { sandbox_local: 0, external_authority: 1, hook: 2, mandate: 3, unknown: 4 };
|
|
2
|
+
const registry = new WeakMap();
|
|
3
|
+
export function brandPolicyAskClass(policy, cls) {
|
|
4
|
+
const desc = Object.getOwnPropertyDescriptor(policy, "check");
|
|
5
|
+
if (desc === undefined || desc.get !== undefined || desc.set !== undefined)
|
|
6
|
+
return policy;
|
|
7
|
+
try {
|
|
8
|
+
Object.defineProperty(policy, "check", { value: policy.check, writable: false, configurable: false, enumerable: desc.enumerable ?? true });
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return policy;
|
|
12
|
+
}
|
|
13
|
+
registry.set(policy, { cls, check: policy.check });
|
|
14
|
+
return policy;
|
|
15
|
+
}
|
|
16
|
+
export function policyAskClassOf(policy) {
|
|
17
|
+
if (policy === null || policy === undefined)
|
|
18
|
+
return "unknown";
|
|
19
|
+
const entry = registry.get(policy);
|
|
20
|
+
if (entry === undefined)
|
|
21
|
+
return "unknown";
|
|
22
|
+
const desc = Object.getOwnPropertyDescriptor(policy, "check");
|
|
23
|
+
if (desc === undefined || desc.get !== undefined || desc.set !== undefined)
|
|
24
|
+
return "unknown";
|
|
25
|
+
return desc.value === entry.check ? entry.cls : "unknown";
|
|
26
|
+
}
|
|
27
|
+
export function dominantAskClass(classes) {
|
|
28
|
+
let strongest = "sandbox_local";
|
|
29
|
+
for (const c of classes)
|
|
30
|
+
if (RANK[c] > RANK[strongest])
|
|
31
|
+
strongest = c;
|
|
32
|
+
return strongest;
|
|
33
|
+
}
|