pi-daddy 0.46.1 → 0.47.1
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 +14 -0
- package/README.md +41 -11
- package/contracts/ledger-record/v1/governance-event.schema.json +65 -3
- package/dist/governance/approval-prompt.d.ts +4 -0
- package/dist/governance/approval-prompt.d.ts.map +1 -1
- package/dist/governance/approval-prompt.js +59 -4
- package/dist/governance/approval-prompt.js.map +1 -1
- package/dist/governance/auto-mode-policy.d.ts +11 -0
- package/dist/governance/auto-mode-policy.d.ts.map +1 -0
- package/dist/governance/auto-mode-policy.js +309 -0
- package/dist/governance/auto-mode-policy.js.map +1 -0
- package/dist/governance/execution-retention.d.ts +3 -0
- package/dist/governance/execution-retention.d.ts.map +1 -1
- package/dist/governance/execution-retention.js +30 -0
- package/dist/governance/execution-retention.js.map +1 -1
- package/dist/governance/ledger-events.d.ts +14 -1
- package/dist/governance/ledger-events.d.ts.map +1 -1
- package/dist/governance/ledger-events.js +13 -0
- package/dist/governance/ledger-events.js.map +1 -1
- package/dist/governance/ledger-report.d.ts.map +1 -1
- package/dist/governance/ledger-report.js +12 -3
- package/dist/governance/ledger-report.js.map +1 -1
- package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
- package/dist/governance/ledger-v3-validation.js +17 -3
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/ledger.d.ts +1 -1
- package/dist/governance/ledger.d.ts.map +1 -1
- package/dist/governance/ledger.js +1 -1
- package/dist/governance/ledger.js.map +1 -1
- package/dist/kernel/approval.d.ts +1 -1
- package/dist/kernel/approval.d.ts.map +1 -1
- package/dist/kernel/approval.js +1 -1
- package/dist/kernel/approval.js.map +1 -1
- package/dist/kernel/auto-mode.d.ts +35 -0
- package/dist/kernel/auto-mode.d.ts.map +1 -0
- package/dist/kernel/auto-mode.js +29 -0
- package/dist/kernel/auto-mode.js.map +1 -0
- package/dist/kernel/delegate-types.d.ts +2 -0
- package/dist/kernel/delegate-types.d.ts.map +1 -1
- package/dist/kernel/delegate-types.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +2 -2
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +2 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +4 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +5 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +8 -2
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/products/dashboard-cli.d.ts +6 -0
- package/dist/products/dashboard-cli.d.ts.map +1 -1
- package/dist/products/dashboard-cli.js +249 -52
- package/dist/products/dashboard-cli.js.map +1 -1
- package/dist/products/dashboard-display-controls.d.ts +2 -1
- package/dist/products/dashboard-display-controls.d.ts.map +1 -1
- package/dist/products/dashboard-display-controls.js +61 -0
- package/dist/products/dashboard-display-controls.js.map +1 -1
- package/dist/products/dashboard-render.d.ts +2 -0
- package/dist/products/dashboard-render.d.ts.map +1 -1
- package/dist/products/dashboard-render.js +2 -2
- package/dist/products/dashboard-render.js.map +1 -1
- package/dist/products/dashboard-screen.d.ts +52 -0
- package/dist/products/dashboard-screen.d.ts.map +1 -0
- package/dist/products/dashboard-screen.js +262 -0
- package/dist/products/dashboard-screen.js.map +1 -0
- package/dist/products/dashboard-session-client.d.ts +34 -2
- package/dist/products/dashboard-session-client.d.ts.map +1 -1
- package/dist/products/dashboard-session-client.js +51 -0
- package/dist/products/dashboard-session-client.js.map +1 -1
- package/dist/products/ecosystem-versions.d.ts +19 -0
- package/dist/products/ecosystem-versions.d.ts.map +1 -0
- package/dist/products/ecosystem-versions.js +2 -0
- package/dist/products/ecosystem-versions.js.map +1 -0
- package/extensions/approvals.ts +3 -2
- package/extensions/dashboard-session-server.ts +32 -4
- package/extensions/delegate-chain.ts +2 -0
- package/extensions/delegation-ledger.ts +10 -6
- package/extensions/ecosystem-versions.ts +166 -0
- package/extensions/grants-command.ts +33 -3
- package/extensions/grants.ts +28 -2
- package/extensions/reload-environment.ts +16 -1
- package/extensions/run-delegation.ts +49 -3
- package/extensions/session-auto-mode.ts +132 -0
- package/extensions/session.ts +47 -37
- package/package.json +2 -2
- package/src/governance/approval-prompt.ts +62 -4
- package/src/governance/auto-mode-policy.ts +297 -0
- package/src/governance/execution-retention.ts +24 -0
- package/src/governance/ledger-events.ts +22 -1
- package/src/governance/ledger-report.ts +14 -3
- package/src/governance/ledger-v3-validation.ts +17 -3
- package/src/governance/ledger.ts +1 -0
- package/src/kernel/approval.ts +1 -1
- package/src/kernel/auto-mode.ts +56 -0
- package/src/kernel/delegate-types.ts +2 -0
- package/src/kernel/delegate.ts +2 -1
- package/src/kernel/env-names.ts +4 -0
- package/src/kernel/propagation.ts +11 -2
- package/src/products/dashboard-cli.ts +256 -53
- package/src/products/dashboard-display-controls.ts +52 -0
- package/src/products/dashboard-render.ts +2 -2
- package/src/products/dashboard-screen.ts +339 -0
- package/src/products/dashboard-session-client.ts +60 -1
- package/src/products/ecosystem-versions.ts +18 -0
package/extensions/session.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { EcosystemVersions } from "../src/products/ecosystem-versions.ts";
|
|
2
|
+
import type { AutoModeReader } from "../src/kernel/auto-mode.ts";
|
|
1
3
|
/**
|
|
2
4
|
* The session object — the one place this extension's state is named.
|
|
3
5
|
*
|
|
@@ -141,6 +143,9 @@ export function activityChildEnv(activity: { rootId: string; path: string; taskI
|
|
|
141
143
|
}
|
|
142
144
|
/** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
|
|
143
145
|
export interface GrantsSession extends NativeSessionHost {
|
|
146
|
+
ecosystemVersions?: () => EcosystemVersions;
|
|
147
|
+
autoMode?: AutoModeReader;
|
|
148
|
+
autoModeFailure?: string;
|
|
144
149
|
/** Reconciled from the actual owner root on reload; captured call owners stay immutable. */
|
|
145
150
|
publicEvidence?: PublicEvidenceOwner;
|
|
146
151
|
/** Legacy PI_GRANTS_* names adopted at construction (ADR-0076 PR 3b); the session-start warning names them. */
|
|
@@ -659,43 +664,47 @@ export function createGrantsSession(
|
|
|
659
664
|
discovery: Object.freeze({ ...discovery }),
|
|
660
665
|
catalog: emptyCatalog,
|
|
661
666
|
catalogReady: Promise.resolve(emptyCatalog),
|
|
662
|
-
delegationContext: async (approved?: InheritableApproval[]) =>
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
667
|
+
delegationContext: async (approved?: InheritableApproval[]) => {
|
|
668
|
+
if (session.autoModeFailure) throw new Error(session.autoModeFailure);
|
|
669
|
+
return {
|
|
670
|
+
autoModeRef: session.autoMode?.reference,
|
|
671
|
+
ownGrant: session.ownGrant,
|
|
672
|
+
episodeId: session.episodeId,
|
|
673
|
+
depth: session.depth,
|
|
674
|
+
maxDepth: session.maxDepth,
|
|
675
|
+
gated: session.gated,
|
|
676
|
+
ledgerPath: session.ledgerPath,
|
|
677
|
+
extensionPath: session.extensionPath,
|
|
678
|
+
observerExtensionPath: session.observerExtensionPath,
|
|
679
|
+
childEnv: activityChildEnv(session.activity),
|
|
680
|
+
// ADR-0042: the pin this session holds, handed to the kernel so a delegated child inherits a narrowed
|
|
681
|
+
// one through the same builder `publishChildEnv` uses. Read here rather than in the kernel so there is
|
|
682
|
+
// one place that knows the environment is where a session's own pin lives.
|
|
683
|
+
...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
|
|
684
|
+
// Context is staged only after its capability survives the ceiling, grant and gate.
|
|
685
|
+
stageHandoff: (granted, options) =>
|
|
686
|
+
createHandoffStager({
|
|
687
|
+
cwd: session.cwd,
|
|
688
|
+
forkRoot: join(agentDir(), "context-forks"),
|
|
689
|
+
...(session.parentSession ? { parentSession: session.parentSession } : {}),
|
|
690
|
+
})(granted, options),
|
|
691
|
+
catalog: await delegationCatalog(session),
|
|
692
|
+
// R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
|
|
693
|
+
// Derived from the catalog's own `source`, so it cannot drift from what was discovered.
|
|
694
|
+
skillPaths: skillPathsFromCatalog(await delegationCatalog(session)),
|
|
695
|
+
// ADR-0016: operator-authored SKILL.md definitions, so `delegate({agent})` can name one.
|
|
696
|
+
definitions: session.definitions,
|
|
697
|
+
// The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
|
|
698
|
+
// Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
|
|
699
|
+
// an executor quietly rewriting it would make the record describe a spawn that did not happen.
|
|
700
|
+
//
|
|
701
|
+
// Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
|
|
702
|
+
// has not run when this session object is built, so a captured value would plan `--print` for a session
|
|
703
|
+
// that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
|
|
704
|
+
interactive: false, // Both qualified backends use the same one-shot Pi JSON protocol.
|
|
705
|
+
...(approved ? { approved } : {}),
|
|
706
|
+
};
|
|
707
|
+
},
|
|
699
708
|
storeCwd,
|
|
700
709
|
...(grantStoreRefusal ? { grantStoreRefusal } : {}),
|
|
701
710
|
adoptGrant: (grant: Capability[], projectLedger?: string) => {
|
|
@@ -725,6 +734,7 @@ export function createGrantsSession(
|
|
|
725
734
|
},
|
|
726
735
|
publishChildEnv: () => {
|
|
727
736
|
const env = childEnv({
|
|
737
|
+
autoModeRef: session.autoMode?.reference,
|
|
728
738
|
ownGrant: session.ownGrant,
|
|
729
739
|
episodeId: session.episodeId,
|
|
730
740
|
depth: session.depth,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.1",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
54
54
|
"test": "node scripts/unit-tests.ts",
|
|
55
55
|
"test:integration": "node --test test-integration/*.it.ts",
|
|
56
|
-
"test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts test-integration/activity.it.ts",
|
|
56
|
+
"test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts test-integration/activity.it.ts test-integration/auto-chain.it.ts",
|
|
57
57
|
"contracts:generate": "node scripts/generate-ledger-record-contract.ts",
|
|
58
58
|
"prepack": "npm run build",
|
|
59
59
|
"test:smoke": "node scripts/smoke-installed.mjs",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* no-op UI context whose `select` resolves undefined, so even a missed `hasUI` check would deny.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import type { AutoModeReader } from "../kernel/auto-mode.ts";
|
|
13
15
|
import { approvalKey, offeredScopes, type ApprovalPath, type ApprovalScope } from "../kernel/approval.ts";
|
|
14
16
|
import type { Capability } from "../kernel/resolve.ts";
|
|
15
17
|
|
|
@@ -72,6 +74,8 @@ export interface PromptOutcome {
|
|
|
72
74
|
* one. The rider's honest source is `session`, and this flag is how the caller can tell.
|
|
73
75
|
*/
|
|
74
76
|
joined?: boolean;
|
|
77
|
+
/** Provisional Auto permission; the admission caller must still check the live owner policy. */
|
|
78
|
+
source?: "auto";
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
export interface ApprovalGateOptions {
|
|
@@ -82,6 +86,7 @@ export interface ApprovalGateOptions {
|
|
|
82
86
|
mode: string;
|
|
83
87
|
/** Resolve only when this caller must open a new dialog; unused invalid settings cannot revoke authority. */
|
|
84
88
|
timeoutMs?: number | (() => number | undefined);
|
|
89
|
+
autoMode?: AutoModeReader;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
export interface ApprovalGate {
|
|
@@ -106,6 +111,8 @@ function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | n
|
|
|
106
111
|
return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
const autoApproval = (): PromptOutcome => ({ scope: "once", kind: "granted", source: "auto" });
|
|
115
|
+
|
|
109
116
|
const interrupted = (request: PromptRequest, kind: "expired" | "aborted"): PromptOutcome => ({
|
|
110
117
|
scope: null,
|
|
111
118
|
kind,
|
|
@@ -124,26 +131,61 @@ async function selectApproval(
|
|
|
124
131
|
const timeout = typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs;
|
|
125
132
|
const controller = new AbortController();
|
|
126
133
|
let outcome: PromptOutcome | undefined;
|
|
134
|
+
let settled = false;
|
|
127
135
|
let wake!: (value: PromptOutcome) => void;
|
|
128
136
|
const stopped = new Promise<PromptOutcome>((resolve) => {
|
|
129
137
|
wake = resolve;
|
|
130
138
|
});
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
139
|
+
const settle = (next: PromptOutcome) => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
outcome = next;
|
|
134
143
|
// Resolve our tagged outcome before a UI reacts to abort by returning undefined.
|
|
135
144
|
wake(outcome);
|
|
136
145
|
controller.abort();
|
|
137
146
|
};
|
|
147
|
+
const stop = (kind: "expired" | "aborted") => settle(interrupted(request, kind));
|
|
138
148
|
const abort = () => stop("aborted");
|
|
139
149
|
request.signal?.addEventListener("abort", abort, { once: true });
|
|
140
150
|
const timer = timeout === undefined ? undefined : setTimeout(() => stop("expired"), timeout);
|
|
151
|
+
let untrack: (() => void) | undefined;
|
|
141
152
|
try {
|
|
142
153
|
if (request.signal?.aborted) stop("aborted");
|
|
143
154
|
if (outcome) return outcome;
|
|
144
|
-
|
|
155
|
+
untrack = options.autoMode?.trackPending({
|
|
156
|
+
id: randomUUID(),
|
|
157
|
+
subject: request.subject,
|
|
158
|
+
capability: request.capability,
|
|
159
|
+
});
|
|
160
|
+
const selection = options.ui.select(title, labels, { signal: controller.signal }).then((chosen) => {
|
|
161
|
+
settled = true;
|
|
162
|
+
return chosen;
|
|
163
|
+
});
|
|
164
|
+
if (options.autoMode) {
|
|
165
|
+
const auto = options.autoMode;
|
|
166
|
+
void (async () => {
|
|
167
|
+
while (!controller.signal.aborted) {
|
|
168
|
+
await auto.waitEnabled(controller.signal);
|
|
169
|
+
// A notification is not authority: ON may already have changed back to OFF.
|
|
170
|
+
if (!controller.signal.aborted && (await auto.read()).enabled) {
|
|
171
|
+
settle(autoApproval());
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})().catch((error) => {
|
|
176
|
+
if (!controller.signal.aborted)
|
|
177
|
+
settle({
|
|
178
|
+
scope: null,
|
|
179
|
+
kind: "error",
|
|
180
|
+
reason: `Auto permission policy unavailable, denying (${String(error)})`,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const chosen = await Promise.race([selection, stopped]);
|
|
145
185
|
return outcome ?? chosen;
|
|
146
186
|
} finally {
|
|
187
|
+
controller.abort();
|
|
188
|
+
untrack?.();
|
|
147
189
|
if (timer !== undefined) clearTimeout(timer);
|
|
148
190
|
request.signal?.removeEventListener("abort", abort);
|
|
149
191
|
}
|
|
@@ -211,6 +253,15 @@ export function createApprovalGate(
|
|
|
211
253
|
inFlight: InFlightApprovals = new Map(),
|
|
212
254
|
): ApprovalGate {
|
|
213
255
|
const ask = async (request: PromptRequest): Promise<PromptOutcome> => {
|
|
256
|
+
// A queued once waiter owns its own decision. Auto may have changed while it joined another dialog.
|
|
257
|
+
if (options.autoMode) {
|
|
258
|
+
try {
|
|
259
|
+
if ((await options.autoMode.read()).enabled)
|
|
260
|
+
return request.signal?.aborted ? interrupted(request, "aborted") : autoApproval();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return { scope: null, kind: "error", reason: `Auto permission policy unavailable, denying (${String(error)})` };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
214
265
|
const scopes = offeredScopes(request.path);
|
|
215
266
|
const title =
|
|
216
267
|
`grants: approve ${request.capability} for ${request.subject}?` +
|
|
@@ -251,6 +302,13 @@ export function createApprovalGate(
|
|
|
251
302
|
return {
|
|
252
303
|
async request(request: PromptRequest): Promise<PromptOutcome> {
|
|
253
304
|
if (request.signal?.aborted) return interrupted(request, "aborted");
|
|
305
|
+
try {
|
|
306
|
+
if (options.autoMode && (await options.autoMode.read()).enabled) {
|
|
307
|
+
return request.signal?.aborted ? interrupted(request, "aborted") : autoApproval();
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { scope: null, kind: "error", reason: `Auto permission policy unavailable, denying (${String(error)})` };
|
|
311
|
+
}
|
|
254
312
|
if (!options.hasUI) {
|
|
255
313
|
// Nobody was there to ask — distinct from a person declining, which is why this is its own kind
|
|
256
314
|
// rather than being folded into "declined" (see PromptOutcomeKind).
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/** Root-owned live Auto authority. Descendants receive read/admit access, never mutation access. */
|
|
2
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, unlink } from "node:fs/promises";
|
|
4
|
+
import { unlinkSync } from "node:fs";
|
|
5
|
+
import { createServer, createConnection, type Socket } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type {
|
|
9
|
+
AutoModeReader,
|
|
10
|
+
AutoModeRef,
|
|
11
|
+
AutoModeSnapshot,
|
|
12
|
+
AutoModeSource,
|
|
13
|
+
PendingApproval,
|
|
14
|
+
} from "../kernel/auto-mode.ts";
|
|
15
|
+
|
|
16
|
+
const aborted = () => new Error("Auto approval request aborted");
|
|
17
|
+
const unavailable = () => new Error("Auto policy owner is unavailable; no automatic permission was granted");
|
|
18
|
+
const validPending = (value: unknown): value is PendingApproval => {
|
|
19
|
+
if (!value || typeof value !== "object") return false;
|
|
20
|
+
const v = value as PendingApproval;
|
|
21
|
+
return [v.id, v.subject, v.capability].every((x) => typeof x === "string" && x.length > 0 && x.length <= 256);
|
|
22
|
+
};
|
|
23
|
+
export interface AutoModeAuthority extends AutoModeReader {
|
|
24
|
+
snapshot(): AutoModeSnapshot;
|
|
25
|
+
set(enabled: boolean, source: AutoModeSource): AutoModeSnapshot;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function createAutoModeAuthority(initial: {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
source: AutoModeSource;
|
|
31
|
+
}): Promise<AutoModeAuthority> {
|
|
32
|
+
const reference = {
|
|
33
|
+
socketPath: join(tmpdir(), `daddy-auto-${process.pid}-${randomBytes(8).toString("hex")}.sock`),
|
|
34
|
+
token: randomBytes(24).toString("hex"),
|
|
35
|
+
ownerId: randomBytes(16).toString("hex"),
|
|
36
|
+
};
|
|
37
|
+
let enabled = initial.enabled,
|
|
38
|
+
source = initial.source,
|
|
39
|
+
revision = 0,
|
|
40
|
+
closed = false;
|
|
41
|
+
const pending = new Map<string, PendingApproval>();
|
|
42
|
+
const waiters = new Set<() => void>();
|
|
43
|
+
const sockets = new Set<Socket>();
|
|
44
|
+
const assertOpen = () => {
|
|
45
|
+
if (closed) throw unavailable();
|
|
46
|
+
};
|
|
47
|
+
const snapshot = (): AutoModeSnapshot => {
|
|
48
|
+
assertOpen();
|
|
49
|
+
return {
|
|
50
|
+
enabled,
|
|
51
|
+
source,
|
|
52
|
+
revision,
|
|
53
|
+
ownerId: reference.ownerId,
|
|
54
|
+
pendingApprovals: [...pending.values()].map((v) => ({ ...v })),
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const waitEnabled = async (signal: AbortSignal): Promise<void> => {
|
|
58
|
+
assertOpen();
|
|
59
|
+
if (signal.aborted) throw aborted();
|
|
60
|
+
if (enabled) return;
|
|
61
|
+
await new Promise<void>((resolve, reject) => {
|
|
62
|
+
const clean = () => {
|
|
63
|
+
waiters.delete(wake);
|
|
64
|
+
signal.removeEventListener("abort", cancel);
|
|
65
|
+
};
|
|
66
|
+
const wake = () => {
|
|
67
|
+
clean();
|
|
68
|
+
closed ? reject(unavailable()) : resolve();
|
|
69
|
+
};
|
|
70
|
+
const cancel = () => {
|
|
71
|
+
clean();
|
|
72
|
+
reject(aborted());
|
|
73
|
+
};
|
|
74
|
+
waiters.add(wake);
|
|
75
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
76
|
+
if (signal.aborted) cancel();
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
const trackPending = (approval: PendingApproval) => {
|
|
80
|
+
assertOpen();
|
|
81
|
+
if (!validPending(approval)) throw new Error("Invalid pending approval");
|
|
82
|
+
const key = randomUUID();
|
|
83
|
+
pending.set(key, { ...approval });
|
|
84
|
+
return () => {
|
|
85
|
+
pending.delete(key);
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
const server = createServer((socket) => {
|
|
89
|
+
sockets.add(socket);
|
|
90
|
+
socket.setEncoding("utf8");
|
|
91
|
+
socket.setTimeout(2000, () => socket.destroy());
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
let input = "",
|
|
94
|
+
handled = false,
|
|
95
|
+
untrack: (() => void) | undefined;
|
|
96
|
+
socket.on("error", () => {});
|
|
97
|
+
socket.once("close", () => {
|
|
98
|
+
sockets.delete(socket);
|
|
99
|
+
controller.abort();
|
|
100
|
+
untrack?.();
|
|
101
|
+
});
|
|
102
|
+
const reply = (value: unknown) => {
|
|
103
|
+
if (!socket.destroyed) socket.end(JSON.stringify(value) + "\n");
|
|
104
|
+
};
|
|
105
|
+
socket.on("data", (chunk) => {
|
|
106
|
+
if (handled) {
|
|
107
|
+
socket.destroy();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
input += chunk;
|
|
111
|
+
if (input.length > 4096) {
|
|
112
|
+
socket.destroy();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!input.includes("\n")) return;
|
|
116
|
+
handled = true;
|
|
117
|
+
try {
|
|
118
|
+
const request = JSON.parse(input.slice(0, input.indexOf("\n")));
|
|
119
|
+
if (request.token !== reference.token || request.ownerId !== reference.ownerId)
|
|
120
|
+
throw new Error("Unauthorized Auto policy request");
|
|
121
|
+
assertOpen();
|
|
122
|
+
if (request.action === "read") reply({ ok: true, value: snapshot() });
|
|
123
|
+
else if (request.action === "admit") reply({ ok: true, value: enabled });
|
|
124
|
+
else if (request.action === "wait") {
|
|
125
|
+
socket.setTimeout(0);
|
|
126
|
+
void waitEnabled(controller.signal)
|
|
127
|
+
.then(() => reply({ ok: true }))
|
|
128
|
+
.catch((error) => reply({ ok: false, error: String(error) }));
|
|
129
|
+
} else if (request.action === "pending" && validPending(request.approval)) {
|
|
130
|
+
socket.setTimeout(0);
|
|
131
|
+
untrack = trackPending(request.approval);
|
|
132
|
+
} else throw new Error("Unknown Auto policy action");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
reply({ ok: false, error: String(error) });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
await new Promise<void>((resolve, reject) => {
|
|
139
|
+
server.once("error", reject);
|
|
140
|
+
server.listen(reference.socketPath, resolve);
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
await chmod(reference.socketPath, 0o600);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
server.close();
|
|
146
|
+
await unlink(reference.socketPath).catch(() => {});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
server.unref();
|
|
150
|
+
const exit = () => {
|
|
151
|
+
try {
|
|
152
|
+
unlinkSync(reference.socketPath);
|
|
153
|
+
} catch {}
|
|
154
|
+
};
|
|
155
|
+
process.once("exit", exit);
|
|
156
|
+
return {
|
|
157
|
+
reference,
|
|
158
|
+
snapshot,
|
|
159
|
+
read: async () => snapshot(),
|
|
160
|
+
waitEnabled,
|
|
161
|
+
trackPending,
|
|
162
|
+
admit: async (signal) => {
|
|
163
|
+
assertOpen();
|
|
164
|
+
if (signal?.aborted) throw aborted();
|
|
165
|
+
return enabled;
|
|
166
|
+
},
|
|
167
|
+
set: (next, from) => {
|
|
168
|
+
assertOpen();
|
|
169
|
+
enabled = next;
|
|
170
|
+
source = from;
|
|
171
|
+
revision++;
|
|
172
|
+
if (enabled) for (const wake of [...waiters]) wake();
|
|
173
|
+
return snapshot();
|
|
174
|
+
},
|
|
175
|
+
close: async () => {
|
|
176
|
+
if (closed) return;
|
|
177
|
+
closed = true;
|
|
178
|
+
enabled = false;
|
|
179
|
+
for (const wake of [...waiters]) wake();
|
|
180
|
+
for (const socket of sockets) socket.destroy();
|
|
181
|
+
pending.clear();
|
|
182
|
+
process.off("exit", exit);
|
|
183
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
184
|
+
await unlink(reference.socketPath).catch((error) => {
|
|
185
|
+
if (error.code !== "ENOENT") throw error;
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function request(ref: AutoModeRef, action: "read" | "admit" | "wait", signal?: AbortSignal): Promise<unknown> {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
if (signal?.aborted) {
|
|
194
|
+
reject(aborted());
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const socket = createConnection(ref.socketPath);
|
|
198
|
+
let output = "",
|
|
199
|
+
settled = false;
|
|
200
|
+
const finish = (error?: Error, value?: unknown) => {
|
|
201
|
+
if (settled) return;
|
|
202
|
+
settled = true;
|
|
203
|
+
signal?.removeEventListener("abort", cancel);
|
|
204
|
+
socket.destroy();
|
|
205
|
+
error ? reject(error) : resolve(value);
|
|
206
|
+
};
|
|
207
|
+
const cancel = () => finish(aborted());
|
|
208
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
209
|
+
socket.setEncoding("utf8");
|
|
210
|
+
socket.setTimeout(2000, () => finish(unavailable()));
|
|
211
|
+
socket.once("error", () => finish(unavailable()));
|
|
212
|
+
socket.once("connect", () => {
|
|
213
|
+
if (action === "wait") socket.setTimeout(0);
|
|
214
|
+
socket.write(JSON.stringify({ ...ref, action }) + "\n");
|
|
215
|
+
});
|
|
216
|
+
socket.on("data", (chunk) => {
|
|
217
|
+
output += chunk;
|
|
218
|
+
if (output.length > 256 * 1024) finish(new Error("Auto policy response too large"));
|
|
219
|
+
});
|
|
220
|
+
socket.once("end", () => {
|
|
221
|
+
try {
|
|
222
|
+
const reply = JSON.parse(output);
|
|
223
|
+
if (reply.ok !== true) throw new Error(reply.error ?? "Auto policy request failed");
|
|
224
|
+
finish(undefined, reply.value);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
finish(error instanceof Error ? error : unavailable());
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
socket.once("close", () => {
|
|
230
|
+
if (!settled) finish(unavailable());
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
|
|
236
|
+
let closed = false;
|
|
237
|
+
const active = new Set<AbortController>();
|
|
238
|
+
const pending = new Set<Socket>();
|
|
239
|
+
const call = async (action: "read" | "admit" | "wait", signal?: AbortSignal) => {
|
|
240
|
+
if (closed) throw unavailable();
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
active.add(controller);
|
|
243
|
+
const abort = () => controller.abort();
|
|
244
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
245
|
+
if (signal?.aborted) abort();
|
|
246
|
+
try {
|
|
247
|
+
return await request(reference, action, controller.signal);
|
|
248
|
+
} finally {
|
|
249
|
+
active.delete(controller);
|
|
250
|
+
signal?.removeEventListener("abort", abort);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
reference,
|
|
255
|
+
read: async () => {
|
|
256
|
+
const value = (await call("read")) as AutoModeSnapshot;
|
|
257
|
+
if (
|
|
258
|
+
!value ||
|
|
259
|
+
value.ownerId !== reference.ownerId ||
|
|
260
|
+
typeof value.enabled !== "boolean" ||
|
|
261
|
+
!["default", "environment", "session"].includes(value.source) ||
|
|
262
|
+
!Number.isSafeInteger(value.revision) ||
|
|
263
|
+
value.revision < 0 ||
|
|
264
|
+
!Array.isArray(value.pendingApprovals) ||
|
|
265
|
+
!value.pendingApprovals.every(validPending)
|
|
266
|
+
)
|
|
267
|
+
throw unavailable();
|
|
268
|
+
return value;
|
|
269
|
+
},
|
|
270
|
+
admit: async (signal) => {
|
|
271
|
+
const value = await call("admit", signal);
|
|
272
|
+
if (typeof value !== "boolean") throw unavailable();
|
|
273
|
+
return value;
|
|
274
|
+
},
|
|
275
|
+
waitEnabled: async (signal) => {
|
|
276
|
+
await call("wait", signal);
|
|
277
|
+
},
|
|
278
|
+
trackPending: (approval) => {
|
|
279
|
+
if (closed) throw unavailable();
|
|
280
|
+
if (!validPending(approval)) throw new Error("Invalid pending approval");
|
|
281
|
+
const socket = createConnection(reference.socketPath);
|
|
282
|
+
pending.add(socket);
|
|
283
|
+
socket.once("error", () => {});
|
|
284
|
+
socket.once("close", () => pending.delete(socket));
|
|
285
|
+
socket.once("connect", () => socket.write(JSON.stringify({ ...reference, action: "pending", approval }) + "\n"));
|
|
286
|
+
return () => {
|
|
287
|
+
pending.delete(socket);
|
|
288
|
+
socket.destroy();
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
close: async () => {
|
|
292
|
+
closed = true;
|
|
293
|
+
for (const controller of active) controller.abort();
|
|
294
|
+
for (const socket of pending) socket.destroy();
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -96,6 +96,9 @@ export const retentionConfigurationDigest = (configuration: unknown): string =>
|
|
|
96
96
|
* A bounded, coalescing observation queue. No worker-control path awaits publication. A failed or stalled
|
|
97
97
|
* archive never changes required ledger/lease semantics. `flush` is exclusively for external archival at
|
|
98
98
|
* quiescence; it is NOT called by the spawner. Missing terminal publication means incomplete observation.
|
|
99
|
+
* Streams checkpoint at doubling byte milestones and once at finish: total stream blob bytes are below
|
|
100
|
+
* three times each stream's largest retained prefix, rather than one cumulative copy per chunk. A crash can lose the newest
|
|
101
|
+
* suffix since the last checkpoint; running manifests mark that lag. Existing archive files are untouched.
|
|
99
102
|
*/
|
|
100
103
|
export function beginExecutionRetention(identity: RetentionIdentity, directory = process.env[ENV_EXECUTION_ARCHIVE]) {
|
|
101
104
|
const archiveId = randomUUID();
|
|
@@ -143,6 +146,7 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
143
146
|
};
|
|
144
147
|
const buffers = new Map<keyof typeof manifest.content, Buffer>();
|
|
145
148
|
const written = new Set<string>();
|
|
149
|
+
const streamCheckpoints = new Map<"stdout" | "stderr", RetainedContent>();
|
|
146
150
|
let total = 0;
|
|
147
151
|
let dirty = false;
|
|
148
152
|
let running: Promise<void> | undefined;
|
|
@@ -161,6 +165,9 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
161
165
|
const loss = (reason: string) => {
|
|
162
166
|
if (!manifest.coverage.losses.includes(reason)) manifest.coverage.losses.push(reason);
|
|
163
167
|
};
|
|
168
|
+
const clearLoss = (reason: string) => {
|
|
169
|
+
manifest.coverage.losses = manifest.coverage.losses.filter((value) => value !== reason);
|
|
170
|
+
};
|
|
164
171
|
const release = () => {
|
|
165
172
|
if (admitted && !released) {
|
|
166
173
|
active--;
|
|
@@ -178,6 +185,13 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
178
185
|
const checkpoint: ExecutionRetentionManifest = JSON.parse(JSON.stringify(manifest));
|
|
179
186
|
const contents = [...buffers];
|
|
180
187
|
for (const [kind, bytes] of contents) {
|
|
188
|
+
const stream = kind === "stdout" || kind === "stderr" ? kind : undefined;
|
|
189
|
+
const previous = stream && streamCheckpoints.get(stream);
|
|
190
|
+
if (previous && checkpoint.state !== "terminal" && bytes.length < previous.bytes! * 2) {
|
|
191
|
+
checkpoint.content[kind] = previous;
|
|
192
|
+
if (bytes.length !== previous.bytes) checkpoint.coverage.losses.push(`${kind}-checkpoint-lagging`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
181
195
|
const sha256 = digest(bytes);
|
|
182
196
|
const name = `${kind}-${sha256}.bin`;
|
|
183
197
|
if (!written.has(name)) {
|
|
@@ -185,6 +199,7 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
185
199
|
written.add(name);
|
|
186
200
|
}
|
|
187
201
|
checkpoint.content[kind] = { status: "retained", path: name, sha256, bytes: bytes.length };
|
|
202
|
+
if (stream) streamCheckpoints.set(stream, checkpoint.content[kind]);
|
|
188
203
|
}
|
|
189
204
|
await writeFile(
|
|
190
205
|
join(root!, "manifest.pending"),
|
|
@@ -263,6 +278,15 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
263
278
|
manifest.native.branchLeafId = observation.branchLeafId;
|
|
264
279
|
buffers.delete("session");
|
|
265
280
|
if (captured.bytes && observation === captured.observation) buffers.set("session", captured.bytes);
|
|
281
|
+
if (buffers.has("session") && observation.status === "verified") {
|
|
282
|
+
clearLoss("native-session-content-unavailable");
|
|
283
|
+
if (manifest.coverage.losses.includes("native-session-read-failed")) {
|
|
284
|
+
clearLoss("native-session-read-failed");
|
|
285
|
+
loss("native-session-read-failed-earlier");
|
|
286
|
+
}
|
|
287
|
+
} else loss("native-session-content-unavailable");
|
|
288
|
+
if (observation.branchState === "observed") clearLoss("active-branch-unknown");
|
|
289
|
+
else loss("active-branch-unknown");
|
|
266
290
|
if (observation.reason) loss(observation.reason);
|
|
267
291
|
schedule();
|
|
268
292
|
}
|
|
@@ -212,8 +212,9 @@ export interface SessionConfigEvent extends Omit<LedgerEventBase, "correlation">
|
|
|
212
212
|
event: "session_config";
|
|
213
213
|
episodeId: string;
|
|
214
214
|
outcome: "kept" | "changed";
|
|
215
|
-
trigger: "first-delegation" | "grants-models";
|
|
215
|
+
trigger: "first-delegation" | "grants-models" | "auto-mode";
|
|
216
216
|
overrides: Record<string, { model: string; thinking: string }>;
|
|
217
|
+
autoMode?: { enabled: boolean; source: "default" | "environment" | "session"; revision: number };
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
export type RuntimeLedgerEvent =
|
|
@@ -244,6 +245,26 @@ export function buildSessionConfigEvent(args: {
|
|
|
244
245
|
});
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
/** Auto authorization changes are explicit session facts, never fabricated prompt approvals. */
|
|
249
|
+
export function buildAutoModeConfigEvent(args: {
|
|
250
|
+
episodeId: string;
|
|
251
|
+
enabled: boolean;
|
|
252
|
+
source: "default" | "environment" | "session";
|
|
253
|
+
revision: number;
|
|
254
|
+
now: Date;
|
|
255
|
+
}): SessionConfigEvent {
|
|
256
|
+
return assertLedgerV3Wire({
|
|
257
|
+
ledgerVersion: LEDGER_VERSION,
|
|
258
|
+
event: "session_config",
|
|
259
|
+
ts: args.now.toISOString(),
|
|
260
|
+
episodeId: args.episodeId,
|
|
261
|
+
outcome: "changed",
|
|
262
|
+
trigger: "auto-mode",
|
|
263
|
+
overrides: {},
|
|
264
|
+
autoMode: { enabled: args.enabled, source: args.source, revision: args.revision },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
247
268
|
export function buildEpisodeOutcomeEvent(args: {
|
|
248
269
|
episodeId: string;
|
|
249
270
|
commit: string;
|