@sema-agent/core 7.31.0 → 7.32.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 +106 -0
- package/dist/agents/inherited-tool-pool.d.ts +28 -0
- package/dist/agents/inherited-tool-pool.js +15 -0
- package/dist/agents/list-agents-tool.d.ts +7 -0
- package/dist/agents/list-agents-tool.js +9 -2
- package/dist/agents/peer-session-drain.d.ts +2 -0
- package/dist/agents/peer-session-drain.js +1 -0
- package/dist/agents/send-message-tool.d.ts +17 -0
- package/dist/agents/send-message-tool.js +11 -4
- package/dist/agents/subagent.d.ts +10 -1
- package/dist/agents/subagent.js +24 -8
- package/dist/core/ask-origin.d.ts +15 -6
- package/dist/core/ask-origin.js +15 -9
- package/dist/core/background-shell.d.ts +45 -0
- package/dist/core/background-shell.js +13 -0
- package/dist/core/checkpoint-store.d.ts +5 -0
- package/dist/core/checkpoint-store.js +3 -2
- package/dist/core/engine-notice.d.ts +9 -0
- package/dist/core/gate-lanes.js +1 -1
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +5 -0
- package/dist/core/hooks.d.ts +2 -0
- package/dist/core/memory-engine/engine.d.ts +10 -1
- package/dist/core/memory-engine/engine.js +30 -6
- package/dist/core/memory-engine/file-backend.d.ts +15 -0
- package/dist/core/memory-engine/file-backend.js +53 -2
- package/dist/core/memory-engine/layout.d.ts +29 -4
- package/dist/core/memory-engine/layout.js +24 -4
- package/dist/core/read-root-grant-screen.d.ts +23 -0
- package/dist/core/read-root-grant-screen.js +17 -0
- package/dist/core/runner/contracts.d.ts +1 -1
- package/dist/core/runner/prepare-artifact.d.ts +12 -7
- package/dist/core/runner/prepare-artifact.js +10 -8
- package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
- package/dist/core/runner/prepare-ask-lane.js +5 -1
- package/dist/core/runner/prepare-boundary-parks.js +5 -5
- package/dist/core/runner/prepare-delegation-surface.d.ts +18 -0
- package/dist/core/runner/prepare-delegation-surface.js +6 -25
- package/dist/core/runner/prepare-hands-readface.d.ts +12 -89
- package/dist/core/runner/prepare-hands-readface.js +14 -48
- package/dist/core/runner/prepare-inherited-gate.d.ts +3 -1
- package/dist/core/runner/prepare-inherited-gate.js +2 -1
- package/dist/core/runner/prepare-park-ask.js +6 -4
- package/dist/core/runner/prepare-policy-chain.js +4 -0
- package/dist/core/runner/prepare-task.js +5 -3
- package/dist/core/runner/read-root-grant.d.ts +66 -0
- package/dist/core/runner/read-root-grant.js +77 -0
- package/dist/core/runner/run-notification-lane.d.ts +1 -1
- package/dist/core/runner/run-settle-and-teardown.js +3 -9
- package/dist/core/runner/runtask.js +1 -1
- package/dist/core/runner/send-message-continuation.d.ts +45 -0
- package/dist/core/runner/send-message-continuation.js +27 -0
- package/dist/core/runner/session-read-file-states.d.ts +91 -0
- package/dist/core/runner/session-read-file-states.js +43 -0
- package/dist/core/secret-env.d.ts +1 -1
- package/dist/core/secret-env.js +7 -2
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
- package/dist/core/task-registry-monitor.js +3 -0
- package/dist/core/task-registry-shared.d.ts +16 -3
- package/dist/core/task-registry.d.ts +65 -29
- package/dist/core/task-registry.js +78 -28
- package/dist/core/task-spec.d.ts +5 -3
- package/dist/core/tool-policy.d.ts +23 -0
- package/dist/core/tool-policy.js +15 -4
- package/dist/core/tool-spec.d.ts +2 -0
- package/dist/engine/execution-env/node-execution-env.d.ts +13 -3
- package/dist/engine/execution-env/node-execution-env.js +26 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/scenarios/full-body.d.ts +5 -2
- package/dist/scenarios/full-body.js +14 -7
- package/dist/tools/fs/fs-bash-background-text.d.ts +16 -0
- package/dist/tools/fs/fs-bash-background-text.js +22 -0
- package/dist/tools/fs/fs-bash.d.ts +6 -0
- package/dist/tools/fs/fs-bash.js +9 -27
- package/dist/tools/fs/fs-read.d.ts +2 -2
- package/dist/tools/fs/fs-search-tools.d.ts +3 -2
- package/dist/tools/fs/index.d.ts +8 -1
- package/dist/tools/fs/index.js +2 -3
- package/dist/tools/fs/repo-map.d.ts +2 -1
- package/dist/tools/fs/safety.d.ts +3 -1
- package/dist/tools/fs/safety.js +2 -1
- package/dist/tools/fs/shell-read-check.d.ts +3 -0
- package/dist/tools/fs/shell-read-check.js +1 -1
- package/dist/tools/fs/shell-read-establish.d.ts +3 -1
- package/dist/tools/fs/shell-read-establish.js +2 -2
- package/dist/tools/fs/shell-read-plan.d.ts +2 -2
- package/dist/tools/fs/shell-read-plan.js +15 -10
- package/dist/tools/fs/shell-read-word.d.ts +5 -4
- package/dist/tools/fs/shell-read-word.js +3 -2
- package/dist/tools/monitor.d.ts +5 -4
- package/dist/tools/monitor.js +5 -4
- package/dist/tools/todo.d.ts +3 -3
- package/dist/tools/todo.js +5 -4
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +13 -1
- package/wire-changes/core@7.31.1.json +189 -0
- package/wire-changes/core@7.32.0.json +850 -0
|
@@ -111,6 +111,19 @@ export interface BackgroundShellCapability {
|
|
|
111
111
|
pollBackground(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
|
|
112
112
|
/** 杀一个后台进程(幂等:杀已死的是 no-op,返回 ok)。`shellId` 同 {@link pollBackground} 的越界校验。 */
|
|
113
113
|
killBackground(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
|
|
114
|
+
/**
|
|
115
|
+
* OPTIONAL — the SYNCHRONOUS hard kill for an exit boundary with no later tick, where a ladder-style
|
|
116
|
+
* {@link killBackground} (TERM, then a timer-driven KILL) never finishes a shell that ignores SIGTERM.
|
|
117
|
+
* Contract (docs/background-process-lifetime.md): complete the kill before returning — POSIX one
|
|
118
|
+
* `kill(-pgid, SIGKILL)` over the shell's process group (an env with no synchronous hard kill, e.g. win32,
|
|
119
|
+
* answers `no_pgid` and the registry falls back to the ordinary kill); no TERM, no timer,
|
|
120
|
+
* no await; never signal a pid the env cannot prove is still its own; never throw; idempotent; the pid never
|
|
121
|
+
* leaves the env. Outcomes: `killed` (delivered now), `already_gone` (exited, or this seat already delivered
|
|
122
|
+
* its kill, or the id is not its shell any more), `no_pgid` (no synchronous kill landed — nothing host-local
|
|
123
|
+
* to signal, or the signal was refused; nothing claimed, retryable). Without this seat the registry's hard
|
|
124
|
+
* face reads `no_pgid` for every shell.
|
|
125
|
+
*/
|
|
126
|
+
killBackgroundNow?(shellId: BackgroundShellId): BackgroundHardKillOutcome;
|
|
114
127
|
/**
|
|
115
128
|
* 杀掉并清理**本 env 的所有**后台进程。Runner 在每条退出路径调:finish/abort/throw 在 run-loop tail finally,**suspend/
|
|
116
129
|
* review 必须在 `suspendVM` 之前**(design/103 §3.7;detached job 不在 suspendVM in-flight 契约内,不能指望 adapter 隐式处理)。
|
|
@@ -125,6 +138,28 @@ export interface BackgroundShellCapability {
|
|
|
125
138
|
except?: readonly BackgroundShellId[];
|
|
126
139
|
}): Promise<void>;
|
|
127
140
|
}
|
|
141
|
+
/** The closed outcome set of {@link BackgroundShellCapability.killBackgroundNow}. */
|
|
142
|
+
export type BackgroundHardKillOutcome = "killed" | "already_gone" | "no_pgid";
|
|
143
|
+
/** The retain lanes a face can see (absent = not declared). */
|
|
144
|
+
export interface BackgroundRetainLanes {
|
|
145
|
+
/** The env's own declaration: `backgroundCapabilities.retainBackgroundProcesses`. */
|
|
146
|
+
readonly env?: boolean;
|
|
147
|
+
/** The task's declaration: `TaskSpec.retainBackgroundProcesses` of the run whose face is asking. */
|
|
148
|
+
readonly task?: boolean;
|
|
149
|
+
/** The row's `retained` mark (stamped at registration; see the registry row). */
|
|
150
|
+
readonly marked?: boolean;
|
|
151
|
+
/** The owning ISOLATED env is destroyed with this run: retention there is illusory; overrides every lane. */
|
|
152
|
+
readonly envDying?: boolean;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* THE retain predicate — "is this background process kept alive past the automatic reaps?" Every face that
|
|
156
|
+
* answers it calls this function (Bash description + receipts, Monitor receipt, the row stamp, teardown settle
|
|
157
|
+
* and dispose, park-only sweeps, session reap, the exit hard face, the listing). Any declared lane retains,
|
|
158
|
+
* unless the owning isolated env is being destroyed. Contract: docs/background-process-lifetime.md.
|
|
159
|
+
*/
|
|
160
|
+
export declare function retainsBackgroundProcesses(lanes: BackgroundRetainLanes): boolean;
|
|
161
|
+
/** The env lane of {@link BackgroundRetainLanes}, read off an env (false for an env without the capability). */
|
|
162
|
+
export declare function envDeclaresRetain(env: ExecutionEnv): boolean;
|
|
128
163
|
/**
|
|
129
164
|
* 结构 + 语义检测:`env` 是否暴露后台 shell 能力且声明 `supported:true`。挂载门控用此(对齐 `hasDestroy`)。
|
|
130
165
|
*
|
|
@@ -156,3 +191,13 @@ export interface EnvSweepAttribution {
|
|
|
156
191
|
export declare function sweepBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, attribution: EnvSweepAttribution | undefined, opts?: {
|
|
157
192
|
except?: readonly string[];
|
|
158
193
|
}): Promise<void>;
|
|
194
|
+
/**
|
|
195
|
+
* The park-only sweep (a durable park that does not pause the env), one entry point for every park lane:
|
|
196
|
+
* nothing is swept when this run's task or the env declares retain; otherwise every row already retained on
|
|
197
|
+
* this env (an earlier run's delivered service) is spared.
|
|
198
|
+
*/
|
|
199
|
+
export declare function sweepParkOnlyBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, registry: EnvSweepAttribution & {
|
|
200
|
+
retainedShellIds(env: unknown): readonly string[];
|
|
201
|
+
}, lanes: {
|
|
202
|
+
task: boolean;
|
|
203
|
+
}): Promise<void>;
|
|
@@ -8,6 +8,13 @@ export class BackgroundShellError extends Error {
|
|
|
8
8
|
this.name = "BackgroundShellError";
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
export function retainsBackgroundProcesses(lanes) {
|
|
12
|
+
return (lanes.env === true || lanes.task === true || lanes.marked === true) && lanes.envDying !== true;
|
|
13
|
+
}
|
|
14
|
+
export function envDeclaresRetain(env) {
|
|
15
|
+
const caps = env.backgroundCapabilities;
|
|
16
|
+
return typeof caps === "object" && caps !== null && caps.retainBackgroundProcesses === true;
|
|
17
|
+
}
|
|
11
18
|
export function hasBackgroundShell(env) {
|
|
12
19
|
const e = env;
|
|
13
20
|
return (typeof e.spawnBackground === "function" &&
|
|
@@ -26,3 +33,9 @@ export async function sweepBackgroundShells(env, attribution, opts) {
|
|
|
26
33
|
}
|
|
27
34
|
await env.disposeBackgroundShells(opts?.except !== undefined ? { except: opts.except } : undefined);
|
|
28
35
|
}
|
|
36
|
+
export async function sweepParkOnlyBackgroundShells(env, registry, lanes) {
|
|
37
|
+
if (retainsBackgroundProcesses({ env: envDeclaresRetain(env), task: lanes.task }))
|
|
38
|
+
return;
|
|
39
|
+
const except = registry.retainedShellIds(env);
|
|
40
|
+
await sweepBackgroundShells(env, registry, except.length > 0 ? { except } : undefined);
|
|
41
|
+
}
|
|
@@ -816,6 +816,9 @@ export type PendingAction = {
|
|
|
816
816
|
* when a person's rule also speaks — never a substitute for this one). Display metadata: no gate, CAS or
|
|
817
817
|
* resume path reads it. */
|
|
818
818
|
mandated?: true;
|
|
819
|
+
/** The PARK twin of `AskRequest.mandate` (same derivation, same precedent): the table word the bit stands on,
|
|
820
|
+
* present only when it is the whole reason. Written only as a member of the closed set. */
|
|
821
|
+
mandate?: import("./ask-origin.js").PersistedRuleMandate;
|
|
819
822
|
/** The PARK twin of `AskRequest.denialLimitFallback`: present
|
|
820
823
|
* ⇔ this parked ask is the classifier DENIAL-LIMIT fallback, carrying the counts that tripped
|
|
821
824
|
* the bound. Its `autoDenyAfterMs` is ALWAYS `0` on a row: the window is a fact of the ask's
|
|
@@ -1947,6 +1950,8 @@ export interface CheckpointSummary {
|
|
|
1947
1950
|
* OMITTED (the {@link toolCallId} absence-shape contract), screened to the literal `true` — a pre-#1079 row
|
|
1948
1951
|
* reads absent (unreported). ECHO-ONLY; nothing gates on it. */
|
|
1949
1952
|
mandated?: true;
|
|
1953
|
+
/** The row's `PendingAction.tool_approval.mandate`, echoed ONLY as a member of the closed set. ECHO-ONLY. */
|
|
1954
|
+
mandate?: import("./ask-origin.js").PersistedRuleMandate;
|
|
1950
1955
|
/** The row's `PendingAction.tool_approval.denialLimitFallback`, projected BESIDE the preview
|
|
1951
1956
|
* for the same one-call reason: a card that shows the fallback's counts and limit must not re-open
|
|
1952
1957
|
* the row to learn them (`autoDenyAfterMs` is `0` on every row — see the row member). Pure echo;
|
|
@@ -3,7 +3,7 @@ import { uuidv7 } from "../internal/harness.js";
|
|
|
3
3
|
import { PROBE_CAUSE_PATH_MAX, PROBE_REASON_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
|
|
4
4
|
import { carriesBidiControls } from "./tool-policy.js";
|
|
5
5
|
import { readDenialLimitFallback } from "./auto-mode.js";
|
|
6
|
-
import { isAskOrigin, isRuleStoreUnreadable } from "./ask-origin.js";
|
|
6
|
+
import { isAskOrigin, isPersistedRuleMandate, isRuleStoreUnreadable } from "./ask-origin.js";
|
|
7
7
|
import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "./checkpoint-execution-record.js";
|
|
8
8
|
import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
|
|
9
9
|
import { redactSecrets } from "./untrusted-egress.js";
|
|
@@ -305,7 +305,8 @@ export function summarizeCheckpoint(cp) {
|
|
|
305
305
|
...(() => {
|
|
306
306
|
const origin = tool?.origin;
|
|
307
307
|
const ruleStoreUnreadable = tool?.ruleStoreUnreadable;
|
|
308
|
-
|
|
308
|
+
const mandate = tool?.mandate;
|
|
309
|
+
return { ...(isAskOrigin(origin) ? { origin } : {}), ...(isRuleStoreUnreadable(ruleStoreUnreadable) ? { ruleStoreUnreadable } : {}), ...(isPersistedRuleMandate(mandate) ? { mandate } : {}) };
|
|
309
310
|
})(),
|
|
310
311
|
...(hasBidi ? { hasBidiControls: true } : {}),
|
|
311
312
|
};
|
|
@@ -400,6 +400,15 @@ export interface EngineNotice {
|
|
|
400
400
|
* AFTER the loop's end is refused typed (`steering.not_running`, every verb alike) and mints
|
|
401
401
|
* nothing: the refusal itself is the loud answer.
|
|
402
402
|
*
|
|
403
|
+
* - `"approval.read_root_granted"` / `"approval.read_root_grant_rejected"` — an approver's `readRootGrant`
|
|
404
|
+
* (`AskOutcome`). GRANTED: it equalled the card's `readRootCandidate.dir` beside `allow: true`; this run's READ
|
|
405
|
+
* directories now include it (never the write roots; this run only); `detail: { dir, covers?, root, toolName,
|
|
406
|
+
* toolCallId, sessionId, runId, taskId? }`, `root` = the canonical spelling held. REJECTED: nothing changed,
|
|
407
|
+
* the verdict stands; `detail: { reason, offered?, requested?, …same ids }`, `reason` ∈ `on_refusal` |
|
|
408
|
+
* `not_a_string` | `no_candidate` | `mismatch` | `unresolvable` | `resolves_elsewhere` | `admits_artifact_store`
|
|
409
|
+
* (the directory contains the wired local artifact store, lies inside it or is it) | `no_read_roots`. Once per
|
|
410
|
+
* answered ask. Audience `"user"`.
|
|
411
|
+
*
|
|
403
412
|
* - `"checkpoint.execution_outcome_unrecorded"` — the resumed leg's pending-call resolver disposed
|
|
404
413
|
* the decided action (executed it, delivered the refusal, or vetoed the person's yes) and filed
|
|
405
414
|
* the frame's record through `CheckpointStore.recordExecutionOutcome`, and the store answered
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -467,7 +467,7 @@ export async function runGateLanes(pass) {
|
|
|
467
467
|
...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
|
|
468
468
|
...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
|
|
469
469
|
...(d.action === "ask" && d.ruleStoreUnreadable !== undefined ? { ruleStoreUnreadable: d.ruleStoreUnreadable } : {}),
|
|
470
|
-
...(d.action === "ask" && d.mandated === true ? { mandated: true } : {}),
|
|
470
|
+
...(d.action === "ask" && d.mandated === true ? { mandated: true, ...(d.mandate !== undefined ? { mandate: d.mandate } : {}) } : {}),
|
|
471
471
|
});
|
|
472
472
|
if (suspendAsk && pass.decision.action === "ask") {
|
|
473
473
|
const parkArgs = [pass.req, pass.currentInput, safety, undefined, realApprovalOf(pass.decision), pass.decision.action === "ask" ? pass.decision.persistedRuleShadowed : undefined, pass.decision.action === "ask" ? pass.decision.decisionReason : undefined, pass.decision.action === "ask" ? pass.decision.probeReason : undefined, pass.decision.action === "ask" ? pass.decision.probeCause : undefined, pass.decision.action === "ask" ? pass.decision.segmentCoverage : undefined, pass.decision.action === "ask" ? pass.decision.matchedAskRule : undefined, pass.decision.action === "ask" ? pass.decision.probeMandated : undefined];
|
|
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
|
|
|
119
119
|
* the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
|
|
120
120
|
* so the catalog is not written a second time here and the two cannot drift.
|
|
121
121
|
*/
|
|
122
|
-
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
|
|
122
|
+
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
|
|
123
123
|
/** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
|
|
124
124
|
* `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
|
|
125
125
|
* sink is a supported shape, and narrowing that field would break it. */
|
|
@@ -83,6 +83,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
|
|
|
83
83
|
"memory.capture_optout_unpersisted",
|
|
84
84
|
"memory.capture_optout_unavailable",
|
|
85
85
|
"memory.capture_optout_sweep_failed",
|
|
86
|
+
"memory.target_outside_store",
|
|
86
87
|
]);
|
|
87
88
|
export function governanceRetryClass(code) {
|
|
88
89
|
if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
|
|
@@ -168,6 +169,8 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
168
169
|
"task.halt_unconsumed",
|
|
169
170
|
"task.late_approval",
|
|
170
171
|
"task.interrupt_unconsumed",
|
|
172
|
+
"approval.read_root_granted",
|
|
173
|
+
"approval.read_root_grant_rejected",
|
|
171
174
|
"memory.capture_opted_out",
|
|
172
175
|
"memory.capture_optout_unpersisted",
|
|
173
176
|
"skills.listing_truncated",
|
|
@@ -192,6 +195,8 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
192
195
|
"task.halt_unconsumed": "user",
|
|
193
196
|
"task.late_approval": "user",
|
|
194
197
|
"task.interrupt_unconsumed": "user",
|
|
198
|
+
"approval.read_root_granted": "user",
|
|
199
|
+
"approval.read_root_grant_rejected": "user",
|
|
195
200
|
"config.durable_gate_unavailable": "user",
|
|
196
201
|
"memory.capture_opted_out": "user",
|
|
197
202
|
"memory.capture_optout_unpersisted": "user",
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1714,6 +1714,8 @@ export interface AskCarry {
|
|
|
1714
1714
|
/** The stamped mandate ({@link import("./tool-policy.js").AskRequest.mandated}) — the park site's
|
|
1715
1715
|
* rule-offer facts read it, so a parked mandated ask offers no rule, like the live card. */
|
|
1716
1716
|
readonly mandated?: true;
|
|
1717
|
+
/** The table word stamped beside it ({@link import("./tool-policy.js").AskRequest.mandate}), for the row's twin. */
|
|
1718
|
+
readonly mandate?: import("./ask-origin.js").PersistedRuleMandate;
|
|
1717
1719
|
}
|
|
1718
1720
|
/** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
|
|
1719
1721
|
* the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
|
|
@@ -833,6 +833,8 @@ export interface SessionMemoryStatus {
|
|
|
833
833
|
export declare class MemoryEngine {
|
|
834
834
|
private readonly backend;
|
|
835
835
|
private readonly memoryDir;
|
|
836
|
+
/** {@link memoryDir}'s physical form as this engine first mounted it; every later mount compares, never re-resolves. */
|
|
837
|
+
private mountedRoot;
|
|
836
838
|
private readonly controlDir;
|
|
837
839
|
private readonly now;
|
|
838
840
|
private readonly perScopeBudgetBytes;
|
|
@@ -1776,10 +1778,17 @@ export declare class MemoryEngine {
|
|
|
1776
1778
|
* @returns the rejection to report, or `undefined` when the index is clean/absent.
|
|
1777
1779
|
*/
|
|
1778
1780
|
private gateDerivedIndex;
|
|
1781
|
+
/** The file backend's verdict at an act the engine itself performs on a store path ({@link
|
|
1782
|
+
* storeTargetEscape}): throws `memory.target_outside_store` when `path` would land outside `root`;
|
|
1783
|
+
* each caller's existing failure lane reports it. `root` is the mount's canonical root where a
|
|
1784
|
+
* handle exists, so a root re-pointed after the mount is judged against the one it mounted. */
|
|
1785
|
+
private refuseOutsideStore;
|
|
1779
1786
|
/** Projection write: reads the target to skip a no-op, then writes — the same check-then-use shape
|
|
1780
1787
|
* as the containment restores, so it takes the same no-follow write. A link at a projection path
|
|
1781
1788
|
* is never a supported layout (the harvest scan refuses symlinked entry files outright), and this
|
|
1782
|
-
* site already reports write failures by throwing, so ELOOP joins the failures it already had.
|
|
1789
|
+
* site already reports write failures by throwing, so ELOOP joins the failures it already had. A
|
|
1790
|
+
* target whose directory resolves outside `root` (a directory on the path is a link) is refused
|
|
1791
|
+
* the same way, before anything is read or written ({@link storeTargetEscape}). */
|
|
1783
1792
|
private writeIfChanged;
|
|
1784
1793
|
/** Persist the canonical projection back to the session file (id minting / frontmatter completion).
|
|
1785
1794
|
* C-F6 (S2-0): called ONLY after the backend transaction committed — never ahead of the journal
|
|
@@ -17,7 +17,7 @@ import { landedUndecided, layerDigest, layerObservationLookup, readOnlyLayerCand
|
|
|
17
17
|
import { markOriginClearanceTombstoned, openOriginClearance, readOriginClearances, settleOriginClearance } from "./origin-clearance.js";
|
|
18
18
|
import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, canonicalJsonStringify, captureErasureInput, erasureRequestInvalid, erasureSelectHash, scanEntryFiles, } from "./file-backend.js";
|
|
19
19
|
import { assembleMemoryExportBundle, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
|
|
20
|
-
import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, restoreContainedBaseline, readIndexRevs, writeIndexRevs, bumpScanFuse, scanFuseKey, canonicalize, adoptRootScopeLayout, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, markSessionPolluted, readSessionPollution, fileSessionCaptureRecordStore, recordRetrievedAccount, writeFileNoFollow, withPermissionRemedy, permissionRemedyOf, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
|
|
20
|
+
import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, restoreContainedBaseline, readIndexRevs, writeIndexRevs, bumpScanFuse, scanFuseKey, canonicalize, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, adoptRootScopeLayout, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, markSessionPolluted, readSessionPollution, fileSessionCaptureRecordStore, recordRetrievedAccount, writeFileNoFollow, withPermissionRemedy, permissionRemedyOf, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
|
|
21
21
|
import { createSessionIncarnationSeat, recalledForAnyIdentity, recalledForIdentity, rememberForIdentity } from "./session-incarnation.js";
|
|
22
22
|
import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
|
|
23
23
|
import { writableRootOf } from "./types.js";
|
|
@@ -310,6 +310,7 @@ const STALE_LEG_NOTE = "memory session account: this harvest belongs to an earli
|
|
|
310
310
|
export class MemoryEngine {
|
|
311
311
|
backend;
|
|
312
312
|
memoryDir;
|
|
313
|
+
mountedRoot;
|
|
313
314
|
controlDir;
|
|
314
315
|
now;
|
|
315
316
|
perScopeBudgetBytes;
|
|
@@ -427,7 +428,7 @@ export class MemoryEngine {
|
|
|
427
428
|
}
|
|
428
429
|
return this.backend.readCommittedShadow?.(id) ?? readSafe(join(this.controlDir, "shadow", `${id}.md`));
|
|
429
430
|
}
|
|
430
|
-
async restoreReadOnlyLayers(candidates, report, selfLeg) {
|
|
431
|
+
async restoreReadOnlyLayers(candidates, report, selfLeg, root) {
|
|
431
432
|
let writers;
|
|
432
433
|
for (const c of candidates) {
|
|
433
434
|
const text = readSafe(c.path);
|
|
@@ -450,6 +451,7 @@ export class MemoryEngine {
|
|
|
450
451
|
continue;
|
|
451
452
|
}
|
|
452
453
|
try {
|
|
454
|
+
this.refuseOutsideStore(root, c.path, "restoring the read-only layer file");
|
|
453
455
|
writeFileNoFollow(c.path, committed);
|
|
454
456
|
report.restored.push(c.relPath);
|
|
455
457
|
}
|
|
@@ -755,6 +757,7 @@ export class MemoryEngine {
|
|
|
755
757
|
const indexPath = join(writableRoot, MEMORY_INDEX_FILENAME);
|
|
756
758
|
const baseline = handle.planeBaseline !== undefined ? handle.planeBaseline.index : handle.indexText;
|
|
757
759
|
try {
|
|
760
|
+
this.refuseOutsideStore(handle.memoryDir, indexPath, "sweeping the index");
|
|
758
761
|
const first = existsSync(indexPath) ? readSafe(indexPath) : undefined;
|
|
759
762
|
if (first !== undefined && first !== baseline) {
|
|
760
763
|
const onDisk = readSafe(indexPath);
|
|
@@ -1088,6 +1091,7 @@ export class MemoryEngine {
|
|
|
1088
1091
|
if (kept.length === lines.length)
|
|
1089
1092
|
continue;
|
|
1090
1093
|
try {
|
|
1094
|
+
this.refuseOutsideStore(landingPathOf(this.memoryDir), indexPath, "rewriting the index");
|
|
1091
1095
|
writeFileNoFollow(indexPath, kept.join("\n"));
|
|
1092
1096
|
}
|
|
1093
1097
|
catch {
|
|
@@ -1409,6 +1413,13 @@ export class MemoryEngine {
|
|
|
1409
1413
|
}
|
|
1410
1414
|
async materializeCore(scopes, writeScope, opts, legSeat) {
|
|
1411
1415
|
ensureDirExists(this.memoryDir);
|
|
1416
|
+
const physicalRoot = landingPathOf(this.memoryDir);
|
|
1417
|
+
this.mountedRoot ??= physicalRoot;
|
|
1418
|
+
if (physicalRoot !== this.mountedRoot) {
|
|
1419
|
+
const e = new Error(`${TARGET_OUTSIDE_STORE}: the memory dir ${this.memoryDir} now resolves to ${physicalRoot}, not ${this.mountedRoot} where this engine first mounted it — refused; open a new engine on the directory you mean`);
|
|
1420
|
+
e.code = TARGET_OUTSIDE_STORE;
|
|
1421
|
+
throw e;
|
|
1422
|
+
}
|
|
1412
1423
|
ensureDirExists(this.controlDir);
|
|
1413
1424
|
await this.adoptLayoutBeforeMount(writeScope);
|
|
1414
1425
|
const restricted = opts?.adoptionRestricted === true;
|
|
@@ -1593,6 +1604,7 @@ export class MemoryEngine {
|
|
|
1593
1604
|
if (handle.writableRoot !== null)
|
|
1594
1605
|
ensureDirExists(handle.writableRoot);
|
|
1595
1606
|
const zeroCopy = this.backendPinnedRoot !== undefined && canonicalize(this.backendPinnedRoot) === canonicalize(this.memoryDir);
|
|
1607
|
+
const projectionRoot = zeroCopy ? undefined : this.mountedRoot;
|
|
1596
1608
|
for (const [scope, dir] of scopeDirs) {
|
|
1597
1609
|
const scopeEntries = (byScope.get(scope) ?? []).slice();
|
|
1598
1610
|
scopeEntries.sort((a, b) => (mtimeById.get(b.id) ?? 0) - (mtimeById.get(a.id) ?? 0));
|
|
@@ -1614,8 +1626,8 @@ export class MemoryEngine {
|
|
|
1614
1626
|
const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
|
|
1615
1627
|
const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
|
|
1616
1628
|
const target = join(dir, `${entry.slug}.md`);
|
|
1617
|
-
if (
|
|
1618
|
-
this.writeIfChanged(target, text);
|
|
1629
|
+
if (projectionRoot !== undefined)
|
|
1630
|
+
this.writeIfChanged(target, text, projectionRoot);
|
|
1619
1631
|
const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
|
|
1620
1632
|
const path = canonicalize(target);
|
|
1621
1633
|
handle.materialized.push({
|
|
@@ -2176,7 +2188,7 @@ export class MemoryEngine {
|
|
|
2176
2188
|
signalContainment((c) => (c.indexRolledBack = true));
|
|
2177
2189
|
report.warnings.push(`memory harvest refused by an incident while this session is marked externally exposed — no entries were committed; instruction-form files were withheld and the derived index rolled back: ${inlineUntrusted(pollutedReason, 200)}`);
|
|
2178
2190
|
};
|
|
2179
|
-
await this.restoreReadOnlyLayers(readOnlyRestorable, report, handle.accountLeg);
|
|
2191
|
+
await this.restoreReadOnlyLayers(readOnlyRestorable, report, handle.accountLeg, handle.memoryDir);
|
|
2180
2192
|
const missingWritable = writable.filter((m) => !recordByPath.has(m.path) && !existsSync(m.path) && !presentIds.has(m.id));
|
|
2181
2193
|
report.missing.push(...missingWritable.map((m) => m.relPath));
|
|
2182
2194
|
if (writable.length > 0 && missingWritable.length / writable.length > MASS_DELETION_FUSE_RATIO) {
|
|
@@ -2203,6 +2215,7 @@ export class MemoryEngine {
|
|
|
2203
2215
|
if (content === undefined)
|
|
2204
2216
|
continue;
|
|
2205
2217
|
try {
|
|
2218
|
+
this.refuseOutsideStore(handle.memoryDir, m.path, "restoring");
|
|
2206
2219
|
mkdirSync(dirname(m.path), { recursive: true });
|
|
2207
2220
|
writeFileNoFollow(m.path, content);
|
|
2208
2221
|
report.restored.push(m.relPath);
|
|
@@ -4818,6 +4831,7 @@ export class MemoryEngine {
|
|
|
4818
4831
|
if (opts.write) {
|
|
4819
4832
|
let wrote = false;
|
|
4820
4833
|
try {
|
|
4834
|
+
this.refuseOutsideStore(handle.memoryDir, indexPath, "writing the index");
|
|
4821
4835
|
mkdirSync(dirname(indexPath), { recursive: true });
|
|
4822
4836
|
if (readNoFollowSafe(indexPath) !== text)
|
|
4823
4837
|
writeFileNoFollow(indexPath, text);
|
|
@@ -4908,6 +4922,7 @@ export class MemoryEngine {
|
|
|
4908
4922
|
}
|
|
4909
4923
|
let cleared = false;
|
|
4910
4924
|
try {
|
|
4925
|
+
this.refuseOutsideStore(handle.memoryDir, indexPath, "clearing the index");
|
|
4911
4926
|
writeFileNoFollow(indexPath, "");
|
|
4912
4927
|
cleared = true;
|
|
4913
4928
|
}
|
|
@@ -4927,7 +4942,16 @@ export class MemoryEngine {
|
|
|
4927
4942
|
contained: cleared,
|
|
4928
4943
|
};
|
|
4929
4944
|
}
|
|
4930
|
-
|
|
4945
|
+
refuseOutsideStore(root, path, act) {
|
|
4946
|
+
const escape = storeTargetEscape(root, path);
|
|
4947
|
+
if (escape === undefined)
|
|
4948
|
+
return;
|
|
4949
|
+
const e = new Error(`${TARGET_OUTSIDE_STORE}: ${act} ${path} was refused — ${escape}; not performed`);
|
|
4950
|
+
e.code = TARGET_OUTSIDE_STORE;
|
|
4951
|
+
throw e;
|
|
4952
|
+
}
|
|
4953
|
+
writeIfChanged(target, text, root) {
|
|
4954
|
+
this.refuseOutsideStore(root, target, "the projection write of");
|
|
4931
4955
|
const current = readNoFollowSafe(target);
|
|
4932
4956
|
if (current === text)
|
|
4933
4957
|
return;
|
|
@@ -353,6 +353,8 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
353
353
|
* NEVER under {@link directoryRoot}, always on the config-root side even for an in-repo memory dir.
|
|
354
354
|
* The engine picks this up (like `directoryRoot`) so both sides share ONE control plane. */
|
|
355
355
|
readonly controlPlaneRoot: string;
|
|
356
|
+
/** {@link directoryRoot}'s physical form at construction — the root every act on a store path is judged against. */
|
|
357
|
+
private readonly physicalRoot;
|
|
356
358
|
/** Injected clock (opus 1.258 LOW: match the engine's `this.now` determinism discipline). */
|
|
357
359
|
private readonly now;
|
|
358
360
|
/** The construction-time incident sink (partition adoption, layout adoption on the standalone faces). */
|
|
@@ -679,6 +681,19 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
679
681
|
* §3.1 ⓪ (r5-①): "no lock" is ENOENT-only — a stat FAILURE folded into "nobody is committing"
|
|
680
682
|
* let a transient EIO run the unlocked recovery (and its staged sweep) over a live transaction. */
|
|
681
683
|
private txnInFlight;
|
|
684
|
+
/**
|
|
685
|
+
* The act gate for store paths ({@link storeTargetEscape}) over `ops`' writes and deletes — shadow
|
|
686
|
+
* ops name control-plane files, not store paths. Refuses with `memory.target_outside_store` at the
|
|
687
|
+
* first op that would land outside the store, naming the op and where it resolves. Runs over a
|
|
688
|
+
* whole batch before its commit marker (no journal is written; a caller that staged files removes
|
|
689
|
+
* them) and per op at the act itself; past the marker a refusal keeps the journal, which replays
|
|
690
|
+
* once the path resolves inside the store again.
|
|
691
|
+
*/
|
|
692
|
+
private refuseEscapingTargets;
|
|
693
|
+
/** At the head of every face (both recovery legs, the lock-free faces' layout seat, the adoption face):
|
|
694
|
+
* the store root still resolves to the directory this backend opened. A root re-pointed since would
|
|
695
|
+
* carry every read, adoption, sweep and restore — not only the journal's acts — into another directory. */
|
|
696
|
+
private assertRootIntact;
|
|
682
697
|
/** Remove orphaned `.stage-*` files (a crash BEFORE the journal rename = clean rollback: nothing
|
|
683
698
|
* was committed, the staged tmps are litter). Dotfiles are invisible to entry scans regardless. */
|
|
684
699
|
private sweepStagedFiles;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
|
|
1
|
+
import { existsSync, fstatSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, join, relative } from "node:path";
|
|
4
4
|
import { jaccardDistance, termSet } from "../memory-vector.js";
|
|
@@ -8,7 +8,7 @@ import { ambiguousOriginRepresentation, committedDistilledOf, committedOriginOf,
|
|
|
8
8
|
import { convertV1SidecarKeys } from "./delegation-settlement.js";
|
|
9
9
|
import { layerObservationLookup, observedDivergenceNotice } from "./layer-observations.js";
|
|
10
10
|
import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
|
|
11
|
-
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
|
|
11
|
+
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, writeAllSync, } from "./layout.js";
|
|
12
12
|
import { screenInboundEntries } from "./data-plane.js";
|
|
13
13
|
import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
|
|
14
14
|
import { thrownText } from "../thrown-value.js";
|
|
@@ -522,6 +522,7 @@ function probeProjection(absPath, id) {
|
|
|
522
522
|
export class FileMemoryEngineBackend {
|
|
523
523
|
directoryRoot;
|
|
524
524
|
controlPlaneRoot;
|
|
525
|
+
physicalRoot;
|
|
525
526
|
now;
|
|
526
527
|
onIncident;
|
|
527
528
|
ledger;
|
|
@@ -545,6 +546,7 @@ export class FileMemoryEngineBackend {
|
|
|
545
546
|
adoptCanonicalKeyedControlDir(resolveMemoryEngineRoot(), dir, opts.onIncident);
|
|
546
547
|
this.controlPlaneRoot = opts.controlDir ?? deriveControlPlaneDir(resolveMemoryEngineRoot(), dir);
|
|
547
548
|
ensureDirExists(dir);
|
|
549
|
+
this.physicalRoot = landingPathOf(dir);
|
|
548
550
|
ensureDirExists(this.controlPlaneRoot);
|
|
549
551
|
this.recoverJournal({ unlocked: true });
|
|
550
552
|
try {
|
|
@@ -577,6 +579,7 @@ export class FileMemoryEngineBackend {
|
|
|
577
579
|
});
|
|
578
580
|
}
|
|
579
581
|
ensureLayout(ownerCandidate) {
|
|
582
|
+
this.assertRootIntact();
|
|
580
583
|
if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
|
|
581
584
|
return;
|
|
582
585
|
const lock = this.tryAcquireTxnLockSync();
|
|
@@ -591,6 +594,7 @@ export class FileMemoryEngineBackend {
|
|
|
591
594
|
}
|
|
592
595
|
}
|
|
593
596
|
async adoptRootScopeLayout(opts = {}) {
|
|
597
|
+
this.assertRootIntact();
|
|
594
598
|
if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
|
|
595
599
|
return;
|
|
596
600
|
const lock = await this.acquireTxnLock();
|
|
@@ -1211,6 +1215,7 @@ export class FileMemoryEngineBackend {
|
|
|
1211
1215
|
}
|
|
1212
1216
|
}
|
|
1213
1217
|
recoverJournal(fence) {
|
|
1218
|
+
this.assertRootIntact();
|
|
1214
1219
|
if (fence.token === undefined) {
|
|
1215
1220
|
if (this.txnInFlight())
|
|
1216
1221
|
return;
|
|
@@ -1399,7 +1404,10 @@ export class FileMemoryEngineBackend {
|
|
|
1399
1404
|
}
|
|
1400
1405
|
}
|
|
1401
1406
|
this.assertTxnLockOwnership(fenceToken);
|
|
1407
|
+
const replayAt = `the journal replay (journal kept at ${jp})`;
|
|
1408
|
+
this.refuseEscapingTargets(journal.ops, replayAt);
|
|
1402
1409
|
for (const op of journal.ops) {
|
|
1410
|
+
this.refuseEscapingTargets([op], replayAt);
|
|
1403
1411
|
try {
|
|
1404
1412
|
if (op.kind === "write" && op.content !== undefined) {
|
|
1405
1413
|
atomicWriteFileSync(op.target, op.content);
|
|
@@ -1457,6 +1465,24 @@ export class FileMemoryEngineBackend {
|
|
|
1457
1465
|
throw new ControlPlaneCorruptError(`txn lock state could not be probed (${code ?? "io error"}) at ${this.txnLockDir()} — a probe failure is not "no writer" (fail-closed)`);
|
|
1458
1466
|
}
|
|
1459
1467
|
}
|
|
1468
|
+
refuseEscapingTargets(ops, at) {
|
|
1469
|
+
for (const op of ops) {
|
|
1470
|
+
if (op.kind !== "write" && op.kind !== "delete")
|
|
1471
|
+
continue;
|
|
1472
|
+
for (const path of op.staged === undefined ? [op.target] : [op.target, op.staged]) {
|
|
1473
|
+
const escape = storeTargetEscape(this.physicalRoot, path);
|
|
1474
|
+
if (escape === undefined)
|
|
1475
|
+
continue;
|
|
1476
|
+
throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: ${at} refused a ${op.kind} of ${op.target}${path === op.target ? "" : ` (its staged file ${path})`} — ${escape}; not performed`, TARGET_OUTSIDE_STORE);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
assertRootIntact() {
|
|
1481
|
+
const now = landingPathOf(this.directoryRoot);
|
|
1482
|
+
if (now === this.physicalRoot)
|
|
1483
|
+
return;
|
|
1484
|
+
throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: the store root ${this.directoryRoot} now resolves to ${now}, not ${this.physicalRoot} where this backend opened it — refused; open a new backend on the directory you mean`, TARGET_OUTSIDE_STORE);
|
|
1485
|
+
}
|
|
1460
1486
|
sweepStagedFiles() {
|
|
1461
1487
|
const walk = (d, depth) => {
|
|
1462
1488
|
let names;
|
|
@@ -2366,16 +2392,37 @@ export class FileMemoryEngineBackend {
|
|
|
2366
2392
|
return report;
|
|
2367
2393
|
}
|
|
2368
2394
|
const txn = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2395
|
+
const stagedFiles = [];
|
|
2396
|
+
const refuseUnstaging = (batch, at) => {
|
|
2397
|
+
try {
|
|
2398
|
+
this.refuseEscapingTargets(batch, at);
|
|
2399
|
+
}
|
|
2400
|
+
catch (err) {
|
|
2401
|
+
for (const f of stagedFiles) {
|
|
2402
|
+
try {
|
|
2403
|
+
const st = lstatSync(f.path);
|
|
2404
|
+
if (st.dev === f.dev && st.ino === f.ino)
|
|
2405
|
+
rmSync(f.path, { force: true });
|
|
2406
|
+
}
|
|
2407
|
+
catch {
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
throw err;
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2369
2413
|
let n = 0;
|
|
2370
2414
|
for (const op of ops) {
|
|
2371
2415
|
if (op.kind !== "write" || op.content === undefined)
|
|
2372
2416
|
continue;
|
|
2417
|
+
refuseUnstaging([op], "applyPatches (staging)");
|
|
2373
2418
|
const staged = join(dirname(op.target), `.stage-${txn}-${n++}`);
|
|
2374
2419
|
mkdirSync(dirname(op.target), { recursive: true });
|
|
2375
2420
|
const fd = openSync(staged, "w", 0o600);
|
|
2376
2421
|
try {
|
|
2377
2422
|
writeAllSync(fd, op.content);
|
|
2378
2423
|
fsyncSync(fd);
|
|
2424
|
+
const st = fstatSync(fd);
|
|
2425
|
+
stagedFiles.push({ path: staged, dev: st.dev, ino: st.ino });
|
|
2379
2426
|
}
|
|
2380
2427
|
finally {
|
|
2381
2428
|
closeSync(fd);
|
|
@@ -2393,8 +2440,10 @@ export class FileMemoryEngineBackend {
|
|
|
2393
2440
|
if (coherentTransfers.length > 0)
|
|
2394
2441
|
this.precheckTransfersAppendable();
|
|
2395
2442
|
const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), ...(coherentTransfers.length > 0 ? { transfers: coherentTransfers } : {}) };
|
|
2443
|
+
refuseUnstaging(ops, "applyPatches (commit point)");
|
|
2396
2444
|
atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
|
|
2397
2445
|
for (const op of ops) {
|
|
2446
|
+
this.refuseEscapingTargets([op], "applyPatches (after the commit point; the journal is kept)");
|
|
2398
2447
|
if (op.kind === "write" && op.staged) {
|
|
2399
2448
|
renameSync(op.staged, op.target);
|
|
2400
2449
|
}
|
|
@@ -2819,8 +2868,10 @@ export class FileMemoryEngineBackend {
|
|
|
2819
2868
|
this.assertTxnLockOwnership(lock.token);
|
|
2820
2869
|
this.precheckTransfersAppendable();
|
|
2821
2870
|
const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), transfers };
|
|
2871
|
+
this.refuseEscapingTargets(ops, "eraseWithEvidence (commit point)");
|
|
2822
2872
|
atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
|
|
2823
2873
|
for (const op of ops) {
|
|
2874
|
+
this.refuseEscapingTargets([op], "eraseWithEvidence (after the commit point; the journal is kept)");
|
|
2824
2875
|
if (op.kind === "delete") {
|
|
2825
2876
|
rmSync(op.target, { force: true });
|
|
2826
2877
|
}
|
|
@@ -259,6 +259,28 @@ export declare function adoptRootScopeLayout(rootPath: string, controlDir: strin
|
|
|
259
259
|
export declare const canonicalize: typeof physicalPathOfExisting;
|
|
260
260
|
/** True iff `child` (canonicalized) is `root` or inside it. */
|
|
261
261
|
export declare function isContainedIn(root: string, child: string): boolean;
|
|
262
|
+
/** The code an act on a store path is refused with when it would land outside the store ({@link storeTargetEscape}). */
|
|
263
|
+
export declare const TARGET_OUTSIDE_STORE = "memory.target_outside_store";
|
|
264
|
+
/** Where an act at `p` lands: the physical path of its normalized spelling (the spelling a child path
|
|
265
|
+
* joined under it carries), a MISSING tail rejoined under the deepest existing directory (what the act
|
|
266
|
+
* creates). Any other resolution fault (EACCES, ELOOP, ENOTDIR) is THROWN as itself: an act that
|
|
267
|
+
* cannot be placed is not performed — a failed resolution never implies the act would fail too
|
|
268
|
+
* (a link whose own mode refuses `realpath` is still followed by `unlink` and `rename`). */
|
|
269
|
+
export declare function landingPathOf(p: string): string;
|
|
270
|
+
/**
|
|
271
|
+
* Where a write, rename or delete at `target` would physically LAND, judged against the store's
|
|
272
|
+
* `physicalRoot`: `undefined` = inside, otherwise the reason. The target's directory, resolved through
|
|
273
|
+
* every link on its existing prefix (a missing tail is what the act creates, under the deepest existing
|
|
274
|
+
* directory), must be the root or inside it. `physicalRoot` is the root's physical form as the store
|
|
275
|
+
* was opened and is not re-resolved here: a store mounted through a link is judged by what the link
|
|
276
|
+
* resolved to, and a root spelling re-pointed after opening moves the acts, not the store. The final
|
|
277
|
+
* component is not judged: `rename` and `unlink` act on it and never through it, and the engine's
|
|
278
|
+
* writes open it no-follow — a link there is replaced, removed or refused, and what it points at is
|
|
279
|
+
* untouched. A lexical spelling under the root proves nothing: a directory under it replaced by a link
|
|
280
|
+
* keeps the spelling while the act follows the link out. A directory that cannot be resolved throws its
|
|
281
|
+
* fault ({@link landingPathOf}).
|
|
282
|
+
*/
|
|
283
|
+
export declare function storeTargetEscape(physicalRoot: string, target: string): string | undefined;
|
|
262
284
|
/** mkdir -p convenience used by the engine/backend (0o700 like the file stores). */
|
|
263
285
|
export declare function ensureDirExists(dir: string): void;
|
|
264
286
|
/** The one remedy every surfaced permission refusal carries — `undefined` unless `err` is a write
|
|
@@ -324,10 +346,13 @@ export interface QuarantineOutcome {
|
|
|
324
346
|
* without native bindings. What this buys is the leaf variant, which is the cheap one; the ancestor
|
|
325
347
|
* variant stays open and is recorded with the rest of the family rather than papered over.
|
|
326
348
|
*
|
|
327
|
-
* Throws like `writeFileSync`, including ELOOP when the final component IS a symlink.
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
349
|
+
* Throws like `writeFileSync`, including ELOOP when the final component IS a symlink. A HARD link is the
|
|
350
|
+
* same tamper by other means — the file's bytes live under a second name, possibly outside the store, and
|
|
351
|
+
* an in-place write would change them there too — so an opened file with more than one name is refused
|
|
352
|
+
* the same way, before anything is truncated (the check reads the opened descriptor: no window). The
|
|
353
|
+
* write itself goes through `writeFileSync` on the open descriptor, which writes every byte or throws —
|
|
354
|
+
* a bare `writeSync` may short-write, and reporting a truncated restore as contained (after the
|
|
355
|
+
* truncation already destroyed the destination) is the failure this guard exists to prevent.
|
|
331
356
|
*/
|
|
332
357
|
export declare function writeFileNoFollow(path: string, content: string): void;
|
|
333
358
|
/** {@link writeFileNoFollow}'s NO-REPLACE twin: write `content` only if nothing is at `path`. `"written"`
|