specpi 0.11.2 → 0.13.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/NPM_RELEASE.md +3 -1
  3. package/README.md +41 -29
  4. package/SECURITY_MODEL.md +36 -0
  5. package/THIRD_PARTY.md +18 -2
  6. package/docs/browser-testing.md +76 -0
  7. package/docs/delegation/README.md +264 -0
  8. package/docs/delegation/design-protocol.md +382 -0
  9. package/docs/delegation/design.md +525 -0
  10. package/docs/delegation/evaluation.md +307 -0
  11. package/docs/delegation/protocol.md +271 -0
  12. package/docs/delegation/research.md +216 -0
  13. package/extensions/browser/core.d.mts +64 -0
  14. package/extensions/browser/diagnostics.ts +275 -0
  15. package/extensions/browser/index.ts +349 -57
  16. package/extensions/browser/interactions.ts +118 -0
  17. package/extensions/browser/lifecycle.ts +28 -0
  18. package/extensions/command-guard/index.ts +118 -33
  19. package/extensions/delegation/core.mjs +772 -0
  20. package/extensions/delegation/errors.mjs +8 -0
  21. package/extensions/delegation/extension.mjs +475 -0
  22. package/extensions/delegation/index.ts +9 -0
  23. package/extensions/delegation/managed-files.mjs +13 -0
  24. package/extensions/delegation/native.mjs +155 -0
  25. package/extensions/delegation/presentation.mjs +315 -0
  26. package/extensions/delegation/protocol.mjs +296 -0
  27. package/extensions/delegation/provider.mjs +689 -0
  28. package/extensions/delegation/snapshot.mjs +532 -0
  29. package/extensions/delegation/worker.mjs +218 -0
  30. package/extensions/tool-wishlist/verification.mjs +1 -0
  31. package/extensions/workflow-controls/index.ts +5 -1
  32. package/package.json +17 -4
  33. package/scripts/check-package.mjs +29 -3
  34. package/scripts/check-pi-package.mjs +5 -0
  35. package/scripts/check-syntax.mjs +61 -0
  36. package/scripts/run-browser-tests.mjs +61 -0
  37. package/scripts/setup-browser-tests.mjs +38 -0
  38. package/scripts/site-browser.mjs +272 -0
  39. package/scripts/specpi.mjs +24 -0
  40. package/site/logo.svg +1 -9
  41. package/templates/AGENTS.md +1 -0
@@ -0,0 +1,118 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type, type Static } from "typebox";
3
+ import type { Locator, Page } from "playwright";
4
+ import { boundedInteger } from "./diagnostics.ts";
5
+ import { normalizeBrowserUrl } from "./core.mjs";
6
+
7
+ const Target = Type.String({
8
+ minLength: 1,
9
+ maxLength: 2000,
10
+ description: "CSS selector, exact text= locator, or current snapshot ref. First match is used.",
11
+ });
12
+ const Timeout = Type.Optional(
13
+ Type.Integer({
14
+ minimum: 1,
15
+ maximum: 30000,
16
+ description: "Whole-operation deadline in milliseconds; default 5000.",
17
+ }),
18
+ );
19
+ export const PressParams = Type.Object(
20
+ { target: Type.Optional(Target), key: Type.String({ minLength: 1, maxLength: 80 }), timeoutMs: Timeout },
21
+ { additionalProperties: false },
22
+ );
23
+ export const SelectionParams = Type.Object(
24
+ {
25
+ target: Target,
26
+ options: Type.Array(
27
+ Type.Object(
28
+ {
29
+ value: Type.Optional(Type.String({ maxLength: 1000 })),
30
+ label: Type.Optional(Type.String({ maxLength: 1000 })),
31
+ index: Type.Optional(Type.Integer({ minimum: 0, maximum: 10000 })),
32
+ },
33
+ { additionalProperties: false },
34
+ ),
35
+ { minItems: 1, maxItems: 50 },
36
+ ),
37
+ timeoutMs: Timeout,
38
+ },
39
+ { additionalProperties: false },
40
+ );
41
+ export const WaitParams = Type.Object(
42
+ {
43
+ condition: StringEnum(["attached", "detached", "visible", "hidden", "text", "url"] as const),
44
+ target: Type.Optional(Target),
45
+ text: Type.Optional(Type.String({ maxLength: 2000 })),
46
+ url: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })),
47
+ timeoutMs: Timeout,
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
52
+ export function interactionTimeout(value?: number): number {
53
+ return boundedInteger(value, 5000, 30000);
54
+ }
55
+
56
+ export function validateKey(key: string): void {
57
+ const parts = key.split("+");
58
+ // A literal plus key is also supported; chords use Playwright key names.
59
+ const final = key === "+" ? "+" : parts.pop()!;
60
+ if (key === "+") {
61
+ parts.length = 0;
62
+ }
63
+
64
+ if (
65
+ parts.some((part) => !["Control", "ControlOrMeta", "Alt", "Shift", "Meta"].includes(part)) ||
66
+ new Set(parts).size !== parts.length ||
67
+ !(
68
+ Array.from(final).length === 1 ||
69
+ /^(?:Tab|Enter|Escape|Backspace|Delete|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|PageUp|PageDown|Space|Insert|F(?:[1-9]|1[0-2]))$/u.test(
70
+ final,
71
+ )
72
+ ) ||
73
+ /[\u0000-\u001f\u007f]/u.test(key)
74
+ ) {
75
+ throw new Error("Unsupported browser key or chord.");
76
+ }
77
+ }
78
+
79
+ export function selectionOptions(params: Static<typeof SelectionParams>) {
80
+ for (const option of params.options) {
81
+ if (Object.keys(option).length !== 1) {
82
+ throw new Error("Each option must specify exactly one of value, label, or index.");
83
+ }
84
+ }
85
+
86
+ return params.options;
87
+ }
88
+
89
+ export function validateWait(params: Static<typeof WaitParams>): void {
90
+ if (params.condition === "url") {
91
+ if (params.url === undefined || params.target !== undefined || params.text !== undefined) {
92
+ throw new Error("URL waits require only url, not target or text.");
93
+ }
94
+ } else if (
95
+ !params.target?.trim() ||
96
+ params.url !== undefined ||
97
+ (params.condition === "text") !== (params.text !== undefined)
98
+ ) {
99
+ throw new Error("Element waits require target; only text waits require text. Do not supply url.");
100
+ }
101
+ }
102
+
103
+ export async function waitForCondition(
104
+ page: Page,
105
+ locator: Locator | undefined,
106
+ params: Static<typeof WaitParams>,
107
+ timeout: number,
108
+ ): Promise<void> {
109
+ if (params.condition === "url") {
110
+ const expected = normalizeBrowserUrl(params.url!);
111
+ await page.waitForURL((url) => url.href === expected, { timeout, waitUntil: "commit" });
112
+ } else if (params.condition === "text") {
113
+ const literal = params.text!.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
114
+ await locator!.filter({ hasText: new RegExp(`^${literal}$`, "u") }).waitFor({ state: "attached", timeout });
115
+ } else {
116
+ await locator!.waitFor({ state: params.condition, timeout });
117
+ }
118
+ }
@@ -0,0 +1,28 @@
1
+ export const CLEANUP_TIMEOUT_MS = 1000;
2
+
3
+ export class BrowserCleanupError extends Error {
4
+ constructor() {
5
+ super(
6
+ "Browser cleanup failed or did not settle within 1000ms. The context was detached and diagnostics discarded, but a browser process may remain; inspect local browser processes before continuing.",
7
+ );
8
+ this.name = "BrowserCleanupError";
9
+ }
10
+ }
11
+
12
+ /** Bound settlement without claiming that an unresponsive external process was terminated. */
13
+ export async function settleBrowserCleanup(operations: Promise<unknown>[]): Promise<void> {
14
+ let timer: ReturnType<typeof setTimeout> | undefined;
15
+ try {
16
+ const result = await Promise.race([
17
+ Promise.allSettled(operations),
18
+ new Promise<never>((_resolve, reject) => {
19
+ timer = setTimeout(() => reject(new BrowserCleanupError()), CLEANUP_TIMEOUT_MS);
20
+ }),
21
+ ]);
22
+ if (result.some((entry) => entry.status === "rejected")) {
23
+ throw new BrowserCleanupError();
24
+ }
25
+ } finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
@@ -18,6 +18,7 @@ type State = {
18
18
  categories: Record<string, number>;
19
19
  rules: Record<string, number>;
20
20
  criticalRule?: string;
21
+ onModeChanged?: () => void;
21
22
  };
22
23
 
23
24
  function validRecord(value: unknown): value is Record<string, unknown> {
@@ -138,12 +139,17 @@ function decisionPrompt(decision: any, cwd: string, affected: string): string {
138
139
  return boundedReason(fields, 1200);
139
140
  }
140
141
 
142
+ function lockSession(state: State): void {
143
+ state.mode = "locked";
144
+ state.generation += 1;
145
+ state.sessionApprovals.clear();
146
+ state.onModeChanged?.();
147
+ }
148
+
141
149
  function deny(state: State, reason: string, critical = false): { block: true; reason: string } {
142
150
  state.blocks += 1;
143
151
  if (critical) {
144
- state.mode = "locked";
145
- state.generation += 1;
146
- state.sessionApprovals.clear();
152
+ lockSession(state);
147
153
  }
148
154
 
149
155
  return { block: true, reason: boundedReason(reason) };
@@ -175,6 +181,34 @@ export default function registerCommandGuard(
175
181
  categories: {},
176
182
  rules: {},
177
183
  };
184
+ state.onModeChanged = () => pi.events?.emit("specpi:guard-policy-changed", { reason: "guard policy changed" });
185
+ let guardStateSubscription: (() => void) | undefined;
186
+ const subscribeGuardState = () => {
187
+ if (!guardStateSubscription) {
188
+ guardStateSubscription = pi.events?.on?.("specpi:guard-state", (request: any) => {
189
+ request.reply({ mode: state.ready && !state.startupFailed ? state.mode : undefined });
190
+ });
191
+ }
192
+ };
193
+
194
+ subscribeGuardState();
195
+ const delegationPolicy = (input: unknown): { fingerprint: string; summary: string } | undefined => {
196
+ let replies = 0;
197
+ let policy: any;
198
+ pi.events?.emit("specpi:delegation-policy", {
199
+ input,
200
+ reply(value: any) {
201
+ replies += 1;
202
+ policy = value;
203
+ },
204
+ });
205
+ if (replies !== 1 || !/^[a-f0-9]{64}$/u.test(policy?.fingerprint) || typeof policy?.summary !== "string") {
206
+ return undefined;
207
+ }
208
+
209
+ return { fingerprint: policy.fingerprint, summary: boundedReason(policy.summary, 1600) };
210
+ };
211
+
178
212
  const reset = () => {
179
213
  clearAnalysisCache();
180
214
  state.mode = "guard";
@@ -192,10 +226,17 @@ export default function registerCommandGuard(
192
226
 
193
227
  pi.on("session_start", async (_event, ctx) => {
194
228
  reset();
229
+ const startupGeneration = state.generation;
195
230
  try {
231
+ subscribeGuardState();
196
232
  const choice = ctx.hasUI ? await startupChoice(ctx, startupTimeoutMs) : undefined;
233
+ if (state.generation !== startupGeneration) {
234
+ return;
235
+ }
236
+
237
+ let mode: "guard" | "strict" | "off" = "guard";
197
238
  if (choice === "Strict") {
198
- state.mode = "strict";
239
+ mode = "strict";
199
240
  } else if (choice === "Off for this session") {
200
241
  const confirmed = ctx.hasUI
201
242
  ? await withTimeout(
@@ -207,10 +248,15 @@ export default function registerCommandGuard(
207
248
  startupTimeoutMs,
208
249
  )
209
250
  : false;
210
- state.mode = confirmed ? "off" : "guard";
251
+ if (state.generation !== startupGeneration) {
252
+ return;
253
+ }
254
+
255
+ mode = confirmed ? "off" : "guard";
211
256
  }
212
257
 
213
- state.baseMode = state.mode;
258
+ state.mode = mode;
259
+ state.baseMode = mode;
214
260
  state.ready = true;
215
261
  state.startupFailed = false;
216
262
  updateStatus(ctx, state);
@@ -221,6 +267,10 @@ export default function registerCommandGuard(
221
267
  state.mode === "off" ? "warning" : "info",
222
268
  );
223
269
  } catch {
270
+ if (state.generation !== startupGeneration) {
271
+ return;
272
+ }
273
+
224
274
  state.startupFailed = true;
225
275
  state.ready = false;
226
276
  try {
@@ -232,6 +282,11 @@ export default function registerCommandGuard(
232
282
  });
233
283
  pi.on("session_shutdown", (_event, ctx) => {
234
284
  reset();
285
+ if (typeof guardStateSubscription === "function") {
286
+ guardStateSubscription();
287
+ guardStateSubscription = undefined;
288
+ }
289
+
235
290
  try {
236
291
  ctx.ui.setStatus("specpi-command-guard", undefined);
237
292
  } catch {
@@ -256,6 +311,12 @@ export default function registerCommandGuard(
256
311
  return;
257
312
  }
258
313
 
314
+ if (!["guard", "strict", "off", "unlock", "clear-approvals"].includes(action)) {
315
+ ctx.ui.notify("Usage: /guard [status|guard|strict|off|unlock|clear-approvals]", "error");
316
+
317
+ return;
318
+ }
319
+
259
320
  if (state.mode === "locked" && action !== "unlock") {
260
321
  ctx.ui.notify(
261
322
  "The command guard is locked. Use /guard unlock after reviewing the critical rule.",
@@ -268,6 +329,7 @@ export default function registerCommandGuard(
268
329
  if (action === "clear-approvals") {
269
330
  state.sessionApprovals.clear();
270
331
  state.generation += 1;
332
+ state.onModeChanged?.();
271
333
  ctx.ui.notify("Session approvals cleared.", "info");
272
334
 
273
335
  return;
@@ -280,6 +342,7 @@ export default function registerCommandGuard(
280
342
  return;
281
343
  }
282
344
 
345
+ const approvalGeneration = state.generation;
283
346
  const ok =
284
347
  ctx.hasUI &&
285
348
  (await withTimeout(
@@ -290,11 +353,12 @@ export default function registerCommandGuard(
290
353
  false,
291
354
  approvalTimeoutMs,
292
355
  ));
293
- if (ok) {
356
+ if (ok && state.generation === approvalGeneration) {
294
357
  state.mode = state.baseMode;
295
358
  state.generation += 1;
296
359
  state.sessionApprovals.clear();
297
360
  state.criticalRule = undefined;
361
+ state.onModeChanged?.();
298
362
  updateStatus(ctx, state);
299
363
  ctx.ui.notify(`Command guard unlocked in ${state.baseMode} mode.`, "warning");
300
364
  }
@@ -303,6 +367,11 @@ export default function registerCommandGuard(
303
367
  }
304
368
 
305
369
  if (action === "off") {
370
+ if (state.mode === "off") {
371
+ return;
372
+ }
373
+
374
+ const approvalGeneration = state.generation;
306
375
  if (
307
376
  !ctx.hasUI ||
308
377
  !(await withTimeout(
@@ -312,7 +381,8 @@ export default function registerCommandGuard(
312
381
  ),
313
382
  false,
314
383
  approvalTimeoutMs,
315
- ))
384
+ )) ||
385
+ state.generation !== approvalGeneration
316
386
  ) {
317
387
  return;
318
388
  }
@@ -321,24 +391,31 @@ export default function registerCommandGuard(
321
391
  state.baseMode = "off";
322
392
  state.generation += 1;
323
393
  state.sessionApprovals.clear();
394
+ state.onModeChanged?.();
324
395
  updateStatus(ctx, state);
325
396
 
326
397
  return;
327
398
  }
328
399
 
329
400
  if (action === "strict" || action === "guard") {
401
+ if (state.mode === action) {
402
+ return;
403
+ }
404
+
405
+ const approvalGeneration = state.generation;
330
406
  if (
331
- action === "guard" &&
332
- state.mode === "strict" &&
333
- (!ctx.hasUI ||
334
- !(await withTimeout(
335
- ctx.ui.confirm(
336
- "Switch to Guard mode?",
337
- "This weakens protection for the rest of this session.",
338
- ),
339
- false,
340
- approvalTimeoutMs,
341
- )))
407
+ (action === "guard" &&
408
+ state.mode === "strict" &&
409
+ (!ctx.hasUI ||
410
+ !(await withTimeout(
411
+ ctx.ui.confirm(
412
+ "Switch to Guard mode?",
413
+ "This weakens protection for the rest of this session.",
414
+ ),
415
+ false,
416
+ approvalTimeoutMs,
417
+ )))) ||
418
+ state.generation !== approvalGeneration
342
419
  ) {
343
420
  return;
344
421
  }
@@ -347,12 +424,11 @@ export default function registerCommandGuard(
347
424
  state.baseMode = action;
348
425
  state.generation += 1;
349
426
  state.sessionApprovals.clear();
427
+ state.onModeChanged?.();
350
428
  updateStatus(ctx, state);
351
429
 
352
430
  return;
353
431
  }
354
-
355
- ctx.ui.notify("Usage: /guard [status|guard|strict|off|unlock|clear-approvals]", "error");
356
432
  },
357
433
  });
358
434
 
@@ -462,9 +538,7 @@ export default function registerCommandGuard(
462
538
  }
463
539
 
464
540
  if (answer === "Lock session") {
465
- state.mode = "locked";
466
- state.generation += 1;
467
- state.sessionApprovals.clear();
541
+ lockSession(state);
468
542
  updateStatus(ctx, state);
469
543
 
470
544
  return deny(state, "The session was locked by command-guard approval.");
@@ -557,9 +631,7 @@ export default function registerCommandGuard(
557
631
  }
558
632
 
559
633
  if (answer === "Lock session") {
560
- state.mode = "locked";
561
- state.generation += 1;
562
- state.sessionApprovals.clear();
634
+ lockSession(state);
563
635
  updateStatus(ctx, state);
564
636
 
565
637
  return deny(state, "The session was locked by command-guard approval.");
@@ -573,7 +645,15 @@ export default function registerCommandGuard(
573
645
 
574
646
  if (state.mode === "strict") {
575
647
  recordDecision(state, { category: "unknown", ruleIds: ["tool.unknown-capability"] });
576
- const approvalFingerprint = toolFingerprint(name, input, ctx.cwd, state.mode);
648
+ const capability = name === "delegate" ? delegationPolicy(input) : undefined;
649
+ if (name === "delegate" && !capability) {
650
+ return deny(state, "Delegation policy is unavailable or ambiguous; execution is denied.");
651
+ }
652
+
653
+ const effectiveInput = capability
654
+ ? { input, delegationPolicyFingerprint: capability.fingerprint }
655
+ : input;
656
+ const approvalFingerprint = toolFingerprint(name, effectiveInput, ctx.cwd, state.mode);
577
657
  if (!approvalFingerprint) {
578
658
  return deny(state, "Unknown-tool approval input is malformed or exceeds the safety bound.");
579
659
  }
@@ -589,7 +669,7 @@ export default function registerCommandGuard(
589
669
  const approvalGeneration = state.generation;
590
670
  const answer = await withTimeout(
591
671
  ctx.ui.select(
592
- `Unknown tool approval — name: ${boundedReason(name, 96)}; mode: ${state.mode}; capability is not in the reviewed command-guard catalog.`,
672
+ `Unknown tool approval — name: ${boundedReason(name, 96)}; mode: ${state.mode}; ${capability?.summary ?? "capability is not in the reviewed command-guard catalog."}`,
593
673
  ["Deny (Recommended)", "Allow once", "Allow exact call for session", "Lock session"],
594
674
  ),
595
675
  undefined,
@@ -598,7 +678,14 @@ export default function registerCommandGuard(
598
678
  if (
599
679
  state.generation !== approvalGeneration ||
600
680
  state.mode === "locked" ||
601
- toolFingerprint(name, input, ctx.cwd, state.mode) !== approvalFingerprint
681
+ toolFingerprint(
682
+ name,
683
+ capability
684
+ ? { input, delegationPolicyFingerprint: delegationPolicy(input)?.fingerprint }
685
+ : input,
686
+ ctx.cwd,
687
+ state.mode,
688
+ ) !== approvalFingerprint
602
689
  ) {
603
690
  return deny(state, "Command-guard state or input changed during approval; execution is denied.");
604
691
  }
@@ -619,9 +706,7 @@ export default function registerCommandGuard(
619
706
  }
620
707
 
621
708
  if (answer === "Lock session") {
622
- state.mode = "locked";
623
- state.generation += 1;
624
- state.sessionApprovals.clear();
709
+ lockSession(state);
625
710
  updateStatus(ctx, state);
626
711
  }
627
712