@sema-agent/core 5.27.0 → 5.28.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 +64 -0
- package/dist/core/hooks.d.ts +22 -0
- package/dist/core/hooks.js +22 -3
- package/dist/core/memory-engine/engine.js +2 -4
- package/dist/core/memory-engine/file-backend.d.ts +66 -7
- package/dist/core/memory-engine/file-backend.js +69 -27
- package/dist/core/memory-engine/layout.d.ts +31 -2
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +6 -5
- package/dist/core/permission-rule-consent.d.ts +82 -8
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +17 -1
- package/dist/core/permission-rule-model.js +21 -0
- package/dist/core/permission-rule-org.d.ts +22 -3
- package/dist/core/permission-rule-org.js +67 -20
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/permission-rule-sync.d.ts +15 -1
- package/dist/core/permission-rule-sync.js +89 -47
- package/dist/core/runner/prepare-task.js +8 -3
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +9 -0
- package/dist/core/task-registry-agent.js +51 -21
- package/dist/core/task-registry-monitor.js +1 -1
- package/dist/core/task-registry-shared.d.ts +9 -0
- package/dist/core/tool-policy.d.ts +35 -2
- package/dist/core/tool-policy.js +37 -3
- package/dist/core/tool-result-store.d.ts +108 -7
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/types.d.ts +80 -10
- package/dist/core/types.js +30 -1
- package/dist/index.d.ts +2 -2
- package/dist/stores/file/tool-result-store.d.ts +41 -1
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-shared.d.ts +5 -0
- package/dist/tools/fs/fs-shared.js +11 -7
- package/dist/tools/fs/index.d.ts +6 -0
- package/dist/tools/fs/index.js +2 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
|
|
1
|
+
import { escapeForDisclosure, parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
|
|
2
2
|
import { sameScope, writerOf } from "./permission-rule-store.js";
|
|
3
3
|
export function orgRuleStatePersistenceOf(store) {
|
|
4
4
|
const s = store;
|
|
@@ -46,7 +46,7 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
46
46
|
throw new Error("org-governed is declared but no OrgRuleSnapshotProvider is wired — a governed deployment without an org source is a configuration contradiction; refusing to boot rather than running silently ungoverned");
|
|
47
47
|
}
|
|
48
48
|
if (typeof cfg.stalenessBoundMs !== "number" || !Number.isFinite(cfg.stalenessBoundMs) || cfg.stalenessBoundMs <= 0) {
|
|
49
|
-
throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${
|
|
49
|
+
throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${escapeForDisclosure(cfg.stalenessBoundMs)})`);
|
|
50
50
|
}
|
|
51
51
|
const provider = cfg.provider;
|
|
52
52
|
const now = cfg.now ?? Date.now;
|
|
@@ -75,11 +75,11 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
75
75
|
let fetched;
|
|
76
76
|
let fetchFailed = false;
|
|
77
77
|
try {
|
|
78
|
-
fetched = await provider.current();
|
|
78
|
+
fetched = structuredClone(await provider.current());
|
|
79
79
|
}
|
|
80
80
|
catch (err) {
|
|
81
81
|
fetchFailed = true;
|
|
82
|
-
disclosures.push(`org snapshot provider failed: ${err instanceof Error ? err.message :
|
|
82
|
+
disclosures.push(`org snapshot provider failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
|
|
83
83
|
}
|
|
84
84
|
return await serialize(async () => {
|
|
85
85
|
const nowMs = now();
|
|
@@ -88,7 +88,7 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
88
88
|
return await readState();
|
|
89
89
|
}
|
|
90
90
|
catch (err) {
|
|
91
|
-
disclosures.push(`org state ${context} read failed: ${err instanceof Error ? err.message :
|
|
91
|
+
disclosures.push(`org state ${context} read failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
|
|
92
92
|
return undefined;
|
|
93
93
|
}
|
|
94
94
|
};
|
|
@@ -101,8 +101,12 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
101
101
|
disclosures.push(`org snapshot refused: ${invalid}; the previously installed snapshot (if any) remains in force`);
|
|
102
102
|
return persisted;
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
const persistedFloor = persisted === undefined ? undefined : antiRollbackFloor(persisted);
|
|
105
|
+
if (persisted !== undefined && !persistedMarkIsCoherent(persisted)) {
|
|
106
|
+
disclosures.push(`the durable org state carries a high-water mark (${escapeForDisclosure(persisted.revisionHighWater)}) that is not a finite number at or above its own snapshot revision (${escapeForDisclosure(persisted.snapshot?.revision)}) — the anti-rollback fence is being held at ${escapeForDisclosure(persistedFloor)} instead, and the durable state should be repaired`);
|
|
107
|
+
}
|
|
108
|
+
if (persistedFloor !== undefined && fetched.revision < persistedFloor) {
|
|
109
|
+
disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${escapeForDisclosure(persistedFloor)} — refusing the rollback; the current deny set is unchanged`);
|
|
106
110
|
return persisted;
|
|
107
111
|
}
|
|
108
112
|
if (persisted !== undefined && fetched.revision === persisted.snapshot.revision && !sameOrgPolicyContent(fetched, persisted.snapshot)) {
|
|
@@ -110,7 +114,7 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
110
114
|
return persisted;
|
|
111
115
|
}
|
|
112
116
|
const state = {
|
|
113
|
-
revisionHighWater: Math.max(
|
|
117
|
+
revisionHighWater: Math.max(persistedFloor !== undefined && Number.isFinite(persistedFloor) ? persistedFloor : 0, fetched.revision),
|
|
114
118
|
snapshot: fetched,
|
|
115
119
|
installedAtMs: nowMs,
|
|
116
120
|
};
|
|
@@ -119,11 +123,11 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
119
123
|
}
|
|
120
124
|
catch (err) {
|
|
121
125
|
const reread = await guardedRead("reconciliation");
|
|
122
|
-
if (reread !== undefined && reread
|
|
123
|
-
disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${reread.snapshot.revision} — continuing on the newer installed state`);
|
|
126
|
+
if (reread !== undefined && antiRollbackFloor(reread) >= state.revisionHighWater) {
|
|
127
|
+
disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${escapeForDisclosure(reread.snapshot.revision)} — continuing on the newer installed state`);
|
|
124
128
|
return reread;
|
|
125
129
|
}
|
|
126
|
-
disclosures.push(`org snapshot install failed (${err instanceof Error ? err.message :
|
|
130
|
+
disclosures.push(`org snapshot install failed (${escapeForDisclosure(err instanceof Error ? err.message : err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
|
|
127
131
|
return undefined;
|
|
128
132
|
}
|
|
129
133
|
return state;
|
|
@@ -132,7 +136,12 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
132
136
|
const withinBound = (snap) => decisionMs - snap.fetchedAtMs <= cfg.stalenessBoundMs && snap.fetchedAtMs <= decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS;
|
|
133
137
|
if (installed !== undefined) {
|
|
134
138
|
if (installed.snapshot.fetchedAtMs > decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
|
|
135
|
-
disclosures.push(`the installed org snapshot claims a FUTURE observation time (${installed.snapshot.fetchedAtMs} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
139
|
+
disclosures.push(`the installed org snapshot claims a FUTURE observation time (${escapeForDisclosure(installed.snapshot.fetchedAtMs)} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
140
|
+
return { status: "unavailable", rules: [], disclosures };
|
|
141
|
+
}
|
|
142
|
+
const servingRefusal = validateOrgSnapshot(installed.snapshot, decisionMs);
|
|
143
|
+
if (servingRefusal !== undefined) {
|
|
144
|
+
disclosures.push(`the installed org snapshot (revision ${escapeForDisclosure(installed.snapshot.revision)}) does not meet this version's validator: ${servingRefusal} — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
136
145
|
return { status: "unavailable", rules: [], disclosures };
|
|
137
146
|
}
|
|
138
147
|
if (withinBound(installed.snapshot)) {
|
|
@@ -156,16 +165,50 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
156
165
|
};
|
|
157
166
|
return { resolve };
|
|
158
167
|
}
|
|
168
|
+
function antiRollbackFloor(state) {
|
|
169
|
+
const mark = typeof state.revisionHighWater === "number" && Number.isFinite(state.revisionHighWater) ? state.revisionHighWater : undefined;
|
|
170
|
+
const own = typeof state.snapshot?.revision === "number" && Number.isFinite(state.snapshot.revision) ? state.snapshot.revision : undefined;
|
|
171
|
+
if (mark === undefined)
|
|
172
|
+
return own ?? Number.NEGATIVE_INFINITY;
|
|
173
|
+
return own === undefined ? mark : Math.max(mark, own);
|
|
174
|
+
}
|
|
175
|
+
function persistedMarkIsCoherent(state) {
|
|
176
|
+
return (typeof state.revisionHighWater === "number" &&
|
|
177
|
+
Number.isFinite(state.revisionHighWater) &&
|
|
178
|
+
typeof state.snapshot?.revision === "number" &&
|
|
179
|
+
Number.isFinite(state.snapshot.revision) &&
|
|
180
|
+
state.revisionHighWater >= state.snapshot.revision);
|
|
181
|
+
}
|
|
159
182
|
function sameOrgPolicyContent(a, b) {
|
|
160
183
|
if (a.rules.length !== b.rules.length)
|
|
161
184
|
return false;
|
|
162
185
|
return a.rules.every((r, i) => b.rules[i]?.rule === r.rule && b.rules[i]?.behavior === r.behavior);
|
|
163
186
|
}
|
|
187
|
+
const DISCLOSED_UNENFORCEABLE_RULES = 20;
|
|
188
|
+
export function unenforceableOrgRules(rules) {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const r of rules) {
|
|
191
|
+
if (typeof r?.rule !== "string" || r.rule === "")
|
|
192
|
+
continue;
|
|
193
|
+
const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
|
|
194
|
+
if ("reject" in parsed)
|
|
195
|
+
out.push({ rule: r.rule, code: parsed.reject.code });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
function describeUnenforceable(unenforceable) {
|
|
200
|
+
const named = unenforceable
|
|
201
|
+
.slice(0, DISCLOSED_UNENFORCEABLE_RULES)
|
|
202
|
+
.map((u) => `"${escapeForDisclosure(u.rule)}" (${u.code})`)
|
|
203
|
+
.join(", ");
|
|
204
|
+
const more = unenforceable.length - DISCLOSED_UNENFORCEABLE_RULES;
|
|
205
|
+
return more > 0 ? `${named} and ${more} more` : named;
|
|
206
|
+
}
|
|
164
207
|
function validateOrgSnapshot(s, nowMs) {
|
|
165
208
|
if (typeof s.revision !== "number" || !Number.isFinite(s.revision))
|
|
166
|
-
return `revision is not a finite number (${
|
|
209
|
+
return `revision is not a finite number (${escapeForDisclosure(s.revision)})`;
|
|
167
210
|
if (typeof s.fetchedAtMs !== "number" || !Number.isFinite(s.fetchedAtMs))
|
|
168
|
-
return `fetchedAtMs is not a finite number (${
|
|
211
|
+
return `fetchedAtMs is not a finite number (${escapeForDisclosure(s.fetchedAtMs)})`;
|
|
169
212
|
if (s.fetchedAtMs > nowMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
|
|
170
213
|
return `fetchedAtMs is ${s.fetchedAtMs - nowMs}ms in the future (allowance ${ORG_FETCHED_AT_SKEW_ALLOWANCE_MS}ms) — a future-dated observation would satisfy the staleness bound indefinitely`;
|
|
171
214
|
}
|
|
@@ -174,11 +217,15 @@ function validateOrgSnapshot(s, nowMs) {
|
|
|
174
217
|
for (const r of s.rules) {
|
|
175
218
|
if (typeof r?.rule !== "string" || r.rule === "")
|
|
176
219
|
return "a rule entry carries no rule text";
|
|
177
|
-
if (r.behavior !== "deny" && r.behavior !== "ask")
|
|
178
|
-
return `rule "${r.rule}" carries behavior "${
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
220
|
+
if (r.behavior !== "deny" && r.behavior !== "ask") {
|
|
221
|
+
return `rule "${escapeForDisclosure(r.rule)}" carries behavior "${escapeForDisclosure(r.behavior)}" — the org layer has no allow bucket`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const unenforceable = unenforceableOrgRules(s.rules);
|
|
225
|
+
const first = unenforceable[0];
|
|
226
|
+
if (first !== undefined) {
|
|
227
|
+
const rest = unenforceable.length > 1 ? ` (${unenforceable.length} in all — ${describeUnenforceable(unenforceable)})` : "";
|
|
228
|
+
return `rule "${escapeForDisclosure(first.rule)}" does not parse (${first.code}) — an unenforceable deny/ask must not stand as policy${rest}`;
|
|
182
229
|
}
|
|
183
230
|
return undefined;
|
|
184
231
|
}
|
|
@@ -231,7 +278,7 @@ function resolveIntrospectionStore(opts) {
|
|
|
231
278
|
return store;
|
|
232
279
|
}
|
|
233
280
|
if (opts.owner !== undefined && opts.owner.kind === "principal" && opts.principal !== undefined && opts.owner.principal !== opts.principal) {
|
|
234
|
-
throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
|
|
281
|
+
throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
|
|
235
282
|
}
|
|
236
283
|
const principal = opts.owner?.kind === "principal" ? opts.owner.principal : opts.principal;
|
|
237
284
|
return opts.provider.forPrincipal(principal);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { canonicalize } from "./canonical-json.js";
|
|
3
|
-
import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
|
|
3
|
+
import { escapeForDisclosure, isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
|
|
4
4
|
export function sameRuleOwner(a, b) {
|
|
5
5
|
return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
|
|
6
6
|
}
|
|
@@ -191,7 +191,7 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
191
191
|
}
|
|
192
192
|
const canonicalTomb = parseAllowRuleText(t.rule);
|
|
193
193
|
if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
|
|
194
|
-
throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${t.rule}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
|
|
194
|
+
throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${escapeForDisclosure(t.rule)}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
const droppedInbound = [];
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
|
|
26
26
|
* level they are buying.
|
|
27
27
|
*/
|
|
28
|
-
import type { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
28
|
+
import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
29
29
|
import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
|
|
30
30
|
import { type TracerHook } from "./trace.js";
|
|
31
31
|
/** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
|
|
@@ -125,6 +125,15 @@ interface ParsedRuleSyncResponse {
|
|
|
125
125
|
dot: RuleDot;
|
|
126
126
|
reason: string;
|
|
127
127
|
}>;
|
|
128
|
+
/** Tombstones whose SHAPE is whole but whose rule TEXT the current validator refuses — the third
|
|
129
|
+
* outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
|
|
130
|
+
* parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
|
|
131
|
+
textRefusedTombstones: Array<{
|
|
132
|
+
rule: string;
|
|
133
|
+
scope: RuleScope;
|
|
134
|
+
dot: RuleDot;
|
|
135
|
+
code: RuleRejectCode;
|
|
136
|
+
}>;
|
|
128
137
|
warnings: string[];
|
|
129
138
|
}
|
|
130
139
|
/**
|
|
@@ -134,6 +143,11 @@ interface ParsedRuleSyncResponse {
|
|
|
134
143
|
* reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
|
|
135
144
|
* tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
|
|
136
145
|
* below is PICKED off the response, the object itself is discarded.
|
|
146
|
+
*
|
|
147
|
+
* A tombstone has THREE outcomes, not two (backlog #176): sound; malformed (bad shape, or a spelling
|
|
148
|
+
* that parses but is not canonical — the withholding arm); and shape-whole-but-TEXT-refused, reported
|
|
149
|
+
* on {@link ParsedRuleSyncResponse.textRefusedTombstones}. The third is a legacy row a tightened
|
|
150
|
+
* validator no longer admits — permanent, so treating it as the second would stall inbound adds forever.
|
|
137
151
|
*/
|
|
138
152
|
export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
|
|
139
153
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAllowRuleText } from "./permission-rule-model.js";
|
|
1
|
+
import { escapeForDisclosure, parseAllowRuleText } from "./permission-rule-model.js";
|
|
2
2
|
import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
|
|
3
3
|
import { emitTrace } from "./trace.js";
|
|
4
4
|
export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
|
|
@@ -9,12 +9,13 @@ function refuseLocalOwnerSync() {
|
|
|
9
9
|
throw e;
|
|
10
10
|
}
|
|
11
11
|
const SYNC_MAX_ATTEMPTS = 8;
|
|
12
|
+
const DISCLOSED_REFUSED_TOMBSTONES = 5;
|
|
12
13
|
export async function syncPermissionRules(opts) {
|
|
13
14
|
if (opts.owner !== undefined) {
|
|
14
15
|
if (opts.owner.kind === "local-owner")
|
|
15
16
|
refuseLocalOwnerSync();
|
|
16
17
|
if (opts.owner.principal !== opts.principal) {
|
|
17
|
-
throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
|
|
18
|
+
throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
if (typeof opts.principal !== "string" || opts.principal === "") {
|
|
@@ -83,6 +84,18 @@ export async function syncPermissionRules(opts) {
|
|
|
83
84
|
if (tombstoneWithholding) {
|
|
84
85
|
warnings.push("the response carried at least one malformed tombstone — every inbound add is withheld this round (legal tombstones land; the adds re-arrive once the peer repairs the tombstone: full-state exchange has no missed-forever window)");
|
|
85
86
|
}
|
|
87
|
+
for (const t of response.textRefusedTombstones) {
|
|
88
|
+
dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
|
|
89
|
+
}
|
|
90
|
+
if (response.textRefusedTombstones.length > 0) {
|
|
91
|
+
const named = response.textRefusedTombstones
|
|
92
|
+
.slice(0, DISCLOSED_REFUSED_TOMBSTONES)
|
|
93
|
+
.map((t) => `"${escapeForDisclosure(t.rule)}" (${t.code})`)
|
|
94
|
+
.join(", ");
|
|
95
|
+
const more = response.textRefusedTombstones.length - DISCLOSED_REFUSED_TOMBSTONES;
|
|
96
|
+
warnings.push(`the response carried ${response.textRefusedTombstones.length} tombstone(s) whose rule text this version's validator refuses — ${named}${more > 0 ? ` and ${more} more` : ""}. ` +
|
|
97
|
+
"They are refused here and never land; this round's inbound adds are NOT withheld for them (an add under the same text is refused by the same validator, so such a tombstone covers nothing that could go live). The peer still holds them — removing them there is what ends this notice");
|
|
98
|
+
}
|
|
86
99
|
const inboundRules = [];
|
|
87
100
|
if (!tombstoneWithholding) {
|
|
88
101
|
for (const r of response.merged.rules) {
|
|
@@ -120,7 +133,7 @@ export async function syncPermissionRules(opts) {
|
|
|
120
133
|
dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
|
|
121
134
|
}
|
|
122
135
|
else {
|
|
123
|
-
warnings.push(`the server dropped an inbound-side record (${d.rule}, dot ${d.dot.actor}#${d.dot.counter}): ${d.reason}`);
|
|
136
|
+
warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
|
|
124
137
|
}
|
|
125
138
|
}
|
|
126
139
|
const clean = !tombstoneWithholding;
|
|
@@ -147,7 +160,7 @@ export async function syncPermissionRules(opts) {
|
|
|
147
160
|
resurrected: [],
|
|
148
161
|
dropped,
|
|
149
162
|
rev: current.rev,
|
|
150
|
-
warnings: [...warnings, `the store refused the sync landing: ${errText(err)} — nothing landed, the local state is unchanged`],
|
|
163
|
+
warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
|
|
151
164
|
});
|
|
152
165
|
}
|
|
153
166
|
if ("conflict" in res) {
|
|
@@ -205,9 +218,11 @@ export async function syncPermissionRules(opts) {
|
|
|
205
218
|
warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
|
|
206
219
|
});
|
|
207
220
|
}
|
|
208
|
-
function
|
|
221
|
+
function pickDot(v) {
|
|
209
222
|
const d = v;
|
|
210
|
-
|
|
223
|
+
const actor = d?.actor;
|
|
224
|
+
const counter = d?.counter;
|
|
225
|
+
return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
|
|
211
226
|
}
|
|
212
227
|
function pickScope(v) {
|
|
213
228
|
const s = v;
|
|
@@ -219,13 +234,16 @@ function pickScope(v) {
|
|
|
219
234
|
}
|
|
220
235
|
function pickAdd(v) {
|
|
221
236
|
const a = v;
|
|
222
|
-
|
|
237
|
+
const dot = pickDot(a?.dot);
|
|
238
|
+
const origin = a?.origin;
|
|
239
|
+
const createdAt = a?.createdAt;
|
|
240
|
+
if (dot === undefined)
|
|
223
241
|
return undefined;
|
|
224
|
-
if (
|
|
242
|
+
if (origin !== "user" && origin !== "imported-cc" && origin !== "starter")
|
|
225
243
|
return undefined;
|
|
226
|
-
if (typeof
|
|
244
|
+
if (typeof createdAt !== "string")
|
|
227
245
|
return undefined;
|
|
228
|
-
return { dot
|
|
246
|
+
return { dot, origin, createdAt };
|
|
229
247
|
}
|
|
230
248
|
function pickFrontier(v) {
|
|
231
249
|
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
@@ -243,35 +261,44 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
243
261
|
throw new Error("the sync response is not an object — protocol error, nothing landed");
|
|
244
262
|
}
|
|
245
263
|
const r = raw;
|
|
246
|
-
|
|
264
|
+
const echoedPrincipal = r.principal;
|
|
265
|
+
if (typeof echoedPrincipal !== "string") {
|
|
247
266
|
throw new Error("the sync response carries no principal echo — protocol error, nothing landed");
|
|
248
267
|
}
|
|
249
|
-
if (
|
|
250
|
-
throw new Error(`the sync response echoes principal "${
|
|
268
|
+
if (echoedPrincipal !== expectedPrincipal) {
|
|
269
|
+
throw new Error(`the sync response echoes principal "${escapeForDisclosure(echoedPrincipal)}" but this round synced "${escapeForDisclosure(expectedPrincipal)}" — the transport is wired to the wrong bucket; refusing the whole round`);
|
|
251
270
|
}
|
|
252
271
|
const merged = r.merged;
|
|
253
|
-
|
|
272
|
+
const mergedRules = merged?.rules;
|
|
273
|
+
const mergedTombstones = merged?.tombstones;
|
|
274
|
+
if (merged === null || typeof merged !== "object" || !Array.isArray(mergedRules) || !Array.isArray(mergedTombstones)) {
|
|
254
275
|
throw new Error("the sync response carries no merged state — protocol error, nothing landed");
|
|
255
276
|
}
|
|
277
|
+
const rawGcFrontier = r.gcFrontier;
|
|
256
278
|
let gcFrontier;
|
|
257
|
-
if (
|
|
258
|
-
gcFrontier = pickFrontier(
|
|
279
|
+
if (rawGcFrontier !== undefined) {
|
|
280
|
+
gcFrontier = pickFrontier(rawGcFrontier);
|
|
259
281
|
if (gcFrontier === undefined) {
|
|
260
282
|
throw new Error("the sync response carries an unparseable gcFrontier — refusing the whole round (a garbled frontier must not drive the fence arm)");
|
|
261
283
|
}
|
|
262
284
|
}
|
|
263
285
|
const warnings = [];
|
|
264
286
|
const rules = [];
|
|
265
|
-
for (const entry of
|
|
287
|
+
for (const entry of mergedRules) {
|
|
266
288
|
const e = entry;
|
|
267
289
|
const scope = pickScope(e?.scope);
|
|
268
|
-
|
|
290
|
+
const eRule = e?.rule;
|
|
291
|
+
const eCommand = e?.command;
|
|
292
|
+
const eTool = e?.tool;
|
|
293
|
+
const eMatch = e?.match;
|
|
294
|
+
const eAdds = e?.adds;
|
|
295
|
+
if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string") {
|
|
269
296
|
warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
|
|
270
297
|
continue;
|
|
271
298
|
}
|
|
272
299
|
const adds = [];
|
|
273
300
|
let addDamaged = false;
|
|
274
|
-
for (const a of
|
|
301
|
+
for (const a of eAdds) {
|
|
275
302
|
const picked = pickAdd(a);
|
|
276
303
|
if (picked === undefined) {
|
|
277
304
|
addDamaged = true;
|
|
@@ -280,64 +307,79 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
280
307
|
adds.push(picked);
|
|
281
308
|
}
|
|
282
309
|
if (addDamaged)
|
|
283
|
-
warnings.push(`rule "${
|
|
310
|
+
warnings.push(`rule "${escapeForDisclosure(eRule)}" carried at least one unshapeable add — skipped those adds`);
|
|
284
311
|
if (adds.length === 0)
|
|
285
312
|
continue;
|
|
286
313
|
rules.push({
|
|
287
|
-
rule:
|
|
288
|
-
tool:
|
|
289
|
-
match:
|
|
290
|
-
command:
|
|
314
|
+
rule: eRule,
|
|
315
|
+
tool: eTool,
|
|
316
|
+
match: eMatch,
|
|
317
|
+
command: eCommand,
|
|
291
318
|
scope,
|
|
292
319
|
adds,
|
|
293
320
|
});
|
|
294
321
|
}
|
|
295
322
|
const tombstones = [];
|
|
296
|
-
|
|
323
|
+
const textRefusedTombstones = [];
|
|
324
|
+
for (const entry of mergedTombstones) {
|
|
297
325
|
const t = entry;
|
|
298
326
|
const scope = pickScope(t?.scope);
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
327
|
+
const ruleText = t?.rule;
|
|
328
|
+
const deletedBy = pickDot(t?.deletedBy);
|
|
329
|
+
const removedRaw = t?.removedDots;
|
|
330
|
+
const removedDots = [];
|
|
331
|
+
if (Array.isArray(removedRaw)) {
|
|
332
|
+
for (const d of removedRaw) {
|
|
333
|
+
const dot = pickDot(d);
|
|
334
|
+
if (dot === undefined) {
|
|
335
|
+
removedDots.length = 0;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
removedDots.push(dot);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
|
|
309
342
|
tombstones.push(undefined);
|
|
310
343
|
continue;
|
|
311
344
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
scope,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
345
|
+
const canonicalTomb = parseAllowRuleText(ruleText);
|
|
346
|
+
if ("reject" in canonicalTomb) {
|
|
347
|
+
textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (canonicalTomb.rule.rule !== ruleText) {
|
|
351
|
+
tombstones.push(undefined);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
|
|
318
355
|
}
|
|
319
356
|
const dropped = [];
|
|
320
|
-
|
|
321
|
-
|
|
357
|
+
const rawDropped = r.dropped;
|
|
358
|
+
if (rawDropped !== undefined) {
|
|
359
|
+
if (!Array.isArray(rawDropped)) {
|
|
322
360
|
warnings.push("the response carried a non-array dropped field — ignored");
|
|
323
361
|
}
|
|
324
362
|
else {
|
|
325
|
-
for (const entry of
|
|
363
|
+
for (const entry of rawDropped) {
|
|
326
364
|
const d = entry;
|
|
327
365
|
const scope = pickScope(d?.scope);
|
|
328
|
-
|
|
366
|
+
const dRule = d?.rule;
|
|
367
|
+
const dDot = pickDot(d?.dot);
|
|
368
|
+
const dReason = d?.reason;
|
|
369
|
+
if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string") {
|
|
329
370
|
warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
|
|
330
371
|
continue;
|
|
331
372
|
}
|
|
332
|
-
dropped.push({ rule:
|
|
373
|
+
dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
|
|
333
374
|
}
|
|
334
375
|
}
|
|
335
376
|
}
|
|
336
377
|
return {
|
|
337
|
-
principal:
|
|
378
|
+
principal: echoedPrincipal,
|
|
338
379
|
merged: { rules, tombstones },
|
|
339
380
|
...(gcFrontier !== undefined ? { gcFrontier } : {}),
|
|
340
381
|
dropped,
|
|
382
|
+
textRefusedTombstones,
|
|
341
383
|
warnings,
|
|
342
384
|
};
|
|
343
385
|
}
|
|
@@ -89,10 +89,14 @@ import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOI
|
|
|
89
89
|
import { boundInputHashOf } from "../canonical-json.js";
|
|
90
90
|
import { countElicitOptIns, deriveAskEffective, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam } from "../wiring-manifest.js";
|
|
91
91
|
import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
|
|
92
|
+
import { deliverEngineNotice } from "../types.js";
|
|
92
93
|
const announcedMaterializeEnv = new Set();
|
|
93
94
|
export function __resetMaterializeEnvAnnouncements() {
|
|
94
95
|
announcedMaterializeEnv.clear();
|
|
95
96
|
}
|
|
97
|
+
function emitMaterializeEnvNotice(onNotice, message, detail) {
|
|
98
|
+
deliverEngineNotice(onNotice, { code: "config.materialize_env_discarded", message, detail });
|
|
99
|
+
}
|
|
96
100
|
const RECONCILE_MAX_RETRIES = 3;
|
|
97
101
|
const DEFAULT_MAX_SUSPENDS = 5;
|
|
98
102
|
const TASK_LIMIT_KEY_DICT = {
|
|
@@ -1659,6 +1663,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1659
1663
|
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1660
1664
|
...(internals?.onTaskNotification !== undefined ? { taskNotification: internals.onTaskNotification } : {}),
|
|
1661
1665
|
...(internals?.detachHub !== undefined ? { detachHub: internals.detachHub } : {}),
|
|
1666
|
+
...(deps.onNotice !== undefined ? { onNotice: deps.onNotice } : {}),
|
|
1662
1667
|
pdfModelCapabilities: pdfModelCapabilitiesOf(model),
|
|
1663
1668
|
...(deps.hands?.bashReadonlyAllow !== undefined ? { bashReadonlyAllow: deps.hands.bashReadonlyAllow } : {}),
|
|
1664
1669
|
...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}),
|
|
@@ -2606,7 +2611,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
2606
2611
|
const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(raw)} is not "swap" or "static" — inert on this task (no deferred tools), but a deferring task WITHOUT an explicit spec strategy will refuse to prepare under it (an explicit legal spec outranks and discards it, loudly). Fix or unset the flag.`;
|
|
2607
2612
|
if (!announcedMaterializeEnv.has(line)) {
|
|
2608
2613
|
announcedMaterializeEnv.add(line);
|
|
2609
|
-
|
|
2614
|
+
emitMaterializeEnvNotice(deps.onNotice, line, { raw });
|
|
2610
2615
|
}
|
|
2611
2616
|
}
|
|
2612
2617
|
}
|
|
@@ -2628,7 +2633,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
2628
2633
|
const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(rawEnvStrategy)} was ignored — not "swap" or "static", and the task spec pins toolMaterializeStrategy=${JSON.stringify(spec.toolMaterializeStrategy)} which outranks it. Fix or unset the env flag.`;
|
|
2629
2634
|
if (!announcedMaterializeEnv.has(line)) {
|
|
2630
2635
|
announcedMaterializeEnv.add(line);
|
|
2631
|
-
|
|
2636
|
+
emitMaterializeEnvNotice(deps.onNotice, line, { raw: rawEnvStrategy, specStrategy: spec.toolMaterializeStrategy });
|
|
2632
2637
|
}
|
|
2633
2638
|
}
|
|
2634
2639
|
const envStrategy = envStrategyInvalid ? undefined : rawEnvStrategy;
|
|
@@ -4731,7 +4736,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
4731
4736
|
...(offloadStore
|
|
4732
4737
|
? {
|
|
4733
4738
|
offload: {
|
|
4734
|
-
persist: createOffloadPersist(offloadStore, sessionId),
|
|
4739
|
+
persist: createOffloadPersist(offloadStore, sessionId, deps.onNotice),
|
|
4735
4740
|
},
|
|
4736
4741
|
}
|
|
4737
4742
|
: {}),
|
|
@@ -131,7 +131,10 @@ const DEFERRED_REISSUE = "[DEFERRED] This tool call shared a batch with a call t
|
|
|
131
131
|
function toolEndBodyFrom(result, isError, settledBy) {
|
|
132
132
|
const o = toolOutputFrom(result);
|
|
133
133
|
const st = structuredFrom(result);
|
|
134
|
-
const
|
|
134
|
+
const det = isError ? result?.details : undefined;
|
|
135
|
+
const codeRaw = det?.code;
|
|
136
|
+
const kindRaw = det?.errorKind;
|
|
137
|
+
const code = typeof codeRaw === "string" ? codeRaw : typeof kindRaw === "string" ? kindRaw : undefined;
|
|
135
138
|
return {
|
|
136
139
|
...(o !== undefined ? { output: o.output, ...(o.truncated ? { truncated: true } : {}), ...(o.totalChars !== undefined ? { totalChars: o.totalChars } : {}) } : {}),
|
|
137
140
|
...(st !== undefined ? { structured: st } : {}),
|
|
@@ -1412,6 +1415,10 @@ export class Runner {
|
|
|
1412
1415
|
return runSideQuery(spec, { brain: this.deps.brain, models: this.deps.models, roles: this.deps.roles });
|
|
1413
1416
|
}
|
|
1414
1417
|
runTaskStream(spec, resume, internals) {
|
|
1418
|
+
if (resume !== undefined && (typeof resume !== "object" || resume.outcome === undefined)) {
|
|
1419
|
+
throw new TypeError("runTaskStream: `resume` must be a ResumeRun carrying `outcome` — got a value without one. " +
|
|
1420
|
+
"(Note the parameter order: runTaskStream(spec, resume?, internals?) — internals is the THIRD parameter.)");
|
|
1421
|
+
}
|
|
1415
1422
|
const entryActor = spec.actor === undefined ? undefined : snapshotActorAssertion(spec.actor);
|
|
1416
1423
|
const queue = new PushQueue();
|
|
1417
1424
|
const detachHub = new ToolDetachHub();
|
|
@@ -471,6 +471,15 @@ export declare function serveDurableAgentRowLane(row: BackgroundAgentRecord): Un
|
|
|
471
471
|
*
|
|
472
472
|
* No store configured (a deployment that never wired a `toolResultStore`) ⇒ returns `clipped` UNCHANGED
|
|
473
473
|
* — the legal degrade design/158 §2.2 calls for, byte-identical to pre-S1 behavior.
|
|
474
|
+
*
|
|
475
|
+
* Backlog #169 — a FAILING `put` (throwing or rejecting; the store contract admits both dialects) is
|
|
476
|
+
* NOT fatal to the poll: the clipped text is still served, with {@link AGENT_SPILL_FAILED_NOTE} in
|
|
477
|
+
* place of the ref disclosure (advertising a ref nobody can read back would be a false promise), and
|
|
478
|
+
* `handle.spillFailed` latches so later polls of the same cycle repeat the honest note instead of
|
|
479
|
+
* retrying the write — see the catch below for why latch-not-retry. Same "store failure is never
|
|
480
|
+
* fatal" posture as the monitor/offload/budget/compaction write sites. A put that settles AFTER a
|
|
481
|
+
* revive (stale cycle) is generation-guarded: it answers its own caller honestly but never writes
|
|
482
|
+
* spill state onto the new cycle (see `mintCycle` below).
|
|
474
483
|
*/
|
|
475
484
|
export declare function spillClippedAgentResult(handle: BackgroundAgentTaskHandle, full: string, clipped: string, store: ToolResultStore | undefined, sessionId: string | undefined): Promise<string>;
|
|
476
485
|
export declare function pollBackgroundAgentLane(handle: BackgroundAgentTaskHandle, deadline?: number, signal?: AbortSignal,
|