pi-daddy 0.34.0 → 0.36.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 +81 -0
- package/dist/advisors/decider.d.ts +7 -3
- package/dist/advisors/decider.d.ts.map +1 -1
- package/dist/advisors/decider.js.map +1 -1
- package/dist/advisors/settings.d.ts +16 -7
- package/dist/advisors/settings.d.ts.map +1 -1
- package/dist/advisors/settings.js +60 -22
- package/dist/advisors/settings.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/kernel/bounded-read.d.ts +57 -0
- package/dist/kernel/bounded-read.d.ts.map +1 -0
- package/dist/kernel/bounded-read.js +100 -0
- package/dist/kernel/bounded-read.js.map +1 -0
- package/dist/kernel/catalog.d.ts +12 -1
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +12 -5
- package/dist/kernel/catalog.js.map +1 -1
- package/dist/kernel/definitions.d.ts +19 -1
- package/dist/kernel/definitions.d.ts.map +1 -1
- package/dist/kernel/definitions.js +34 -9
- package/dist/kernel/definitions.js.map +1 -1
- package/dist/kernel/delegate-types.d.ts +11 -1
- 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 +3 -1
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +14 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +16 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +1 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +8 -3
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/kernel/skill-packages.d.ts.map +1 -1
- package/dist/kernel/skill-packages.js +20 -9
- package/dist/kernel/skill-packages.js.map +1 -1
- package/dist/kernel/workspace.d.ts +7 -1
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +39 -81
- package/dist/kernel/workspace.js.map +1 -1
- package/extensions/context-staging.ts +15 -7
- package/extensions/effort-advice.ts +99 -0
- package/extensions/grants-command.ts +17 -0
- package/extensions/grants.ts +4 -0
- package/extensions/init-command.ts +7 -1
- package/extensions/pruning-advice.ts +88 -0
- package/extensions/run-delegation.ts +61 -3
- package/extensions/session-report.ts +15 -0
- package/extensions/session.ts +61 -5
- package/package.json +1 -1
- package/src/advisors/decider.ts +7 -3
- package/src/advisors/settings.ts +61 -21
- package/src/cli.ts +9 -1
- package/src/kernel/bounded-read.ts +125 -0
- package/src/kernel/catalog.ts +42 -21
- package/src/kernel/definitions.ts +38 -8
- package/src/kernel/delegate-types.ts +12 -1
- package/src/kernel/delegate.ts +3 -1
- package/src/kernel/env-names.ts +16 -0
- package/src/kernel/propagation.ts +9 -2
- package/src/kernel/skill-packages.ts +26 -9
- package/src/kernel/workspace.ts +43 -109
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
|
-
import {
|
|
23
|
+
import { readBoundedFile } from "./bounded-read.ts";
|
|
24
24
|
import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
|
|
25
25
|
import { CAPABILITY_NAMESPACE_PREFIXES } from "./capabilities.ts";
|
|
26
26
|
import type { Capability } from "./resolve.ts";
|
|
@@ -194,22 +194,52 @@ export function ceilingForDefinition(definition: SkillDefinition): DefinitionCei
|
|
|
194
194
|
return { capabilities: [...capabilities].sort(), patterns, undeclared: false };
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A `SKILL.md` is an operator-authored markdown file; anything approaching this is not one.
|
|
199
|
+
*
|
|
200
|
+
* The same order of magnitude as the registry's bound and for the same reason. Measured at `7096f78`: a
|
|
201
|
+
* bare `readFile` pulled an 8 MiB `SKILL.md` into memory in 8ms without complaint, and this loop runs once
|
|
202
|
+
* per discovered skill inside `session_start`.
|
|
203
|
+
*/
|
|
204
|
+
export const DEFINITION_MAX_BYTES = 1 << 20;
|
|
205
|
+
|
|
206
|
+
/** A definition read is a local file read; a second is three orders of magnitude of headroom. */
|
|
207
|
+
export const DEFINITION_READ_TIMEOUT_MS = 2_000;
|
|
208
|
+
|
|
197
209
|
/**
|
|
198
210
|
* Read definitions from Pi's enabled resources, including installed packages and local overrides.
|
|
199
211
|
* Resolver precedence and filters are shared with the capability catalog; unregistered npm packages
|
|
200
212
|
* are not runtime resources until legacy init explicitly scaffolds them.
|
|
213
|
+
*
|
|
214
|
+
* **Bounded, and loud about what it dropped.** This used a bare `readFile` with `catch { continue }`, which
|
|
215
|
+
* is both halves of what rule 8 forbids: unbounded, and silent. `resolveSkillResources` filters by
|
|
216
|
+
* `statSync(...).isFile()`, so a FIFO *named* in the resource list is already dropped — but that check is by
|
|
217
|
+
* NAME and the read that followed was by name too, which is the TOCTOU the registry's own comment block
|
|
218
|
+
* describes swapping a regular file for a FIFO through. `readBoundedFile` makes every check against the held
|
|
219
|
+
* descriptor. `skipped` exists so a caller can say which paths were dropped and why, rather than an operator
|
|
220
|
+
* finding a definition absent from `/grants` with nothing anywhere explaining it.
|
|
201
221
|
*/
|
|
202
|
-
export async function loadDefinitions(
|
|
222
|
+
export async function loadDefinitions(
|
|
223
|
+
cwd: string,
|
|
224
|
+
skipped?: (path: string, reason: string) => void,
|
|
225
|
+
): Promise<Map<string, SkillDefinition>> {
|
|
203
226
|
const definitions = new Map<string, SkillDefinition>();
|
|
204
227
|
for (const { path } of (await resolveSkillResources(cwd)).skills) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
228
|
+
const read = await readBoundedFile(path, {
|
|
229
|
+
maxBytes: DEFINITION_MAX_BYTES,
|
|
230
|
+
timeoutMs: DEFINITION_READ_TIMEOUT_MS,
|
|
231
|
+
});
|
|
232
|
+
if (!read.ok) {
|
|
233
|
+
skipped?.(path, read.detail);
|
|
209
234
|
continue;
|
|
210
235
|
}
|
|
211
|
-
const parsed = parseSkillDefinition(path, text);
|
|
212
|
-
if (
|
|
236
|
+
const parsed = parseSkillDefinition(path, read.text);
|
|
237
|
+
if (!parsed) skipped?.(path, `${path} has no readable frontmatter with a description`);
|
|
238
|
+
// Shadowing is legitimate — a project override is SUPPOSED to win over a package's copy — but review
|
|
239
|
+
// pointed out it was the one remaining drop with no word said, in the very function being made loud.
|
|
240
|
+
else if (definitions.has(parsed.name))
|
|
241
|
+
skipped?.(path, `${path} is shadowed by an earlier definition named ${parsed.name}`);
|
|
242
|
+
else definitions.set(parsed.name, parsed);
|
|
213
243
|
}
|
|
214
244
|
return definitions;
|
|
215
245
|
}
|
|
@@ -90,7 +90,18 @@ export interface DelegationContext {
|
|
|
90
90
|
* reads nothing. What it returns reaches the child as an appended system prompt or as fork arguments; it can
|
|
91
91
|
* carry no capability, so nothing here can widen a grant.
|
|
92
92
|
*/
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Turn ids a composition-layer selector chose for a `pruned` handoff (ADR-0077's second decision point).
|
|
95
|
+
*
|
|
96
|
+
* Composition, because choosing may mean asking an advisor and the kernel does no I/O and knows no advisor. The
|
|
97
|
+
* ids can only NARROW: staging keeps the intersection with what the deterministic rule already surfaced, so a
|
|
98
|
+
* selector cannot introduce a turn the rule did not offer, whatever it returns.
|
|
99
|
+
*/
|
|
100
|
+
handoffTurnIds?: readonly string[];
|
|
101
|
+
stageHandoff?: (
|
|
102
|
+
granted: ContextRequest,
|
|
103
|
+
options?: { keepTurnIds?: readonly string[] },
|
|
104
|
+
) => {
|
|
94
105
|
contextPrompt?: string;
|
|
95
106
|
forkFrom?: { sessionPath: string; sessionDir: string; sessionId: string };
|
|
96
107
|
record?: Delegation["handoffRecord"];
|
package/src/kernel/delegate.ts
CHANGED
|
@@ -326,7 +326,9 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
326
326
|
handoff.mode !== "none" && result.effective.includes(contextCapability(handoff.mode)) ? handoff : undefined;
|
|
327
327
|
const canSubDelegate = result.effective.includes(DELEGATE_CAPABILITY);
|
|
328
328
|
// Only for a handoff that survived, so a refused mode reads no file and forks no session.
|
|
329
|
-
const staged = grantedHandoff
|
|
329
|
+
const staged = grantedHandoff
|
|
330
|
+
? ctx.stageHandoff?.(grantedHandoff, ctx.handoffTurnIds ? { keepTurnIds: ctx.handoffTurnIds } : {})
|
|
331
|
+
: undefined;
|
|
330
332
|
if (staged?.refusal)
|
|
331
333
|
return denied({ ...empty, requested, result, reason: staged.refusal }, "CONTEXT_REQUEST_INVALID");
|
|
332
334
|
const plan = planSpawn({
|
package/src/kernel/env-names.ts
CHANGED
|
@@ -48,6 +48,20 @@ export const ENV_GOVERNANCE = "PI_DADDY_GOVERNANCE";
|
|
|
48
48
|
* inherited a paid credential its grant never named. Measured in review.
|
|
49
49
|
*/
|
|
50
50
|
export const ENV_ADVISOR_KEY = "PI_DADDY_ADVISOR_KEY";
|
|
51
|
+
/**
|
|
52
|
+
* Which advisor is in force, or absent for none (ADR-0077).
|
|
53
|
+
*
|
|
54
|
+
* **The enable lives here rather than in the project settings file, and that is a correction.** 0.34.0 read it from
|
|
55
|
+
* `.pi/pi-daddy/settings.json`, which `grant-store.ts` is explicit about: that file is writable by any child
|
|
56
|
+
* holding `tool:write`, so it is "the reviewable record of the decision, not the thing the enforcer reads". A
|
|
57
|
+
* grant is kept outside the workspace for exactly that reason, and an advisor switch needs the same treatment for a
|
|
58
|
+
* neighbouring one — a child that could flip it on would make the operator's NEXT session ship its own description
|
|
59
|
+
* to a third party. The settings file may still narrow (a model, a timeout, or `enabled: false`); it can no longer
|
|
60
|
+
* turn one on.
|
|
61
|
+
*/
|
|
62
|
+
export const ENV_ADVISOR = "PI_DADDY_ADVISOR";
|
|
63
|
+
/** Overrides the adapter's pinned model. In the environment, never the workspace file: a model is a destination. */
|
|
64
|
+
export const ENV_ADVISOR_MODEL = "PI_DADDY_ADVISOR_MODEL";
|
|
51
65
|
|
|
52
66
|
/** Every variable that shapes governance. The `childEnv` hook may set none of these. */
|
|
53
67
|
export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
|
|
@@ -74,6 +88,8 @@ export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
|
|
|
74
88
|
ENV_RETAIN_NATIVE_SESSIONS,
|
|
75
89
|
ENV_GOVERNANCE,
|
|
76
90
|
ENV_ADVISOR_KEY,
|
|
91
|
+
ENV_ADVISOR,
|
|
92
|
+
ENV_ADVISOR_MODEL,
|
|
77
93
|
]);
|
|
78
94
|
|
|
79
95
|
/**
|
|
@@ -31,7 +31,9 @@ import { WORKSPACE_WILDCARD } from "./resolve.ts";
|
|
|
31
31
|
import { inheritApprovals, type InheritableApproval } from "./approval.ts";
|
|
32
32
|
import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
|
|
33
33
|
import {
|
|
34
|
+
ENV_ADVISOR_MODEL,
|
|
34
35
|
ENV_ADVISOR_KEY,
|
|
36
|
+
ENV_ADVISOR,
|
|
35
37
|
ENV_GRANT,
|
|
36
38
|
ENV_FANOUT,
|
|
37
39
|
ENV_PARENT_ID,
|
|
@@ -76,9 +78,14 @@ export {
|
|
|
76
78
|
* to give it.
|
|
77
79
|
*/
|
|
78
80
|
export const GRANT_ENV_KEYS = [
|
|
79
|
-
// Not governance state, but the same rule applies for a stronger reason: a credential the parent holds
|
|
80
|
-
// something a child inherits by being spawned (ADR-0077).
|
|
81
|
+
// Not governance state, but the same rule applies for a stronger reason: neither a credential the parent holds
|
|
82
|
+
// nor the switch that points it at a third party is something a child inherits by being spawned (ADR-0077).
|
|
83
|
+
//
|
|
84
|
+
// **This strips the PROCESS executor's environment.** A Herdr pane inherits the daemon's environment, which this
|
|
85
|
+
// function never sees (R-148), so a daemon started from a shell exporting these two hands them to every pane
|
|
86
|
+
// child. Named here rather than claimed away: "stripped from every child" would be false on that path.
|
|
81
87
|
ENV_ADVISOR_KEY,
|
|
88
|
+
ENV_ADVISOR,
|
|
82
89
|
ENV_GRANT,
|
|
83
90
|
ENV_DEPTH,
|
|
84
91
|
ENV_MAX_DEPTH,
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
9
|
+
import { readBoundedBytes } from "./bounded-read.ts";
|
|
9
10
|
import { homedir } from "node:os";
|
|
10
11
|
import { join, resolve, sep } from "node:path";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ceilingForDefinition,
|
|
14
|
+
parseSkillDefinition,
|
|
15
|
+
type SkillDefinition,
|
|
16
|
+
DEFINITION_MAX_BYTES,
|
|
17
|
+
DEFINITION_READ_TIMEOUT_MS,
|
|
18
|
+
} from "./definitions.ts";
|
|
12
19
|
import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
|
|
13
20
|
import { WILDCARD } from "./pi-tools.ts";
|
|
14
21
|
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
@@ -146,12 +153,17 @@ async function readSkill(packageDir: string, entry: string): Promise<DiscoveredS
|
|
|
146
153
|
// smoke test one day earlier and not applied here.
|
|
147
154
|
const realPackageDir = await realpath(packageDir).catch(() => packageDir);
|
|
148
155
|
for (const path of [join(target, "SKILL.md"), ...(target.endsWith(".md") ? [target] : [])]) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
// **Bounded with the SAME limit the runtime loader uses, because the two must agree about which
|
|
157
|
+
// definitions exist.** Review measured the divergence: a 2 MiB `SKILL.md` was refused by
|
|
158
|
+
// `loadDefinitions` and accepted here, so `pi-daddy init` wrote `agent:advice` into the operator's grant
|
|
159
|
+
// for a definition the session could never load, and the eventual `delegate` said only
|
|
160
|
+
// `unknown agent "advice"`. An unreadable entry lands in `unreadable`, which `init` already prints.
|
|
161
|
+
const read = await readBoundedBytes(path, {
|
|
162
|
+
maxBytes: DEFINITION_MAX_BYTES,
|
|
163
|
+
timeoutMs: DEFINITION_READ_TIMEOUT_MS,
|
|
164
|
+
});
|
|
165
|
+
if (!read.ok) continue;
|
|
166
|
+
const bytes = read.bytes;
|
|
155
167
|
const realPath = await realpath(path).catch(() => path);
|
|
156
168
|
if (!realPath.startsWith(realPackageDir + sep)) return null;
|
|
157
169
|
|
|
@@ -255,8 +267,13 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
|
|
|
255
267
|
}
|
|
256
268
|
}
|
|
257
269
|
for (const resource of resolved.skills) {
|
|
258
|
-
|
|
259
|
-
|
|
270
|
+
// Same bound, same reason: this is the third `SKILL.md` reader and it feeds `planInit`.
|
|
271
|
+
const resourceRead = await readBoundedBytes(resource.path, {
|
|
272
|
+
maxBytes: DEFINITION_MAX_BYTES,
|
|
273
|
+
timeoutMs: DEFINITION_READ_TIMEOUT_MS,
|
|
274
|
+
});
|
|
275
|
+
if (!resourceRead.ok) continue;
|
|
276
|
+
const bytes = resourceRead.bytes;
|
|
260
277
|
const text = bytes.toString("utf8");
|
|
261
278
|
const resourceName = skillResourceName(resource.path);
|
|
262
279
|
if (seenSkills.has(resourceName)) continue;
|
package/src/kernel/workspace.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { once } from "node:events";
|
|
4
|
-
import { mkdir,
|
|
5
|
-
import
|
|
6
|
-
import { constants } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { readBoundedFile } from "./bounded-read.ts";
|
|
7
6
|
import { homedir } from "node:os";
|
|
8
7
|
import { isAbsolute, join } from "node:path";
|
|
9
8
|
import { promisify } from "node:util";
|
|
@@ -60,111 +59,36 @@ const REGISTRY_READ_TIMEOUT_MS = 2_000;
|
|
|
60
59
|
const REGISTRY_MAX_BYTES = 1 << 20;
|
|
61
60
|
|
|
62
61
|
export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegistryFile> {
|
|
63
|
-
//
|
|
64
|
-
// the
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"WORKSPACE_NOT_REGISTERED",
|
|
92
|
-
`workspace registry ${path} is not a regular file — refusing to read it. A FIFO, device or socket ` +
|
|
93
|
-
`at ${ENV_WORKSPACE_REGISTRY} would block session start rather than fail, because opening one ` +
|
|
94
|
-
`waits for a writer that may never come.`,
|
|
95
|
-
{ registry_path: path },
|
|
96
|
-
),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
if (info.size > REGISTRY_MAX_BYTES) {
|
|
100
|
-
throw new GovernanceRefusal(
|
|
101
|
-
refusal(
|
|
102
|
-
"WORKSPACE_NOT_REGISTERED",
|
|
103
|
-
`workspace registry ${path} is ${info.size} bytes, over the ${REGISTRY_MAX_BYTES} limit — refusing ` +
|
|
104
|
-
`rather than reading it into memory at session start.`,
|
|
105
|
-
{ registry_path: path },
|
|
106
|
-
),
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
// **Ownership and mode are NOT checked here, and that is a scope decision (R-137, ADR-0036).** A
|
|
110
|
-
// previous revision refused a registry not owned by this user or writable by others. Those guards are
|
|
111
|
-
// about *tamper resistance*, which is a different question from the one ADR-0035 raised, and they went in
|
|
112
|
-
// mid-review without an ADR — where they promptly acquired a false claim ("nobody ELSE may rewrite it":
|
|
113
|
-
// it inspects the file and never its parent directory, and `rename(2)` needs only directory write) and a
|
|
114
|
-
// false-positive refusal of `0664`, which `umask 002` produces for every file an operator creates.
|
|
115
|
-
//
|
|
116
|
-
// What IS checked above is what ADR-0035 made this reader's problem: it began reading the registry at
|
|
117
|
-
// SESSION START, so the read must be bounded and must not block. Integrity is R-137, open and measured.
|
|
118
|
-
// The deadline is checked BETWEEN chunks, which is all `AbortSignal.timeout` ever did on the previous
|
|
119
|
-
// implementation — review measured 74 of 200 one-MiB reads completing in full with `signal.aborted`
|
|
120
|
-
// already true, because a signal is never observed *inside* a libuv read request. An explicit check makes
|
|
121
|
-
// the bound as real as it can be in-process, and its limit is the same one honestly stated below: a
|
|
122
|
-
// stalled `open` or a single wedged read cannot be interrupted from here.
|
|
123
|
-
const deadline = Date.now() + REGISTRY_READ_TIMEOUT_MS;
|
|
124
|
-
const buffer = Buffer.allocUnsafe(REGISTRY_MAX_BYTES + 1);
|
|
125
|
-
let filled = 0;
|
|
126
|
-
while (filled < buffer.length) {
|
|
127
|
-
if (Date.now() > deadline) {
|
|
128
|
-
throw new GovernanceRefusal(
|
|
129
|
-
refusal(
|
|
130
|
-
"WORKSPACE_NOT_REGISTERED",
|
|
131
|
-
`workspace registry ${path} did not finish reading within ${REGISTRY_READ_TIMEOUT_MS}ms — ` +
|
|
132
|
-
`refusing rather than waiting, because session start awaits this read.`,
|
|
133
|
-
{ registry_path: path },
|
|
134
|
-
),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
|
|
138
|
-
if (bytesRead === 0) break;
|
|
139
|
-
filled += bytesRead;
|
|
140
|
-
}
|
|
141
|
-
if (filled > REGISTRY_MAX_BYTES) {
|
|
142
|
-
throw new GovernanceRefusal(
|
|
143
|
-
refusal(
|
|
144
|
-
"WORKSPACE_NOT_REGISTERED",
|
|
145
|
-
`workspace registry ${path} exceeded the ${REGISTRY_MAX_BYTES} limit while being read — it grew ` +
|
|
146
|
-
`after its size was checked. Refusing rather than allocating it.`,
|
|
147
|
-
{ registry_path: path },
|
|
148
|
-
),
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
raw = buffer.subarray(0, filled).toString("utf8");
|
|
152
|
-
} catch (error) {
|
|
153
|
-
if (error instanceof GovernanceRefusal) throw error;
|
|
154
|
-
const timedOut = error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
155
|
-
throw new GovernanceRefusal(
|
|
156
|
-
refusal(
|
|
157
|
-
"WORKSPACE_NOT_REGISTERED",
|
|
158
|
-
timedOut
|
|
159
|
-
? `workspace registry ${path} did not return within ${REGISTRY_READ_TIMEOUT_MS}ms — refusing rather ` +
|
|
160
|
-
`than waiting, because session start awaits this read.`
|
|
161
|
-
: `workspace registry ${path} could not be read (${String(error)})`,
|
|
162
|
-
{ registry_path: path },
|
|
163
|
-
),
|
|
164
|
-
);
|
|
165
|
-
} finally {
|
|
166
|
-
await handle.close().catch(() => {});
|
|
62
|
+
// The guards this call carries — non-blocking open, every check on the held descriptor, a deadline between
|
|
63
|
+
// chunks, the size bound checked twice — were worked out HERE and now live in `bounded-read.ts`, because a
|
|
64
|
+
// second session-start reader went on using a bare `readFile` rather than copying them. The mapping from a
|
|
65
|
+
// reason to a refusal stays here: only this caller knows that an unreadable registry is a governance
|
|
66
|
+
// refusal naming the file, and its messages are unchanged.
|
|
67
|
+
const read = await readBoundedFile(path, {
|
|
68
|
+
maxBytes: REGISTRY_MAX_BYTES,
|
|
69
|
+
timeoutMs: REGISTRY_READ_TIMEOUT_MS,
|
|
70
|
+
});
|
|
71
|
+
if (!read.ok) {
|
|
72
|
+
const message =
|
|
73
|
+
read.why === "not-a-regular-file"
|
|
74
|
+
? `workspace registry ${path} is not a regular file — refusing to read it. A FIFO, device or socket ` +
|
|
75
|
+
`at ${ENV_WORKSPACE_REGISTRY} would block session start rather than fail, because opening one ` +
|
|
76
|
+
`waits for a writer that may never come.`
|
|
77
|
+
: read.why === "too-large"
|
|
78
|
+
? `workspace registry ${path} is ${read.size} bytes, over the ${REGISTRY_MAX_BYTES} limit — ` +
|
|
79
|
+
`refusing rather than reading it into memory at session start.`
|
|
80
|
+
: read.why === "grew-while-reading"
|
|
81
|
+
? `workspace registry ${path} exceeded the ${REGISTRY_MAX_BYTES} limit while being read — it ` +
|
|
82
|
+
`grew after its size was checked. Refusing rather than allocating it.`
|
|
83
|
+
: read.why === "timed-out"
|
|
84
|
+
? `workspace registry ${path} did not finish reading within ${REGISTRY_READ_TIMEOUT_MS}ms — ` +
|
|
85
|
+
`refusing rather than waiting, because session start awaits this read.`
|
|
86
|
+
: read.why === "unopenable"
|
|
87
|
+
? `workspace registry ${path} could not be opened (${read.detail})`
|
|
88
|
+
: `workspace registry ${path} could not be read (${read.detail})`;
|
|
89
|
+
throw new GovernanceRefusal(refusal("WORKSPACE_NOT_REGISTERED", message, { registry_path: path }));
|
|
167
90
|
}
|
|
91
|
+
const raw = read.text;
|
|
168
92
|
let parsed: unknown;
|
|
169
93
|
try {
|
|
170
94
|
parsed = JSON.parse(raw);
|
|
@@ -237,12 +161,22 @@ export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegi
|
|
|
237
161
|
* that module — documents itself as "Pure: no filesystem". It is a registry concern; this is where the
|
|
238
162
|
* registry lives. Moved when `init.ts` crossed the 400-line ceiling, which this project splits for rather
|
|
239
163
|
* than raising (`delegate.ts` at 413, `grants.ts` at 398).
|
|
164
|
+
*
|
|
165
|
+
* **`onRefusal` exists because `catch { return [] }` was rule 8's silent safe-mode.** A malformed registry
|
|
166
|
+
* made `pi-daddy init` scaffold with no workspace capabilities at all and say nothing about why, which an
|
|
167
|
+
* operator cannot tell apart from having registered none. Failing soft stays — `init` must work without a
|
|
168
|
+
* registry — but the reason is handed to the caller instead of discarded. A caller that passes nothing keeps
|
|
169
|
+
* the old behaviour, which is why this is an optional parameter and not a changed return type.
|
|
240
170
|
*/
|
|
241
|
-
export async function registeredWorkspaceIds(
|
|
171
|
+
export async function registeredWorkspaceIds(
|
|
172
|
+
registryPath = process.env[ENV_WORKSPACE_REGISTRY],
|
|
173
|
+
onRefusal?: (reason: string) => void,
|
|
174
|
+
): Promise<string[]> {
|
|
242
175
|
if (!registryPath) return [];
|
|
243
176
|
try {
|
|
244
177
|
return Object.keys((await loadWorkspaceRegistry(registryPath)).workspaces).sort();
|
|
245
|
-
} catch {
|
|
178
|
+
} catch (error) {
|
|
179
|
+
onRefusal?.(error instanceof Error ? error.message : String(error));
|
|
246
180
|
return [];
|
|
247
181
|
}
|
|
248
182
|
}
|