@gotgenes/pi-permission-system 26.0.0 → 26.2.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 +33 -0
- package/docs/configuration.md +14 -14
- package/docs/subagent-integration.md +36 -3
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +49 -16
- package/src/authority/authorizer-chain.ts +39 -11
- package/src/authority/authorizer-selection.ts +5 -5
- package/src/authority/authorizer.ts +17 -4
- package/src/authority/decision-source.ts +235 -0
- package/src/authority/denying-authorizer.ts +4 -0
- package/src/authority/forwarded-request-server.ts +44 -12
- package/src/authority/forwarding-io.ts +5 -0
- package/src/authority/forwarding-liveness.ts +450 -0
- package/src/authority/forwarding-manager.ts +21 -0
- package/src/authority/permission-dialog.ts +23 -2
- package/src/authority/permission-forwarding.ts +22 -1
- package/src/authority/permission-prompt-component.ts +34 -10
- package/src/authority/permission-prompt-decision.ts +7 -3
- package/src/authority/permission-prompter.ts +8 -0
- package/src/authority/serving-registry.ts +33 -0
- package/src/handlers/gates/bash-external-directory.ts +9 -0
- package/src/handlers/gates/bash-path.ts +8 -0
- package/src/handlers/gates/descriptor.ts +9 -0
- package/src/handlers/gates/external-directory.ts +2 -0
- package/src/handlers/gates/runner.ts +20 -0
- package/src/handlers/tool-call-boundary.ts +4 -1
- package/src/index.ts +24 -3
- package/src/permission-gate.ts +11 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
|
|
3
|
+
*
|
|
4
|
+
* The in-process answer already exists: a serving session marks itself in the
|
|
5
|
+
* process-global `ServingSessionRegistry`, and an in-process child abandons a
|
|
6
|
+
* target that has looked unmarked for the grace window instead of waiting out
|
|
7
|
+
* the full forwarding timeout (#719).
|
|
8
|
+
*
|
|
9
|
+
* A child spawned as a separate `pi` process shares no `globalThis` with its
|
|
10
|
+
* parent, so that mark is invisible to it and it keeps waiting the full ten
|
|
11
|
+
* minutes — every `ask` forwarded to a session that has already exited costs
|
|
12
|
+
* the whole timeout and ends in a denial nobody made (#735 scenario 1).
|
|
13
|
+
*
|
|
14
|
+
* The filesystem is the only channel those two processes share, so the serving
|
|
15
|
+
* session publishes a heartbeat there: one record per serving session,
|
|
16
|
+
* refreshed while it polls and withdrawn when it stops.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
ensureDirectoryExists,
|
|
23
|
+
isErrnoCode,
|
|
24
|
+
logPermissionForwardingError,
|
|
25
|
+
safeDeleteFile,
|
|
26
|
+
writeJsonFileAtomic,
|
|
27
|
+
} from "#src/authority/forwarding-io";
|
|
28
|
+
import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
|
|
29
|
+
import {
|
|
30
|
+
encodeSessionIdForPath,
|
|
31
|
+
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
32
|
+
} from "#src/authority/permission-forwarding";
|
|
33
|
+
import type {
|
|
34
|
+
ServingAnnouncer,
|
|
35
|
+
ServingLookup,
|
|
36
|
+
} from "#src/authority/serving-registry";
|
|
37
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How often a serving session rewrites its heartbeat — four poll ticks.
|
|
41
|
+
*
|
|
42
|
+
* Longer than the poll interval so `ForwardingManager` can announce on every
|
|
43
|
+
* tick without four filesystem writes a second, and short enough that a record
|
|
44
|
+
* deleted underneath its owner reappears well inside the grace window a
|
|
45
|
+
* forwarding child waits out before abandoning.
|
|
46
|
+
*/
|
|
47
|
+
export const SERVING_HEARTBEAT_REFRESH_MS =
|
|
48
|
+
4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long a heartbeat may go unrefreshed before its writer is presumed gone —
|
|
52
|
+
* five refreshes.
|
|
53
|
+
*
|
|
54
|
+
* Generous because a delayed Node timer is not a dead session, and because the
|
|
55
|
+
* case this threshold exists for (a process that is alive but no longer
|
|
56
|
+
* polling) is the rare one: an exited session withdraws its record and a killed
|
|
57
|
+
* one is caught by the recorded pid, neither of which waits for staleness.
|
|
58
|
+
*/
|
|
59
|
+
export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
|
|
60
|
+
|
|
61
|
+
/** What a serving session publishes while it drains its forwarded-permission inbox. */
|
|
62
|
+
export interface ServingHeartbeat {
|
|
63
|
+
sessionId: string;
|
|
64
|
+
/** The serving process, so a killed session is detectable without waiting out staleness. */
|
|
65
|
+
pid: number;
|
|
66
|
+
updatedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How a session's heartbeat reads right now.
|
|
71
|
+
*
|
|
72
|
+
* Only `"alive"` means someone is draining the inbox. The other three are the
|
|
73
|
+
* ways a target can be unserved, kept apart because they are the diagnosis a
|
|
74
|
+
* stalled forward needs: `"absent"` is a session that exited (or never served,
|
|
75
|
+
* or runs a version that does not publish), `"dead_pid"` one that was killed,
|
|
76
|
+
* and `"stale"` one whose process survives but stopped polling.
|
|
77
|
+
*/
|
|
78
|
+
export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read side of the heartbeat channel, consumed by a forwarding child.
|
|
82
|
+
*
|
|
83
|
+
* Separate from the announce seam because the two have no caller in common: a
|
|
84
|
+
* serving session only publishes, and a forwarding child only reads (ISP).
|
|
85
|
+
*/
|
|
86
|
+
export interface HeartbeatReader {
|
|
87
|
+
read(sessionId: string): HeartbeatState;
|
|
88
|
+
/** Every session whose record reads as alive, for the abandonment diagnostic. */
|
|
89
|
+
servingIds(): readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Query-side seam: is the session a forwarding target names being drained?
|
|
94
|
+
*
|
|
95
|
+
* Keyed on the target rather than a session id because the answer depends on
|
|
96
|
+
* how the target was resolved. An in-process child and its parent share a
|
|
97
|
+
* `globalThis`, so the registry answers for them; an out-of-process pair shares
|
|
98
|
+
* only the filesystem; and a session that owns the inbox it is forwarding to is
|
|
99
|
+
* not a case either channel describes.
|
|
100
|
+
*
|
|
101
|
+
* Consolidating that into one collaborator is what keeps `ParentAuthorizer`
|
|
102
|
+
* from holding two lookups and re-deciding which one applies — the decision has
|
|
103
|
+
* one home, and a third channel would not reach the poll loop.
|
|
104
|
+
*/
|
|
105
|
+
export interface TargetServingLookup {
|
|
106
|
+
/** `true` serving, `false` not serving, `null` when the target carries no signal. */
|
|
107
|
+
isServing(target: PermissionForwardingTarget): boolean | null;
|
|
108
|
+
/** What the judge observed, for the review entry a child writes when it gives up. */
|
|
109
|
+
describe(target: PermissionForwardingTarget): ServingObservation;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What answered a liveness question, and what it saw. */
|
|
113
|
+
export interface ServingObservation {
|
|
114
|
+
channel: "registry" | "heartbeat" | "none";
|
|
115
|
+
/** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
|
|
116
|
+
state: HeartbeatState | null;
|
|
117
|
+
servingIds: readonly string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Constructor config for {@link ForwardingLivenessJudge}. */
|
|
121
|
+
export interface ForwardingLivenessJudgeDeps {
|
|
122
|
+
/** Answers for a target the requester shares a process with. */
|
|
123
|
+
registry: ServingLookup;
|
|
124
|
+
/** Answers for a target in another process. */
|
|
125
|
+
heartbeats: HeartbeatReader;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Routes a liveness question to the channel that can answer it.
|
|
130
|
+
*
|
|
131
|
+
* The routing key is `PermissionForwardingTarget.source`, which the resolver
|
|
132
|
+
* already produces — so "in-process" is decided once, where the target is
|
|
133
|
+
* found, rather than re-derived here (#719).
|
|
134
|
+
*/
|
|
135
|
+
export class ForwardingLivenessJudge implements TargetServingLookup {
|
|
136
|
+
constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
|
|
137
|
+
|
|
138
|
+
isServing(target: PermissionForwardingTarget): boolean | null {
|
|
139
|
+
switch (target.source) {
|
|
140
|
+
case "registry":
|
|
141
|
+
return this.deps.registry.isServing(target.sessionId);
|
|
142
|
+
case "env":
|
|
143
|
+
return this.deps.heartbeats.read(target.sessionId) === "alive";
|
|
144
|
+
case "self":
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe(target: PermissionForwardingTarget): ServingObservation {
|
|
150
|
+
switch (target.source) {
|
|
151
|
+
case "registry":
|
|
152
|
+
return {
|
|
153
|
+
channel: "registry",
|
|
154
|
+
state: null,
|
|
155
|
+
servingIds: this.deps.registry.servingIds(),
|
|
156
|
+
};
|
|
157
|
+
case "env":
|
|
158
|
+
return {
|
|
159
|
+
channel: "heartbeat",
|
|
160
|
+
state: this.deps.heartbeats.read(target.sessionId),
|
|
161
|
+
servingIds: this.deps.heartbeats.servingIds(),
|
|
162
|
+
};
|
|
163
|
+
case "self":
|
|
164
|
+
return { channel: "none", state: null, servingIds: [] };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where serving heartbeats live: beside the `sessions/` tree, never inside it.
|
|
173
|
+
*
|
|
174
|
+
* A heartbeat under `sessions/<id>/` would make that session root permanently
|
|
175
|
+
* non-empty, entangling liveness with the request/response cleanup whose
|
|
176
|
+
* removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
|
|
177
|
+
* that logic stays untouched and "who is serving" is a single directory read.
|
|
178
|
+
*/
|
|
179
|
+
export function servingHeartbeatDir(forwardingDir: string): string {
|
|
180
|
+
return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
|
|
184
|
+
export function servingHeartbeatPath(
|
|
185
|
+
forwardingDir: string,
|
|
186
|
+
sessionId: string,
|
|
187
|
+
): string {
|
|
188
|
+
return join(
|
|
189
|
+
servingHeartbeatDir(forwardingDir),
|
|
190
|
+
`${encodeSessionIdForPath(sessionId)}.json`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Constructor config for {@link ServingHeartbeatStore}. */
|
|
195
|
+
export interface ServingHeartbeatStoreDeps {
|
|
196
|
+
forwardingDir: string;
|
|
197
|
+
logger: DebugReviewLogger;
|
|
198
|
+
/** Injected so the refresh throttle and staleness are testable without sleeping. */
|
|
199
|
+
now?: () => number;
|
|
200
|
+
/** The process to record. Injected so a test can publish a pid it controls. */
|
|
201
|
+
pid?: number;
|
|
202
|
+
/** Injected so a test can decide which pids are running. */
|
|
203
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Publishes this session's serving heartbeat to the filesystem.
|
|
208
|
+
*
|
|
209
|
+
* Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
|
|
210
|
+
* so `ForwardingManager` announces to both channels through one collaborator
|
|
211
|
+
* and neither knows the other exists.
|
|
212
|
+
*
|
|
213
|
+
* `markServing` is idempotent by that seam's contract and internally throttled,
|
|
214
|
+
* so the caller may announce on every poll tick. Nothing here throws: it runs
|
|
215
|
+
* from a timer, and a filesystem failure must degrade to the pre-existing
|
|
216
|
+
* timeout rather than break the poll loop.
|
|
217
|
+
*/
|
|
218
|
+
export class ServingHeartbeatStore
|
|
219
|
+
implements ServingAnnouncer, HeartbeatReader
|
|
220
|
+
{
|
|
221
|
+
private readonly forwardingDir: string;
|
|
222
|
+
private readonly logger: DebugReviewLogger;
|
|
223
|
+
private readonly now: () => number;
|
|
224
|
+
private readonly pid: number;
|
|
225
|
+
private readonly isProcessAlive: (pid: number) => boolean;
|
|
226
|
+
private published: { sessionId: string; at: number } | null = null;
|
|
227
|
+
private hasSweptDeadRecords = false;
|
|
228
|
+
|
|
229
|
+
constructor(deps: ServingHeartbeatStoreDeps) {
|
|
230
|
+
this.forwardingDir = deps.forwardingDir;
|
|
231
|
+
this.logger = deps.logger;
|
|
232
|
+
this.now = deps.now ?? Date.now;
|
|
233
|
+
this.pid = deps.pid ?? process.pid;
|
|
234
|
+
this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
|
|
238
|
+
markServing(sessionId: string): void {
|
|
239
|
+
const at = this.now();
|
|
240
|
+
if (this.isThrottled(sessionId, at)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const directory = servingHeartbeatDir(this.forwardingDir);
|
|
245
|
+
if (
|
|
246
|
+
!ensureDirectoryExists(
|
|
247
|
+
this.logger,
|
|
248
|
+
directory,
|
|
249
|
+
"permission forwarding serving heartbeat",
|
|
250
|
+
)
|
|
251
|
+
) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.sweepDeadRecordsOnce();
|
|
255
|
+
|
|
256
|
+
const heartbeat: ServingHeartbeat = {
|
|
257
|
+
sessionId,
|
|
258
|
+
pid: this.pid,
|
|
259
|
+
updatedAt: at,
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
writeJsonFileAtomic(
|
|
263
|
+
this.logger,
|
|
264
|
+
servingHeartbeatPath(this.forwardingDir, sessionId),
|
|
265
|
+
heartbeat,
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logPermissionForwardingError(
|
|
269
|
+
this.logger,
|
|
270
|
+
`Failed to publish the serving heartbeat for session '${sessionId}'`,
|
|
271
|
+
error,
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.published = { sessionId, at };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
|
|
279
|
+
clearServing(sessionId: string): void {
|
|
280
|
+
if (this.published?.sessionId === sessionId) {
|
|
281
|
+
this.published = null;
|
|
282
|
+
}
|
|
283
|
+
safeDeleteFile(
|
|
284
|
+
this.logger,
|
|
285
|
+
servingHeartbeatPath(this.forwardingDir, sessionId),
|
|
286
|
+
"permission forwarding serving heartbeat",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** How `sessionId`'s heartbeat reads right now. */
|
|
291
|
+
read(sessionId: string): HeartbeatState {
|
|
292
|
+
const record = this.readRecord(
|
|
293
|
+
servingHeartbeatPath(this.forwardingDir, sessionId),
|
|
294
|
+
);
|
|
295
|
+
return record === null ? "absent" : this.classify(record);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Every session whose record reads as alive. */
|
|
299
|
+
servingIds(): readonly string[] {
|
|
300
|
+
const ids: string[] = [];
|
|
301
|
+
for (const { record } of this.listRecords()) {
|
|
302
|
+
if (record !== null && this.classify(record) === "alive") {
|
|
303
|
+
ids.push(record.sessionId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return ids;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Private methods ────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Delete the records of processes that are provably gone, once per session.
|
|
313
|
+
*
|
|
314
|
+
* Without this the directory grows one record per session that was killed
|
|
315
|
+
* rather than shut down, forever. Bounded to a single directory read at the
|
|
316
|
+
* first announcement, and safe under pid reuse: a wrongly swept owner
|
|
317
|
+
* republishes within the refresh window, which is shorter than the grace a
|
|
318
|
+
* forwarding child waits out.
|
|
319
|
+
*
|
|
320
|
+
* Only a dead pid is proof. A record that is merely stale belongs to a
|
|
321
|
+
* process that still exists, and the reader already reports it as stale
|
|
322
|
+
* without anyone having to remove it.
|
|
323
|
+
*/
|
|
324
|
+
private sweepDeadRecordsOnce(): void {
|
|
325
|
+
if (this.hasSweptDeadRecords) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.hasSweptDeadRecords = true;
|
|
329
|
+
for (const { path, record } of this.listRecords()) {
|
|
330
|
+
if (record !== null && this.isProcessAlive(record.pid)) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
safeDeleteFile(
|
|
334
|
+
this.logger,
|
|
335
|
+
path,
|
|
336
|
+
"abandoned permission forwarding serving heartbeat",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Every published record, paired with its path; unusable ones read as `null`. */
|
|
342
|
+
private listRecords(): {
|
|
343
|
+
path: string;
|
|
344
|
+
record: ServingHeartbeat | null;
|
|
345
|
+
}[] {
|
|
346
|
+
const directory = servingHeartbeatDir(this.forwardingDir);
|
|
347
|
+
let names: string[];
|
|
348
|
+
try {
|
|
349
|
+
names = readdirSync(directory);
|
|
350
|
+
} catch {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
return names
|
|
354
|
+
.filter((name) => name.endsWith(".json"))
|
|
355
|
+
.map((name) => {
|
|
356
|
+
const path = join(directory, name);
|
|
357
|
+
return { path, record: this.readRecord(path) };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read a record, or `null` when it is missing or unusable.
|
|
363
|
+
*
|
|
364
|
+
* Silent by design: a forwarding child calls this on every poll tick, so a
|
|
365
|
+
* warning per unreadable read would flood the review log at four lines a
|
|
366
|
+
* second. The unusability is already reported once, as the `absent` state on
|
|
367
|
+
* the abandonment entry.
|
|
368
|
+
*/
|
|
369
|
+
private readRecord(path: string): ServingHeartbeat | null {
|
|
370
|
+
try {
|
|
371
|
+
return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Which of the four states a well-formed record is in. */
|
|
378
|
+
private classify(record: ServingHeartbeat): HeartbeatState {
|
|
379
|
+
if (!this.isProcessAlive(record.pid)) {
|
|
380
|
+
return "dead_pid";
|
|
381
|
+
}
|
|
382
|
+
return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
|
|
383
|
+
? "stale"
|
|
384
|
+
: "alive";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Whether the record on disk is recent enough to leave alone.
|
|
389
|
+
*
|
|
390
|
+
* Time alone, with no existence probe: an existence check would cost a
|
|
391
|
+
* syscall on every poll tick to save at most one refresh window, and a record
|
|
392
|
+
* removed underneath its owner reappears inside the grace window anyway.
|
|
393
|
+
*/
|
|
394
|
+
private isThrottled(sessionId: string, at: number): boolean {
|
|
395
|
+
return (
|
|
396
|
+
this.published !== null &&
|
|
397
|
+
this.published.sessionId === sessionId &&
|
|
398
|
+
at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Module-private helpers ────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Narrow a parsed record, or `undefined`.
|
|
407
|
+
*
|
|
408
|
+
* `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
|
|
409
|
+
* the caller's own process group and `kill(-n)` a foreign one, so a malformed
|
|
410
|
+
* record must be rejected before it can reach the liveness probe.
|
|
411
|
+
*/
|
|
412
|
+
function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
|
|
413
|
+
if (typeof value !== "object" || value === null) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const candidate = value as Partial<ServingHeartbeat>;
|
|
417
|
+
if (
|
|
418
|
+
typeof candidate.sessionId !== "string" ||
|
|
419
|
+
candidate.sessionId.length === 0 ||
|
|
420
|
+
typeof candidate.pid !== "number" ||
|
|
421
|
+
!Number.isInteger(candidate.pid) ||
|
|
422
|
+
candidate.pid <= 0 ||
|
|
423
|
+
typeof candidate.updatedAt !== "number" ||
|
|
424
|
+
!Number.isFinite(candidate.updatedAt)
|
|
425
|
+
) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
sessionId: candidate.sessionId,
|
|
430
|
+
pid: candidate.pid,
|
|
431
|
+
updatedAt: candidate.updatedAt,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Whether `pid` names a running process.
|
|
437
|
+
*
|
|
438
|
+
* Signal `0` performs the permission and existence checks without delivering
|
|
439
|
+
* anything. `EPERM` means the process exists under another user — reported as
|
|
440
|
+
* alive, the direction that falls back to the timeout rather than abandoning a
|
|
441
|
+
* request someone may still answer.
|
|
442
|
+
*/
|
|
443
|
+
function isRunningProcess(pid: number): boolean {
|
|
444
|
+
try {
|
|
445
|
+
process.kill(pid, 0);
|
|
446
|
+
return true;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return isErrnoCode(error, "EPERM");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -65,6 +65,12 @@ export class ForwardingManager {
|
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
this.timer = setInterval(() => {
|
|
68
|
+
// Ahead of the processing guard: a session whose human is deliberating at
|
|
69
|
+
// a forwarded dialog holds `processInbox` open for as long as they take,
|
|
70
|
+
// and it is serving throughout. Refreshing behind the guard would let its
|
|
71
|
+
// announcement decay exactly when it is most demonstrably alive, and
|
|
72
|
+
// every other forwarding child would give up on it.
|
|
73
|
+
this.refreshServing();
|
|
68
74
|
if (!this.context || this.processing) {
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
@@ -107,6 +113,21 @@ export class ForwardingManager {
|
|
|
107
113
|
});
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Re-announce the served session, keeping a decayable channel current.
|
|
118
|
+
*
|
|
119
|
+
* Separate from {@link announceServing} because that one detects a change to
|
|
120
|
+
* write its log line, and this one deliberately writes none — four review
|
|
121
|
+
* entries a second would drown the log the announcement exists to make
|
|
122
|
+
* readable.
|
|
123
|
+
*/
|
|
124
|
+
private refreshServing(): void {
|
|
125
|
+
if (this.servingSessionId === null) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.deps.serving.markServing(this.servingSessionId);
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
/** Withdraw the published session, if any. */
|
|
111
132
|
private withdrawServing(): void {
|
|
112
133
|
const sessionId = this.servingSessionId;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
|
+
|
|
1
3
|
export type PermissionDecisionState =
|
|
2
4
|
| "approved"
|
|
3
5
|
| "approved_for_session"
|
|
@@ -26,8 +28,27 @@ export type PermissionPromptDecision = {
|
|
|
26
28
|
* denial — a user who was never asked denied nothing (#719).
|
|
27
29
|
*/
|
|
28
30
|
confirmationUnavailable?: true;
|
|
31
|
+
/**
|
|
32
|
+
* What decided this request, stamped by the site that decided it.
|
|
33
|
+
*
|
|
34
|
+
* Required: every decision names its decider, and the type is what
|
|
35
|
+
* guarantees it rather than a convention each producer has to remember — the
|
|
36
|
+
* same discipline `PromptPermissionDetails.payload` carries (#726).
|
|
37
|
+
*/
|
|
38
|
+
decidedBy: DecisionSource;
|
|
29
39
|
};
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A decision before its decider is known.
|
|
43
|
+
*
|
|
44
|
+
* The inner producers — the dialog's decision model, the `select`/`input`
|
|
45
|
+
* fallback, the verdict mapper — state the outcome; which decider to attribute
|
|
46
|
+
* it to is settled one layer up, at the site that chose the producer. The same
|
|
47
|
+
* shape `GateBypass.decision` uses for the request id: a producer emits only
|
|
48
|
+
* what it knows.
|
|
49
|
+
*/
|
|
50
|
+
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
|
|
51
|
+
|
|
31
52
|
export interface PermissionDecisionUi {
|
|
32
53
|
select(title: string, options: string[]): Promise<string | undefined>;
|
|
33
54
|
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
@@ -51,7 +72,7 @@ export function normalizePermissionDenialReason(
|
|
|
51
72
|
|
|
52
73
|
export function createDeniedPermissionDecision(
|
|
53
74
|
denialReason?: string,
|
|
54
|
-
):
|
|
75
|
+
): UnattributedDecision {
|
|
55
76
|
const normalizedReason = normalizePermissionDenialReason(denialReason);
|
|
56
77
|
return normalizedReason
|
|
57
78
|
? {
|
|
@@ -96,7 +117,7 @@ export async function requestPermissionDecisionFromUi(
|
|
|
96
117
|
title: string,
|
|
97
118
|
message: string,
|
|
98
119
|
options?: RequestPermissionOptions,
|
|
99
|
-
): Promise<
|
|
120
|
+
): Promise<UnattributedDecision> {
|
|
100
121
|
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
|
101
122
|
const decisionOptions = [
|
|
102
123
|
APPROVE_OPTION,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
3
4
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
4
5
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
@@ -163,6 +164,18 @@ export type ForwardedPermissionResponse = {
|
|
|
163
164
|
denialReason?: string;
|
|
164
165
|
responderSessionId: string;
|
|
165
166
|
respondedAt: number;
|
|
167
|
+
/**
|
|
168
|
+
* What decided, inside the responding session (#726).
|
|
169
|
+
*
|
|
170
|
+
* `responderSessionId` names *where* the decision was made; this names
|
|
171
|
+
* *what* made it, which is the difference between a human at the parent's
|
|
172
|
+
* dialog and the parent's policy answering on their behalf.
|
|
173
|
+
*
|
|
174
|
+
* Optional for version-skew tolerance: an older responder omits it, and the
|
|
175
|
+
* requester records the hop with a `null` inner decision rather than
|
|
176
|
+
* rejecting the answer.
|
|
177
|
+
*/
|
|
178
|
+
decidedBy?: DecisionSource;
|
|
166
179
|
};
|
|
167
180
|
|
|
168
181
|
export type PermissionForwardingLocation = {
|
|
@@ -188,7 +201,15 @@ export function normalizePermissionForwardingSessionId(
|
|
|
188
201
|
return trimmed;
|
|
189
202
|
}
|
|
190
203
|
|
|
191
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Make a session id safe to name a path segment.
|
|
206
|
+
*
|
|
207
|
+
* Exported because the forwarding tree has two layouts keyed by session id —
|
|
208
|
+
* `sessions/<id>/` and the serving-heartbeat records beside it — and a second
|
|
209
|
+
* encoding would be a silent way for the two to disagree about which file
|
|
210
|
+
* belongs to which session.
|
|
211
|
+
*/
|
|
212
|
+
export function encodeSessionIdForPath(sessionId: string): string {
|
|
192
213
|
return encodeURIComponent(sessionId);
|
|
193
214
|
}
|
|
194
215
|
|
|
@@ -4,10 +4,15 @@ import type {
|
|
|
4
4
|
KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type Component, matchesKey } from "@earendil-works/pi-tui";
|
|
7
|
+
import type {
|
|
8
|
+
DecisionSource,
|
|
9
|
+
UserDecisionSurface,
|
|
10
|
+
} from "#src/authority/decision-source";
|
|
7
11
|
import {
|
|
8
12
|
type PermissionPromptDecision,
|
|
9
13
|
type RequestPermissionOptions,
|
|
10
14
|
requestPermissionDecisionFromUi,
|
|
15
|
+
type UnattributedDecision,
|
|
11
16
|
} from "#src/authority/permission-dialog";
|
|
12
17
|
import {
|
|
13
18
|
initialPromptState,
|
|
@@ -64,15 +69,23 @@ export interface PromptPreferences {
|
|
|
64
69
|
*
|
|
65
70
|
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
|
|
66
71
|
* one place so the fallback and the inline component never both render.
|
|
72
|
+
*
|
|
73
|
+
* It is therefore also the one place that knows which surface the human
|
|
74
|
+
* answered on, so it is where the decision is attributed to that surface
|
|
75
|
+
* (#726). Having the dialog model and the fallback each name themselves would
|
|
76
|
+
* be two sites that must agree with this branch.
|
|
67
77
|
*/
|
|
68
|
-
export function requestPermissionDecision(
|
|
78
|
+
export async function requestPermissionDecision(
|
|
69
79
|
view: PermissionPromptView,
|
|
70
80
|
title: string,
|
|
71
81
|
payload: PromptPayload,
|
|
72
82
|
options?: RequestPermissionOptions,
|
|
73
83
|
): Promise<PermissionPromptDecision> {
|
|
74
84
|
if (view.mode === "tui") {
|
|
75
|
-
return
|
|
85
|
+
return attributeToHuman(
|
|
86
|
+
await presentInlinePermissionPrompt(view, title, payload, options),
|
|
87
|
+
"dialog",
|
|
88
|
+
);
|
|
76
89
|
}
|
|
77
90
|
// The fallback renders once and cannot re-render, so it neither paints nor
|
|
78
91
|
// offers an expansion; it substitutes a nominal width for the terminal size
|
|
@@ -81,14 +94,25 @@ export function requestPermissionDecision(
|
|
|
81
94
|
...view.budget,
|
|
82
95
|
width: FALLBACK_RENDER_WIDTH,
|
|
83
96
|
});
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
return attributeToHuman(
|
|
98
|
+
await requestPermissionDecisionFromUi(
|
|
99
|
+
view.ui,
|
|
100
|
+
title,
|
|
101
|
+
rendered.lines.join("\n"),
|
|
102
|
+
options,
|
|
103
|
+
),
|
|
104
|
+
"select",
|
|
89
105
|
);
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
function attributeToHuman(
|
|
109
|
+
decision: UnattributedDecision,
|
|
110
|
+
via: UserDecisionSurface,
|
|
111
|
+
): PermissionPromptDecision {
|
|
112
|
+
const decidedBy: DecisionSource = { kind: "user", via };
|
|
113
|
+
return { ...decision, decidedBy };
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
/** The width the `select`/`input` fallback renders against. */
|
|
93
117
|
const FALLBACK_RENDER_WIDTH = 80;
|
|
94
118
|
|
|
@@ -113,13 +137,13 @@ export function presentInlinePermissionPrompt(
|
|
|
113
137
|
title: string,
|
|
114
138
|
payload: PromptPayload,
|
|
115
139
|
options?: RequestPermissionOptions,
|
|
116
|
-
): Promise<
|
|
140
|
+
): Promise<UnattributedDecision> {
|
|
117
141
|
const config: PromptModelConfig = {
|
|
118
142
|
doublePressToConfirm: view.doublePressToConfirm,
|
|
119
143
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
120
144
|
sessionScope: options?.sessionScope,
|
|
121
145
|
};
|
|
122
|
-
return view.ui.custom<
|
|
146
|
+
return view.ui.custom<UnattributedDecision>(
|
|
123
147
|
(tui, theme, keybindings, done) =>
|
|
124
148
|
new PermissionPromptComponent(
|
|
125
149
|
theme,
|
|
@@ -176,7 +200,7 @@ class PermissionPromptComponent implements Component {
|
|
|
176
200
|
private readonly budget: RenderBudget,
|
|
177
201
|
private readonly handleAppAction: (data: string) => boolean,
|
|
178
202
|
private readonly requestRender: () => void,
|
|
179
|
-
private readonly done: (decision:
|
|
203
|
+
private readonly done: (decision: UnattributedDecision) => void,
|
|
180
204
|
) {
|
|
181
205
|
this.state = initialPromptState(config);
|
|
182
206
|
}
|