@sema-agent/core 7.26.2 → 7.27.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 +61 -0
- package/dist/core/auto-mode-directive.d.ts +6 -4
- package/dist/core/auto-mode-directive.js +1 -5
- package/dist/core/engine-notice.d.ts +13 -6
- package/dist/core/runner/contracts.d.ts +31 -7
- package/dist/core/runner/denial-limit-arms.d.ts +17 -15
- package/dist/core/runner/denial-limit-arms.js +4 -1
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -0
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +1 -2
- package/dist/core/runner/prepare-inherited-gate.d.ts +1 -2
- package/dist/core/runner/prepare-policy-chain.d.ts +1 -2
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/run-leg.js +5 -1
- package/dist/core/tool-policy.d.ts +23 -2
- package/dist/core/tool-policy.js +106 -103
- package/dist/index.d.ts +6 -1
- package/dist/stores/file/background-agent-store.d.ts +4 -0
- package/dist/stores/file/background-agent-store.js +10 -0
- package/dist/stores/file/checkpoint-store.d.ts +3 -0
- package/dist/stores/file/checkpoint-store.js +18 -0
- package/dist/stores/file/corrupt-read-sink.d.ts +20 -0
- package/dist/stores/file/corrupt-read-sink.js +15 -0
- package/dist/stores/file/file-history-store.d.ts +1 -2
- package/dist/stores/file/file-history-store.js +3 -8
- package/dist/stores/file/fs-atomic.d.ts +8 -3
- package/dist/stores/file/fs-atomic.js +19 -2
- package/dist/stores/file/index.js +4 -4
- package/dist/stores/file/mailbox-store.d.ts +3 -0
- package/dist/stores/file/mailbox-store.js +24 -18
- package/dist/stores/file/permission-rule-store.js +18 -1
- package/dist/stores/file/session-policy-store.d.ts +17 -1
- package/dist/stores/file/session-policy-store.js +3 -12
- package/dist/stores/file/session-store.d.ts +1 -5
- package/dist/stores/file/session-store.js +9 -14
- package/dist/stores/file/shared-ledger.d.ts +18 -0
- package/dist/stores/file/shared-ledger.js +16 -2
- package/dist/stores/file/tool-result-store.d.ts +9 -1
- package/dist/stores/file/tool-result-store.js +17 -2
- package/dist/stores/file/workflow-journal-store.d.ts +10 -1
- package/dist/stores/file/workflow-journal-store.js +8 -12
- package/dist/stores/file/workflow-run-store.d.ts +3 -0
- package/dist/stores/file/workflow-run-store.js +10 -0
- package/dist/tools/artifact/local-stub.js +16 -1
- package/dist/tools/fs/dead-cwd.d.ts +48 -0
- package/dist/tools/fs/dead-cwd.js +56 -0
- package/dist/tools/fs/fs-bash.js +4 -32
- package/dist/tools/monitor.d.ts +4 -0
- package/dist/tools/monitor.js +6 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +3 -1
- package/wire-changes/core@7.26.3.json +65 -0
- package/wire-changes/core@7.27.0.json +152 -0
package/dist/core/tool-policy.js
CHANGED
|
@@ -414,11 +414,31 @@ export function createApprovalPolicy(opts) {
|
|
|
414
414
|
if (signal?.aborted) {
|
|
415
415
|
return attest({ action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)` }, settledByNobody("task_aborted"));
|
|
416
416
|
}
|
|
417
|
+
if (typeof ok === "object" && ok !== null) {
|
|
418
|
+
const reading = readApproverOutcomeObject(ok, req.toolName, "none");
|
|
419
|
+
if (reading.kind === "refused")
|
|
420
|
+
return attest({ action: "deny", message: reading.message }, reading.settlement);
|
|
421
|
+
if (reading.kind === "deny") {
|
|
422
|
+
return attest({
|
|
423
|
+
action: "deny",
|
|
424
|
+
message: reading.word === "policy"
|
|
425
|
+
? policyRefusalMessage(req.toolName, undefined, reading.note)
|
|
426
|
+
: reading.word === "timeout"
|
|
427
|
+
? `no one answered the approval request for "${req.toolName}" — the approver reported its own window elapsed with no answer; denied fail-closed${reading.note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", reading.note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
|
|
428
|
+
: `approval denied for "${req.toolName}"${reading.note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", reading.note, REVIEWER_NOTE_MAX_BODY)}` : ""}`,
|
|
429
|
+
}, reading.settlement);
|
|
430
|
+
}
|
|
431
|
+
return attest({ action: "allow" }, reading.settlement);
|
|
432
|
+
}
|
|
417
433
|
const okRaw = ok;
|
|
418
434
|
if (okRaw === true)
|
|
419
435
|
return attest({ action: "allow" }, settledByPerson("human_allowed", undefined));
|
|
420
436
|
if (okRaw !== false) {
|
|
421
|
-
return attest({
|
|
437
|
+
return attest({
|
|
438
|
+
action: "deny",
|
|
439
|
+
message: `approval callback for "${req.toolName}" returned an out-of-contract value (${typeof ok}) — denied fail-closed ` +
|
|
440
|
+
`(this policy's \`approve\` returns true, false, or the {allow, settledBy?, reason?, approver?} outcome object)`,
|
|
441
|
+
}, settledByNobody("approver_contract"));
|
|
422
442
|
}
|
|
423
443
|
return attest({ action: "deny", message: `approval denied for "${req.toolName}"` }, settledByPerson("human_refused", undefined));
|
|
424
444
|
}
|
|
@@ -1134,6 +1154,78 @@ function detachLateAskWait(wait, onLate) {
|
|
|
1134
1154
|
})
|
|
1135
1155
|
.catch(() => undefined);
|
|
1136
1156
|
}
|
|
1157
|
+
function readApproverOutcomeObject(ok, toolName, editLane) {
|
|
1158
|
+
const refused = (message, settlement) => ({ kind: "refused", message, settlement });
|
|
1159
|
+
let supplied;
|
|
1160
|
+
let allowed;
|
|
1161
|
+
let suppliedEdit;
|
|
1162
|
+
let suppliedApprover;
|
|
1163
|
+
try {
|
|
1164
|
+
supplied = ok.settledBy;
|
|
1165
|
+
allowed = ok.allow;
|
|
1166
|
+
suppliedEdit = ok.updatedInput;
|
|
1167
|
+
suppliedApprover = ok.approver;
|
|
1168
|
+
}
|
|
1169
|
+
catch (err) {
|
|
1170
|
+
return refused(`the approver's outcome for "${toolName}" could not be read (${describeThrown(err)}) — denied fail-closed`, settledByNobody("approver_contract"));
|
|
1171
|
+
}
|
|
1172
|
+
if (typeof allowed !== "boolean") {
|
|
1173
|
+
return refused(`the approver for "${toolName}" returned an object whose allow is not a boolean ` +
|
|
1174
|
+
`(got ${allowed === null ? "null" : typeof allowed}) — a verdict is exactly true or false; denied fail-closed`, settledByNobody("approver_contract"));
|
|
1175
|
+
}
|
|
1176
|
+
if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
|
|
1177
|
+
return refused(`the approver for "${toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
|
|
1178
|
+
`approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`, settledByNobody("approver_contract"));
|
|
1179
|
+
}
|
|
1180
|
+
const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
|
|
1181
|
+
const attribution = screenApproverAttribution(suppliedApprover);
|
|
1182
|
+
if (attribution.defect !== undefined) {
|
|
1183
|
+
return refused(`the approver for "${toolName}" reported an attribution this seam refuses: ${attribution.defect}; denied fail-closed`, settledByNobody("approver_contract"));
|
|
1184
|
+
}
|
|
1185
|
+
if (settledWord !== "human" && allowed === true) {
|
|
1186
|
+
return refused(`the approver for "${toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
|
|
1187
|
+
`names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
|
|
1188
|
+
`(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`, settledByNobody("approver_contract"));
|
|
1189
|
+
}
|
|
1190
|
+
if (allowed !== true) {
|
|
1191
|
+
let reasonText;
|
|
1192
|
+
try {
|
|
1193
|
+
const suppliedReason = ok.reason;
|
|
1194
|
+
if (suppliedReason !== undefined && typeof suppliedReason !== "string") {
|
|
1195
|
+
return refused(`the approver for "${toolName}" attached a reason that is not a plain string ` +
|
|
1196
|
+
`(got ${suppliedReason === null ? "null" : typeof suppliedReason}) — a deny note is the decider's plain text; denied fail-closed`, settledByNobody("approver_contract"));
|
|
1197
|
+
}
|
|
1198
|
+
reasonText = typeof suppliedReason === "string" ? suppliedReason : undefined;
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
return refused(`the approver for "${toolName}" attached a reason that could not be read (${describeThrown(err)}) — denied fail-closed`, settledByNobody("approver_contract"));
|
|
1202
|
+
}
|
|
1203
|
+
const note = decidersNote(reasonText);
|
|
1204
|
+
return {
|
|
1205
|
+
kind: "deny",
|
|
1206
|
+
word: settledWord,
|
|
1207
|
+
note,
|
|
1208
|
+
settlement: settledWord === "timeout"
|
|
1209
|
+
? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
|
|
1210
|
+
: settledWord === "policy"
|
|
1211
|
+
? settledByNobody("policy_refused")
|
|
1212
|
+
: settledByPerson("human_refused", attribution.approver, note),
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if (suppliedEdit === undefined)
|
|
1216
|
+
return { kind: "allow", edit: undefined, settlement: settledByPerson("human_allowed", attribution.approver) };
|
|
1217
|
+
if (editLane === "none") {
|
|
1218
|
+
return refused(`the approver for "${toolName}" attached an edit (updatedInput) on a seat that has no lane to adjudicate one — ` +
|
|
1219
|
+
`this seat's answer is the deployment POLICY's verdict, and an edit arriving through it would execute without the ` +
|
|
1220
|
+
`re-adjudication an approver's edit gets; denied fail-closed (approve or refuse what was shown, and route edits ` +
|
|
1221
|
+
`through the run's approver seat)`, settledByNobody("approver_contract"));
|
|
1222
|
+
}
|
|
1223
|
+
const edit = tryCloneArgs(suppliedEdit);
|
|
1224
|
+
if (!edit.ok) {
|
|
1225
|
+
return refused(`the approved edit for "${toolName}" is not safely clonable (${edit.reason}) — denied fail-closed`, settledByNobody("presentation_failed"));
|
|
1226
|
+
}
|
|
1227
|
+
return { kind: "allow", edit: { value: edit.value }, settlement: settledByPerson("human_allowed", attribution.approver) };
|
|
1228
|
+
}
|
|
1137
1229
|
async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
|
|
1138
1230
|
if (onAsk === "allow") {
|
|
1139
1231
|
if (req.requiresRealApproval === true) {
|
|
@@ -1229,113 +1321,24 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
|
|
|
1229
1321
|
};
|
|
1230
1322
|
}
|
|
1231
1323
|
if (typeof ok === "object" && ok !== null) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
try {
|
|
1237
|
-
supplied = ok.settledBy;
|
|
1238
|
-
allowed = ok.allow;
|
|
1239
|
-
suppliedEdit = ok.updatedInput;
|
|
1240
|
-
suppliedApprover = ok.approver;
|
|
1241
|
-
}
|
|
1242
|
-
catch (err) {
|
|
1243
|
-
return {
|
|
1244
|
-
action: "deny",
|
|
1245
|
-
message: `the approver's outcome for "${req.toolName}" could not be read (${describeThrown(err)}) — denied fail-closed`,
|
|
1246
|
-
decisionReason: "mode",
|
|
1247
|
-
settlement: settledByNobody("approver_contract"),
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
if (typeof allowed !== "boolean") {
|
|
1324
|
+
const reading = readApproverOutcomeObject(ok, req.toolName, "readjudicated");
|
|
1325
|
+
if (reading.kind === "refused")
|
|
1326
|
+
return { action: "deny", message: reading.message, decisionReason: "mode", settlement: reading.settlement };
|
|
1327
|
+
if (reading.kind === "deny") {
|
|
1251
1328
|
return {
|
|
1252
1329
|
action: "deny",
|
|
1253
|
-
message:
|
|
1254
|
-
`
|
|
1330
|
+
message: reading.word === "timeout"
|
|
1331
|
+
? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${reading.note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", reading.note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
|
|
1332
|
+
: reading.word === "policy"
|
|
1333
|
+
? policyRefusalMessage(req.toolName, req.message, reading.note)
|
|
1334
|
+
: humanRefusalMessage(req, reading.note),
|
|
1255
1335
|
decisionReason: "mode",
|
|
1256
|
-
settlement:
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1259
|
-
if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
|
|
1260
|
-
return {
|
|
1261
|
-
action: "deny",
|
|
1262
|
-
message: `the approver for "${req.toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
|
|
1263
|
-
`approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`,
|
|
1264
|
-
decisionReason: "mode",
|
|
1265
|
-
settlement: settledByNobody("approver_contract"),
|
|
1266
|
-
};
|
|
1267
|
-
}
|
|
1268
|
-
const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
|
|
1269
|
-
const attribution = screenApproverAttribution(suppliedApprover);
|
|
1270
|
-
if (attribution.defect !== undefined) {
|
|
1271
|
-
return {
|
|
1272
|
-
action: "deny",
|
|
1273
|
-
message: `the approver for "${req.toolName}" reported an attribution this seam refuses: ${attribution.defect}; denied fail-closed`,
|
|
1274
|
-
decisionReason: "mode",
|
|
1275
|
-
settlement: settledByNobody("approver_contract"),
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
if (settledWord !== "human" && allowed === true) {
|
|
1279
|
-
return {
|
|
1280
|
-
action: "deny",
|
|
1281
|
-
message: `the approver for "${req.toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
|
|
1282
|
-
`names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
|
|
1283
|
-
`(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`,
|
|
1284
|
-
decisionReason: "mode",
|
|
1285
|
-
settlement: settledByNobody("approver_contract"),
|
|
1286
|
-
};
|
|
1287
|
-
}
|
|
1288
|
-
if (allowed !== true) {
|
|
1289
|
-
let reasonText;
|
|
1290
|
-
try {
|
|
1291
|
-
const suppliedReason = ok.reason;
|
|
1292
|
-
if (suppliedReason !== undefined && typeof suppliedReason !== "string") {
|
|
1293
|
-
return {
|
|
1294
|
-
action: "deny",
|
|
1295
|
-
message: `the approver for "${req.toolName}" attached a reason that is not a plain string ` +
|
|
1296
|
-
`(got ${suppliedReason === null ? "null" : typeof suppliedReason}) — a deny note is the decider's plain text; denied fail-closed`,
|
|
1297
|
-
decisionReason: "mode",
|
|
1298
|
-
settlement: settledByNobody("approver_contract"),
|
|
1299
|
-
};
|
|
1300
|
-
}
|
|
1301
|
-
reasonText = typeof suppliedReason === "string" ? suppliedReason : undefined;
|
|
1302
|
-
}
|
|
1303
|
-
catch (err) {
|
|
1304
|
-
return {
|
|
1305
|
-
action: "deny",
|
|
1306
|
-
message: `the approver for "${req.toolName}" attached a reason that could not be read (${describeThrown(err)}) — denied fail-closed`,
|
|
1307
|
-
decisionReason: "mode",
|
|
1308
|
-
settlement: settledByNobody("approver_contract"),
|
|
1309
|
-
};
|
|
1310
|
-
}
|
|
1311
|
-
const note = decidersNote(reasonText);
|
|
1312
|
-
return {
|
|
1313
|
-
action: "deny",
|
|
1314
|
-
message: settledWord === "timeout"
|
|
1315
|
-
? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
|
|
1316
|
-
: settledWord === "policy"
|
|
1317
|
-
? policyRefusalMessage(req.toolName, req.message, note)
|
|
1318
|
-
: humanRefusalMessage(req, note),
|
|
1319
|
-
decisionReason: "mode",
|
|
1320
|
-
settlement: settledWord === "timeout"
|
|
1321
|
-
? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
|
|
1322
|
-
: settledWord === "policy"
|
|
1323
|
-
? settledByNobody("policy_refused")
|
|
1324
|
-
: settledByPerson("human_refused", attribution.approver, note),
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
if (suppliedEdit === undefined)
|
|
1328
|
-
return { action: "allow", decisionReason: "mode", presentedInput: presented.value, settlement: settledByPerson("human_allowed", attribution.approver) };
|
|
1329
|
-
const edit = tryCloneArgs(suppliedEdit);
|
|
1330
|
-
if (!edit.ok) {
|
|
1331
|
-
return {
|
|
1332
|
-
action: "deny",
|
|
1333
|
-
message: `the approved edit for "${req.toolName}" is not safely clonable (${edit.reason}) — denied fail-closed`,
|
|
1334
|
-
decisionReason: "mode",
|
|
1335
|
-
settlement: settledByNobody("presentation_failed"),
|
|
1336
|
+
settlement: reading.settlement,
|
|
1336
1337
|
};
|
|
1337
1338
|
}
|
|
1338
|
-
|
|
1339
|
+
if (reading.edit === undefined)
|
|
1340
|
+
return { action: "allow", decisionReason: "mode", presentedInput: presented.value, settlement: reading.settlement };
|
|
1341
|
+
return { action: "allow", updatedInput: reading.edit.value, decisionReason: "mode", settlement: reading.settlement };
|
|
1339
1342
|
}
|
|
1340
1343
|
const okRaw = ok;
|
|
1341
1344
|
if (okRaw === true)
|
package/dist/index.d.ts
CHANGED
|
@@ -177,7 +177,12 @@ export type { SchedulerCapability, SchedulerErrorCode, ScheduledIntent, Schedule
|
|
|
177
177
|
export { createSchedulerTools, type SchedulerToolContext, SCHEDULE_WAKEUP_TOOL_NAME, AUTONOMOUS_LOOP_SENTINEL, AUTONOMOUS_LOOP_DYNAMIC_SENTINEL, } from "./tools/scheduler-tools.js";
|
|
178
178
|
export { resolveAutonomousLoopPrompt, AUTONOMOUS_LOOP_PREAMBLE, AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT, type AutonomousLoopPromptOptions, } from "./tools/loop-tick.js";
|
|
179
179
|
export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.js";
|
|
180
|
-
export { createAllowDenyPolicy, createApprovalPolicy,
|
|
180
|
+
export { createAllowDenyPolicy, createApprovalPolicy,
|
|
181
|
+
/** The return type of `createApprovalPolicy.approve` — a boolean, or the `AskOutcome` object minus the two
|
|
182
|
+
* members that seat has no lane for (`"unavailable"`, a routing answer; `updatedInput`, an operator edit a
|
|
183
|
+
* policy verdict cannot get re-adjudicated). Exported so a host can NAME it instead of deriving it through
|
|
184
|
+
* `Parameters<typeof createApprovalPolicy>[0]["approve"]`. */
|
|
185
|
+
type ApprovalSeatOutcome, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
|
|
181
186
|
export { type AskOrigin, ASK_ORIGINS, isAskOrigin, classifierMayAnswer, ORIGIN_IMPLIES_REAL_APPROVAL, RULE_STORE_UNREADABLE_KINDS, type RuleStoreUnreadable, isRuleStoreUnreadable } from "./core/ask-origin.js";
|
|
182
187
|
export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource, type AutoModeDenySource } from "./core/runtime-caps.js";
|
|
183
188
|
export { SETTLEMENT_KINDS, type SettlementKind, isSettlementKind, type Settlement, SETTLEMENT_IS_REFUSAL, DENIED_BY_VALUES, type DeniedBy, isDeniedBy, DENIED_BY_MAY_VETO, CLASSIFIER_DENY_CAUSES, type ClassifierDenyCause, isClassifierDenyCause, DENIED_BY_MAY_CARRY_CAUSE, type GateDisposition, type GateOutcome, screenGateOutcome } from "./core/gate-outcome.js";
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type BackgroundAgentReapOptions, type BackgroundAgentRecord, type BackgroundAgentRowSummary, type BackgroundAgentStore, type BackgroundAgentUpdateIfExpect } from "../../core/background-agent-store.js";
|
|
2
|
+
import { type CorruptReadInfo } from "./corrupt-read-sink.js";
|
|
2
3
|
export interface FileBackgroundAgentStoreOptions {
|
|
4
|
+
/** This store's half of {@link import("./shared-ledger.js").LedgerPaths.onCorruptRead} — the same seat,
|
|
5
|
+
* over the agent row model. */
|
|
6
|
+
onCorruptRead?: (info: CorruptReadInfo) => void;
|
|
3
7
|
/** When false, a ledger append is NOT fsync'd. Default true. */
|
|
4
8
|
fsync?: boolean;
|
|
5
9
|
/** Compact the ledger into a snapshot once it exceeds this many events (then truncate). Default 1000. */
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, assertBackgroundAgentReapOptions, queryBackgroundAgents, } from "../../core/background-agent-store.js";
|
|
3
3
|
import { SharedLedgerTable } from "./shared-ledger.js";
|
|
4
|
+
import { guardCorruptSink } from "./corrupt-read-sink.js";
|
|
4
5
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
6
|
+
const isAgentRow = (x) => x !== null && typeof x === "object" && typeof x.handle === "string" && typeof x.scope === "string";
|
|
5
7
|
const agentLedgers = new SharedLedgerTable({
|
|
6
8
|
label: "background-agent ledger",
|
|
7
9
|
keyOf: (r) => FileBackgroundAgentStore.key(r.handle, r.scope),
|
|
@@ -12,6 +14,13 @@ const agentLedgers = new SharedLedgerTable({
|
|
|
12
14
|
}
|
|
13
15
|
rows.set(ev.k, ev.r);
|
|
14
16
|
},
|
|
17
|
+
judgeRow: isAgentRow,
|
|
18
|
+
judgeEvent: (x) => {
|
|
19
|
+
const ev = x;
|
|
20
|
+
if (typeof ev.k !== "string")
|
|
21
|
+
return false;
|
|
22
|
+
return ev.t === "delete" ? true : isAgentRow(ev.r);
|
|
23
|
+
},
|
|
15
24
|
});
|
|
16
25
|
export class FileBackgroundAgentStore {
|
|
17
26
|
fsyncEnabled;
|
|
@@ -29,6 +38,7 @@ export class FileBackgroundAgentStore {
|
|
|
29
38
|
this.ledger = agentLedgers.acquire({
|
|
30
39
|
dir,
|
|
31
40
|
tmpDir: join(root, "tmp"),
|
|
41
|
+
...(opts.onCorruptRead !== undefined ? { onCorruptRead: guardCorruptSink(opts.onCorruptRead) } : {}),
|
|
32
42
|
ledgerPath: join(dir, "agents.jsonl"),
|
|
33
43
|
snapshotPath: join(dir, "agents.snapshot.jsonl"),
|
|
34
44
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type ExecutionOutcomeRecordWord } from "../../core/checkpoint-execution-record.js";
|
|
2
2
|
import { type Checkpoint, type PendingSteerInput, type CheckpointFaultMode, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type ReopenReason, type ResolveExpectation, type ResumeOutcome } from "../../core/checkpoint-store.js";
|
|
3
3
|
import type { GateOutcome } from "../../core/gate-outcome.js";
|
|
4
|
+
import { type CorruptReadInfo } from "./corrupt-read-sink.js";
|
|
4
5
|
export interface FileCheckpointStoreOptions {
|
|
6
|
+
/** This store's half of {@link import("./shared-ledger.js").LedgerPaths.onCorruptRead}. */
|
|
7
|
+
onCorruptRead?: (info: CorruptReadInfo) => void;
|
|
5
8
|
/** When false, an `appendLine` for a state transition is NOT fsync'd. The checkpoint COMMIT POINT always
|
|
6
9
|
* fsyncs regardless (its crash-safety depends on it); this governs the `put` and `setPendingSteer`
|
|
7
10
|
* (steer-append) commits only — `resolve`/`reopen`/`expire` fsync unconditionally. Default true. */
|
|
@@ -3,6 +3,7 @@ import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
|
3
3
|
import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "../../core/checkpoint-execution-record.js";
|
|
4
4
|
import { appendPendingSteer, CheckpointError, checkpointOccMatches, checkpointRowMatches, summarizeCheckpoint, validatePendingSteer, winnerFromOutcome, } from "../../core/checkpoint-store.js";
|
|
5
5
|
import { SharedLedgerTable } from "./shared-ledger.js";
|
|
6
|
+
import { guardCorruptSink } from "./corrupt-read-sink.js";
|
|
6
7
|
const CHECKPOINT_LEDGER_EVENT_REGISTRY = {
|
|
7
8
|
put: true,
|
|
8
9
|
resolve: true,
|
|
@@ -57,10 +58,26 @@ function applyCheckpointEvent(cps, ev) {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
}
|
|
61
|
+
const isCheckpointRow = (x) => x !== null &&
|
|
62
|
+
typeof x === "object" &&
|
|
63
|
+
typeof x.token === "string" &&
|
|
64
|
+
typeof x.scope === "string" &&
|
|
65
|
+
typeof x.status === "string";
|
|
60
66
|
const checkpointLedgers = new SharedLedgerTable({
|
|
61
67
|
label: "checkpoint ledger",
|
|
62
68
|
keyOf: (cp) => cp.token,
|
|
63
69
|
apply: applyCheckpointEvent,
|
|
70
|
+
judgeRow: isCheckpointRow,
|
|
71
|
+
judgeEvent: (x) => {
|
|
72
|
+
const ev = x;
|
|
73
|
+
if (typeof ev.t !== "string")
|
|
74
|
+
return false;
|
|
75
|
+
if (!KNOWN_CHECKPOINT_LEDGER_EVENTS.has(ev.t))
|
|
76
|
+
return true;
|
|
77
|
+
if (typeof ev.token !== "string")
|
|
78
|
+
return false;
|
|
79
|
+
return ev.t === "put" ? isCheckpointRow(ev.cp) : true;
|
|
80
|
+
},
|
|
64
81
|
});
|
|
65
82
|
export class FileCheckpointStore {
|
|
66
83
|
durability = "durable";
|
|
@@ -83,6 +100,7 @@ export class FileCheckpointStore {
|
|
|
83
100
|
this.ledger = checkpointLedgers.acquire({
|
|
84
101
|
dir,
|
|
85
102
|
tmpDir: join(root, "tmp"),
|
|
103
|
+
...(opts.onCorruptRead !== undefined ? { onCorruptRead: guardCorruptSink(opts.onCorruptRead) } : {}),
|
|
86
104
|
ledgerPath: join(dir, "checkpoints.jsonl"),
|
|
87
105
|
snapshotPath: join(dir, "checkpoints.snapshot.jsonl"),
|
|
88
106
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file backend's ONE disclosure-seat guard: `@contract store.read_back_shape_judged`'s second legal
|
|
3
|
+
* posture — an absence that DISCLOSES — rides a host's `onCorruptRead`, and the guard around it was
|
|
4
|
+
* hand-written four times. Three copies contained an async sink's rejection; the mailbox's caught only a
|
|
5
|
+
* synchronous throw, so an `async` sink there became an unhandled rejection.
|
|
6
|
+
*/
|
|
7
|
+
/** Coordinates of one durable read a store answered as absent because the bytes were corrupt. */
|
|
8
|
+
export interface CorruptReadInfo {
|
|
9
|
+
path: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a host's disclosure seat: neither a synchronous throw nor an async rejection can re-introduce the
|
|
14
|
+
* fault the fail-open avoids. `undefined` in ⇒ `undefined` out. Generic in the payload because the
|
|
15
|
+
* session-policy store discloses session coordinates rather than a path.
|
|
16
|
+
*/
|
|
17
|
+
/** The sink is invoked UNBOUND (`this` is undefined): pass a bound function or an arrow, never a bare
|
|
18
|
+
* method reference — a method reading `this` throws, and the throw is contained here, so the disclosure
|
|
19
|
+
* is silently lost rather than surfaced. */
|
|
20
|
+
export declare function guardCorruptSink<I>(sink: ((info: I) => void) | undefined): ((info: I) => void) | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function guardCorruptSink(sink) {
|
|
2
|
+
if (sink === undefined)
|
|
3
|
+
return undefined;
|
|
4
|
+
return (info) => {
|
|
5
|
+
try {
|
|
6
|
+
const returned = sink(info);
|
|
7
|
+
if (typeof returned?.then === "function") {
|
|
8
|
+
void Promise.resolve(returned).then(undefined, () => {
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -233,8 +233,7 @@ export declare class FileFileHistoryStore implements FileHistoryStore {
|
|
|
233
233
|
* GC that could not run leaves a graph larger than its bound, the safer of the two directions).
|
|
234
234
|
*/
|
|
235
235
|
private applyBoundaryRetention;
|
|
236
|
-
/** The single delivery point for the disclosure sink
|
|
237
|
-
* remember, and so a host's throwing sink cannot break the never-throw seam. */
|
|
236
|
+
/** The single delivery point for the disclosure sink. */
|
|
238
237
|
private disclose;
|
|
239
238
|
private scopeDir;
|
|
240
239
|
private trackedPath;
|
|
@@ -5,6 +5,7 @@ import { thrownText } from "../../core/thrown-value.js";
|
|
|
5
5
|
import { applyFileRestoreViaEnv, boundaryPublishVerdict, worldStillMatchesMint, captureFileBackupViaEnv, modeProvenDifferent, previewFileDelta, describeTrackKey, fileHistoryExportDigest, resolveTrackKey, trackKeyOf, validateFileHistoryExport, } from "../../core/file-history-store.js";
|
|
6
6
|
import { fileHistoryBoundariesToKeep, resolveFileHistoryRetention } from "../../core/file-history-retention.js";
|
|
7
7
|
import { atomicWriteFile, ensureDir, sanitizePathComponent, sanitizeScope, writeThenLink } from "./fs-atomic.js";
|
|
8
|
+
import { guardCorruptSink } from "./corrupt-read-sink.js";
|
|
8
9
|
import { isAbsoluteForFamily } from "../../tools/fs/safety.js";
|
|
9
10
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
10
11
|
function trackedDigest(rows) {
|
|
@@ -156,7 +157,7 @@ export class FileFileHistoryStore {
|
|
|
156
157
|
this.scopesDir = join(this.base, "scopes");
|
|
157
158
|
ensureDir(this.blobsDir);
|
|
158
159
|
ensureDir(this.scopesDir);
|
|
159
|
-
this.onCorruptRead = opts?.onCorruptRead;
|
|
160
|
+
this.onCorruptRead = guardCorruptSink(opts?.onCorruptRead);
|
|
160
161
|
}
|
|
161
162
|
applyBoundaryRetention(scope, justCommitted) {
|
|
162
163
|
if (this.boundaryKeep === null)
|
|
@@ -194,13 +195,7 @@ export class FileFileHistoryStore {
|
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
disclose(path, reason) {
|
|
197
|
-
|
|
198
|
-
const r = this.onCorruptRead?.({ path, reason });
|
|
199
|
-
if (typeof r?.then === "function")
|
|
200
|
-
r.then(undefined, () => { });
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
}
|
|
198
|
+
this.onCorruptRead?.({ path, reason });
|
|
204
199
|
}
|
|
205
200
|
scopeDir(scope) {
|
|
206
201
|
return join(this.scopesDir, sanitizeScope(scope));
|
|
@@ -40,11 +40,16 @@ export declare function atomicWriteFile(tmpDir: string, target: string, bytes: s
|
|
|
40
40
|
* parity), so a single corrupt interior line never poisons the whole replay.
|
|
41
41
|
* A missing file → `[]`. This is why JSONL beats one big JSON blob: a torn tail can never corrupt the store.
|
|
42
42
|
*
|
|
43
|
+
* SHAPE (`@contract store.read_back_shape_judged`): parseability is not the judgement. Every store on this
|
|
44
|
+
* format writes ONE JSON OBJECT per line, so a line parsing to a number, string, boolean, `null` or array
|
|
45
|
+
* takes the road a torn line takes — disclosed, skipped, the replay intact — because it is the same fact:
|
|
46
|
+
* these bytes are not what this store wrote. Without it `null` made a `line.kind` test throw.
|
|
47
|
+
*
|
|
43
48
|
* `onCorrupt` (optional, review-510 disclosure-seat family): fired once per skipped INTERIOR record so a
|
|
44
49
|
* caller-side seat can observe real corruption — a silently-skipped record is otherwise indistinguishable
|
|
45
|
-
* from "less data"
|
|
46
|
-
*
|
|
47
|
-
* must not throw swallow-guards on ITS side
|
|
50
|
+
* from "less data"; its `reason` opens with the {@link CorruptBodyForm} word, so a sink classifies without
|
|
51
|
+
* reading prose. The torn TAIL stays out by design: dying mid-write is the crash shape this format exists
|
|
52
|
+
* to absorb. The callback is invoked as given — a seat that must not throw swallow-guards on ITS side.
|
|
48
53
|
*/
|
|
49
54
|
export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: {
|
|
50
55
|
path: string;
|
|
@@ -126,15 +126,32 @@ export function readJsonlRecords(path, onCorrupt) {
|
|
|
126
126
|
const line = parts[i];
|
|
127
127
|
if (line === "")
|
|
128
128
|
continue;
|
|
129
|
+
let record;
|
|
129
130
|
try {
|
|
130
|
-
|
|
131
|
+
record = JSON.parse(line);
|
|
131
132
|
}
|
|
132
133
|
catch {
|
|
133
|
-
onCorrupt?.({ path, reason:
|
|
134
|
+
onCorrupt?.({ path, reason: corruptRecordReason("torn-json", i + 1, "unparseable jsonl record") });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) {
|
|
138
|
+
onCorrupt?.({ path, reason: corruptRecordReason("not-an-object", i + 1, `jsonl record is ${jsonTypeName(record)}, not an object`) });
|
|
139
|
+
continue;
|
|
134
140
|
}
|
|
141
|
+
out.push(record);
|
|
135
142
|
}
|
|
136
143
|
return out;
|
|
137
144
|
}
|
|
145
|
+
function corruptRecordReason(form, lineNo, what) {
|
|
146
|
+
return `${form}: ${what} at line ${lineNo} (skipped; the rest of the replay is intact)`;
|
|
147
|
+
}
|
|
148
|
+
function jsonTypeName(value) {
|
|
149
|
+
if (value === null)
|
|
150
|
+
return "null";
|
|
151
|
+
if (Array.isArray(value))
|
|
152
|
+
return "an array";
|
|
153
|
+
return `a ${typeof value}`;
|
|
154
|
+
}
|
|
138
155
|
export function canonicalStoreKey(p) {
|
|
139
156
|
return physicalPathOfExisting(p).toLowerCase();
|
|
140
157
|
}
|
|
@@ -65,19 +65,19 @@ export class FileStorageBackend {
|
|
|
65
65
|
this.fileSessions = repo;
|
|
66
66
|
this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget", durability: "durable", placements: { subagent: { durability: "durable" } } });
|
|
67
67
|
this.sessionStore = this.ttl;
|
|
68
|
-
this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
|
|
68
|
+
this.fileCheckpoints = new FileCheckpointStore(this.root, { ...opts.checkpoint, ...corruptRead });
|
|
69
69
|
built.push(() => this.fileCheckpoints.close());
|
|
70
70
|
this.checkpointStore = this.fileCheckpoints;
|
|
71
71
|
this.fileMemory = new FileMemoryStore(this.root, opts.embedder ? { embedder: opts.embedder } : {});
|
|
72
72
|
built.push(() => this.fileMemory.close());
|
|
73
73
|
this.memoryStore = guardedMemoryStore(this.fileMemory, opts.utilityGate);
|
|
74
|
-
this.toolResultStore = new FileToolResultStore(this.root);
|
|
74
|
+
this.toolResultStore = new FileToolResultStore(this.root, corruptRead);
|
|
75
75
|
this.sessionPolicyStore = new FileSessionPolicyStore(this.root, corruptRead);
|
|
76
|
-
this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root);
|
|
76
|
+
this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root, true, corruptRead);
|
|
77
77
|
built.push(() => this.fileWorkflowJournal.dispose());
|
|
78
78
|
this.workflowJournalStore = this.fileWorkflowJournal;
|
|
79
79
|
this.usageWindowStore = new FileUsageWindowStore(this.root);
|
|
80
|
-
this.fileAgentRows = new FileBackgroundAgentStore(this.root);
|
|
80
|
+
this.fileAgentRows = new FileBackgroundAgentStore(this.root, corruptRead ?? {});
|
|
81
81
|
built.push(() => this.fileAgentRows.close());
|
|
82
82
|
this.backgroundAgentStore = this.fileAgentRows;
|
|
83
83
|
this.fileMailbox = new FileMailboxStore(this.root, corruptRead ?? {});
|
|
@@ -105,6 +105,9 @@ export declare class FileMailboxStore implements MailboxStore {
|
|
|
105
105
|
* and the cache is replayed from the file. A moved INODE also swap-closes the descriptor (ring 3):
|
|
106
106
|
* the next write reopens the current file instead of landing on the unlinked one.
|
|
107
107
|
*/
|
|
108
|
+
/** Fold ONE replayed event, disclosing a record that is not one of this box's ({@link applyEvent}
|
|
109
|
+
* answers `false`): dropped silently it reads exactly like a shorter mailbox. */
|
|
110
|
+
private applyReplayed;
|
|
108
111
|
private reconcile;
|
|
109
112
|
private load;
|
|
110
113
|
private commit;
|