imprint-mcp 0.7.0 → 0.7.2

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 (58) hide show
  1. package/README.md +66 -2
  2. package/package.json +1 -1
  3. package/prompts/auth-compile-agent.md +173 -22
  4. package/prompts/compile-agent.md +45 -8
  5. package/prompts/component-recovery.md +40 -0
  6. package/prompts/master-teach-decision.md +28 -3
  7. package/prompts/master-teach-focused-planner.md +18 -5
  8. package/src/cli.ts +26 -18
  9. package/src/imprint/agent-tool.ts +15 -0
  10. package/src/imprint/agent.ts +2 -11
  11. package/src/imprint/audit.ts +35 -13
  12. package/src/imprint/auth-browser-discovery.ts +276 -0
  13. package/src/imprint/auth-compile-agent.ts +513 -224
  14. package/src/imprint/auth-compile-tools.ts +213 -25
  15. package/src/imprint/auth-discovery-evidence.ts +81 -0
  16. package/src/imprint/auth-plan.ts +28 -0
  17. package/src/imprint/auth-verifier.ts +234 -20
  18. package/src/imprint/authentication-interaction.ts +137 -0
  19. package/src/imprint/backend-ladder.ts +271 -43
  20. package/src/imprint/build-plan.ts +2 -22
  21. package/src/imprint/cdp-browser-fetch.ts +242 -60
  22. package/src/imprint/chromium.ts +93 -29
  23. package/src/imprint/claude-cli-compile.ts +2 -0
  24. package/src/imprint/codex-cli-compile.ts +99 -46
  25. package/src/imprint/compile-agent-types.ts +9 -5
  26. package/src/imprint/compile-agent.ts +33 -0
  27. package/src/imprint/compile-tool-executor.ts +783 -0
  28. package/src/imprint/compile-tools.ts +42 -9
  29. package/src/imprint/compile-verification.ts +2 -0
  30. package/src/imprint/compile.ts +3 -0
  31. package/src/imprint/compiler-log.ts +16 -0
  32. package/src/imprint/component-recovery.ts +120 -0
  33. package/src/imprint/cookie-jar.ts +117 -7
  34. package/src/imprint/credential-extract.ts +9 -2
  35. package/src/imprint/credential-lock.ts +147 -0
  36. package/src/imprint/credential-observation.ts +261 -0
  37. package/src/imprint/credential-store.ts +284 -44
  38. package/src/imprint/live-component-recovery.ts +73 -0
  39. package/src/imprint/llm.ts +4 -3
  40. package/src/imprint/log.ts +16 -1
  41. package/src/imprint/login.ts +9 -0
  42. package/src/imprint/master-teach-agent-contracts.ts +40 -3
  43. package/src/imprint/master-teach-agents.ts +229 -14
  44. package/src/imprint/master-teach-checks.ts +8 -1
  45. package/src/imprint/master-teach-controller.ts +681 -138
  46. package/src/imprint/master-teach-prompt-projections.ts +2 -0
  47. package/src/imprint/mcp-cdp-sessions.ts +7 -2
  48. package/src/imprint/mcp-compile-server.ts +62 -850
  49. package/src/imprint/mcp-server.ts +667 -91
  50. package/src/imprint/mcp-session-recovery.ts +167 -0
  51. package/src/imprint/native-teach-agents.ts +232 -108
  52. package/src/imprint/runtime.ts +416 -47
  53. package/src/imprint/stealth-chromium.ts +67 -7
  54. package/src/imprint/teach-browser-resources.ts +88 -0
  55. package/src/imprint/teach-stage-progress.ts +38 -0
  56. package/src/imprint/terminal-user-prompt.ts +42 -0
  57. package/src/imprint/types.ts +51 -18
  58. package/src/imprint/user-interaction.ts +836 -0
@@ -1,3 +1,4 @@
1
+ import { abortSignalError } from './concurrency.ts';
1
2
  /**
2
3
  * Shared compile-agent tool implementations.
3
4
  *
@@ -67,6 +68,7 @@ import {
67
68
  type Workflow,
68
69
  WorkflowSchema,
69
70
  } from './types.ts';
71
+ import { userInteractionTools } from './user-interaction.ts';
70
72
 
71
73
  const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
72
74
  const MAX_SHARED_MODULE_SOURCE_CHARS = 50_000;
@@ -126,6 +128,7 @@ export function buildCompileTools(
126
128
  isIrreversibleRequest(request) && (!candidateSeqs || candidateSeqs.has(request.seq)),
127
129
  );
128
130
  const tools = [
131
+ ...userInteractionTools(context.signal),
129
132
  buildReadSessionSummaryTool(session, context, toolDir),
130
133
  buildReadRequestTool(session),
131
134
  buildInspectBodyStructureTool(session),
@@ -157,6 +160,8 @@ export function buildCompileTools(
157
160
  }
158
161
 
159
162
  export interface CompileToolContext {
163
+ /** Run/assignment lifetime; individual adapters also forward their request signal. */
164
+ signal?: AbortSignal;
160
165
  candidate?: ToolCandidate;
161
166
  sharedContext?: SharedCompileContext;
162
167
  teachCredentials?: { site: string; values: Record<string, string> };
@@ -2064,6 +2069,7 @@ async function runCommand(
2064
2069
  export async function typecheckArtifacts(
2065
2070
  dir: string,
2066
2071
  includes: string[],
2072
+ signal?: AbortSignal,
2067
2073
  ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
2068
2074
  const configPath = pathJoin(dir, '.imprint-typecheck.tsconfig.json');
2069
2075
  const rootTsconfig = realpathSync(pathJoin(REPO_ROOT, 'tsconfig.json'));
@@ -2096,6 +2102,9 @@ export async function typecheckArtifacts(
2096
2102
  'bunx tsc --noEmit -p .imprint-typecheck.tsconfig.json',
2097
2103
  dir,
2098
2104
  120000,
2105
+ undefined,
2106
+ undefined,
2107
+ signal,
2099
2108
  );
2100
2109
  return JSON.parse(result.result) as {
2101
2110
  stdout: string;
@@ -2133,7 +2142,9 @@ export function buildRunTestsTool(
2133
2142
  properties: {},
2134
2143
  required: [],
2135
2144
  },
2136
- handler: async () => {
2145
+ handler: async (_input, request) => {
2146
+ const signal = request?.signal;
2147
+ if (signal?.aborted) throw abortSignalError(signal);
2137
2148
  const testNames = ['parser.test.ts', 'request.test.ts'].filter((name) =>
2138
2149
  existsSync(pathJoin(toolDir, name)),
2139
2150
  );
@@ -2170,9 +2181,17 @@ export function buildRunTestsTool(
2170
2181
  isError: true,
2171
2182
  };
2172
2183
  }
2173
- const cmdResult = await runCommand(testCommand, toolDir, 120000, {
2174
- [SESSION_PATH_ENV]: sessionPath,
2175
- });
2184
+ const cmdResult = await runCommand(
2185
+ testCommand,
2186
+ toolDir,
2187
+ 120000,
2188
+ {
2189
+ [SESSION_PATH_ENV]: sessionPath,
2190
+ },
2191
+ undefined,
2192
+ signal,
2193
+ );
2194
+ if (signal?.aborted) throw abortSignalError(signal);
2176
2195
 
2177
2196
  const output = JSON.parse(cmdResult.result) as {
2178
2197
  stdout: string;
@@ -2193,7 +2212,7 @@ export function buildRunTestsTool(
2193
2212
  );
2194
2213
  const typecheck =
2195
2214
  typecheckIncludes.length > 0
2196
- ? await typecheckArtifacts(toolDir, typecheckIncludes)
2215
+ ? await typecheckArtifacts(toolDir, typecheckIncludes, signal)
2197
2216
  : { stdout: '', stderr: '', exitCode: 0, timedOut: false };
2198
2217
  const typecheckFailed = typecheck.exitCode !== 0 || typecheck.timedOut;
2199
2218
 
@@ -2971,6 +2990,7 @@ export async function externalVerification(
2971
2990
  session: Session,
2972
2991
  sessionPath: string,
2973
2992
  opts: {
2993
+ signal?: AbortSignal;
2974
2994
  expectedToolName?: string;
2975
2995
  candidateRequestSeqs?: number[];
2976
2996
  /** Build-plan-declared dependency seqs included in exact effect provenance. */
@@ -3001,6 +3021,7 @@ export async function externalVerification(
3001
3021
  * integration run. The caller hands these to the independent semantic agent. */
3002
3022
  integrationEvidence: LiveIntegrationEvidence[];
3003
3023
  }> {
3024
+ if (opts.signal?.aborted) throw abortSignalError(opts.signal);
3004
3025
  const failures: string[] = [];
3005
3026
  const warnings: string[] = [];
3006
3027
  let integrationEvidence: LiveIntegrationEvidence[] = [];
@@ -3203,6 +3224,7 @@ export async function externalVerification(
3203
3224
  },
3204
3225
  {
3205
3226
  networkDisabled: safetyBlocked,
3227
+ signal: opts.signal,
3206
3228
  },
3207
3229
  );
3208
3230
  if (run.exitCode !== 0) {
@@ -3234,6 +3256,7 @@ export async function externalVerification(
3234
3256
  },
3235
3257
  {
3236
3258
  networkDisabled: safetyBlocked,
3259
+ signal: opts.signal,
3237
3260
  },
3238
3261
  );
3239
3262
  if (run.exitCode !== 0) {
@@ -3264,9 +3287,15 @@ export async function externalVerification(
3264
3287
  } catch {
3265
3288
  // Best-effort cleanup before the run.
3266
3289
  }
3267
- const run = await runBunTestWithResults(integrationTestPath, toolDir, verifierTimeoutMs, {
3268
- [LIVE_EVIDENCE_PATH_ENV]: evidencePath,
3269
- });
3290
+ const run = await runBunTestWithResults(
3291
+ integrationTestPath,
3292
+ toolDir,
3293
+ verifierTimeoutMs,
3294
+ {
3295
+ [LIVE_EVIDENCE_PATH_ENV]: evidencePath,
3296
+ },
3297
+ { signal: opts.signal },
3298
+ );
3270
3299
  try {
3271
3300
  integrationEvidence = readLiveIntegrationEvidence(evidencePath);
3272
3301
  } catch (error) {
@@ -3292,7 +3321,11 @@ export async function externalVerification(
3292
3321
  }
3293
3322
 
3294
3323
  if (existsSync(parserPath) || existsSync(parserTestPath)) {
3295
- const output = await typecheckArtifacts(toolDir, ['parser.ts', 'request-transform.ts']);
3324
+ const output = await typecheckArtifacts(
3325
+ toolDir,
3326
+ ['parser.ts', 'request-transform.ts'],
3327
+ opts.signal,
3328
+ );
3296
3329
  if (output.exitCode !== 0 || output.timedOut) {
3297
3330
  failures.push(
3298
3331
  `generated TypeScript artifacts failed typecheck (bunx tsc --noEmit -p .imprint-typecheck.tsconfig.json) exited ${output.exitCode}${output.timedOut ? ' after timing out' : ''}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`,
@@ -33,6 +33,7 @@ export interface LiveIntegrationEvidence {
33
33
  result: LadderRun['result'];
34
34
  usedBackend: LadderRun['usedBackend'];
35
35
  attempts: LadderRun['attempts'];
36
+ responseObservations?: LadderRun['responseObservations'];
36
37
  durationMs: number;
37
38
  }
38
39
 
@@ -182,6 +183,7 @@ export async function runCapturedIntegrationCase(opts: {
182
183
  result: run.result,
183
184
  usedBackend: run.usedBackend,
184
185
  attempts: run.attempts,
186
+ responseObservations: run.responseObservations,
185
187
  durationMs: Date.now() - startedAt,
186
188
  };
187
189
  appendFileSync(evidencePath, `${JSON.stringify(evidence)}\n`, { mode: 0o600 });
@@ -46,6 +46,7 @@ import {
46
46
  type Workflow,
47
47
  WorkflowSchema,
48
48
  } from './types.ts';
49
+ import type { OnUserPrompt } from './user-interaction.ts';
49
50
 
50
51
  const PROMPTS_DIR = pathJoin(import.meta.dir, '..', '..', 'prompts');
51
52
  const log = createLog('compile');
@@ -81,6 +82,7 @@ interface CompileOptions {
81
82
  // ─── generate (workflow.json) ────────────────────────────────────────────────
82
83
 
83
84
  interface GenerateOptions extends CompileOptions {
85
+ onPrompt?: OnUserPrompt;
84
86
  /** Hard wall-clock budget for the agent. Default 30 minutes. */
85
87
  maxDurationMs?: number;
86
88
  /** Progress callback with verification cycle information. */
@@ -146,6 +148,7 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
146
148
  const outDir = opts.outDir ?? (opts.outPath ? dirname(opts.outPath) : undefined);
147
149
  const result = await compileAgent({
148
150
  sessionPath: opts.sessionPath,
151
+ onPrompt: opts.onPrompt,
149
152
  maxDurationMs: opts.maxDurationMs,
150
153
  deadlineMs: opts.deadlineMs,
151
154
  runDeadline: opts.runDeadline,
@@ -10,3 +10,19 @@ export function recordCompilerHostError(conversationLogPath: string, message: st
10
10
  writeFileSync(conversationLogPath, JSON.stringify(log, null, 2), 'utf8');
11
11
  } catch {}
12
12
  }
13
+
14
+ /** Error facts for private compiler journals, without exception messages/values. */
15
+ export function compilerToolFailure(error: unknown): { name: string; code?: string | number } {
16
+ const value =
17
+ error && typeof error === 'object' ? (error as { name?: unknown; code?: unknown }) : {};
18
+ return {
19
+ name:
20
+ typeof value.name === 'string' && /^[A-Za-z][A-Za-z0-9]*Error$/.test(value.name)
21
+ ? value.name
22
+ : 'Error',
23
+ ...(typeof value.code === 'number' ||
24
+ (typeof value.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value.code))
25
+ ? { code: value.code }
26
+ : {}),
27
+ };
28
+ }
@@ -0,0 +1,120 @@
1
+ import { abortSignalError, withAbortSignal } from './concurrency.ts';
2
+ import type { ToolResult } from './types.ts';
3
+
4
+ interface RecoveryComponent {
5
+ id: string;
6
+ kind: string;
7
+ label: string;
8
+ /** Explicit consumers; an empty list does not make an owner global. */
9
+ toolIds: readonly string[];
10
+ }
11
+
12
+ export interface ComponentRecoveryOwner extends RecoveryComponent {
13
+ /** Repair and verify owned state before reporting success. Artifact owners
14
+ * also commit their dependency/build changes through their normal host. */
15
+ recover: (input: {
16
+ failure: Extract<ToolResult<unknown>, { ok: false }>;
17
+ previousRecoveries: number;
18
+ reason: string;
19
+ }) => Promise<boolean>;
20
+ }
21
+
22
+ /** Ownership and change observations are scoped to an explicit component id. */
23
+ export class ComponentRecoveryQueue {
24
+ private readonly tails = new Map<string, Promise<void>>();
25
+ private readonly revisions = new Map<string, number>();
26
+
27
+ snapshot(): ReadonlyMap<string, number> {
28
+ return new Map(this.revisions);
29
+ }
30
+ revision(id: string): number {
31
+ return this.revisions.get(id) ?? 0;
32
+ }
33
+ recovered(id: string): void {
34
+ this.revisions.set(id, this.revision(id) + 1);
35
+ }
36
+
37
+ async run<T>(id: string, work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
38
+ const previous = this.tails.get(id) ?? Promise.resolve();
39
+ let release = () => {};
40
+ const finished = new Promise<void>((resolve) => {
41
+ release = resolve;
42
+ });
43
+ const tail = previous.then(() => finished);
44
+ this.tails.set(id, tail);
45
+ try {
46
+ await withAbortSignal(() => previous, signal);
47
+ if (signal?.aborted) throw abortSignalError(signal);
48
+ return await work();
49
+ } finally {
50
+ release();
51
+ void tail.then(() => {
52
+ if (this.tails.get(id) === tail) this.tails.delete(id);
53
+ });
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Agents select declared owners. The engine never infers cause from a site,
59
+ * error code, message, cookie name, module name, or HTTP status. */
60
+ export async function runWithComponentRecovery<T extends { result: ToolResult<unknown> }>(options: {
61
+ invoke: () => Promise<T>;
62
+ select: (
63
+ failed: T,
64
+ previousRecoveries: number,
65
+ ) => Promise<
66
+ | {
67
+ owner: ComponentRecoveryOwner;
68
+ reason: string;
69
+ }
70
+ | undefined
71
+ >;
72
+ onAttempt: (observed: T, previousRecoveries: number) => void;
73
+ queue?: ComponentRecoveryQueue;
74
+ signal?: AbortSignal;
75
+ }): Promise<T> {
76
+ const queue = options.queue ?? new ComponentRecoveryQueue();
77
+ let previousRecoveries = 0;
78
+ let observedRevisions = queue.snapshot();
79
+ const invoke = async () => {
80
+ if (options.signal?.aborted) throw abortSignalError(options.signal);
81
+ observedRevisions = queue.snapshot();
82
+ const result = await options.invoke();
83
+ options.onAttempt(result, previousRecoveries);
84
+ return result;
85
+ };
86
+ let observed = await invoke();
87
+ while (!observed.result.ok) {
88
+ const selection = await options.select(observed, previousRecoveries);
89
+ if (!selection) return observed;
90
+ const revision = observedRevisions.get(selection.owner.id) ?? 0;
91
+ let declined = false;
92
+ await queue.run(
93
+ selection.owner.id,
94
+ async () => {
95
+ if (queue.revision(selection.owner.id) !== revision) {
96
+ previousRecoveries += queue.revision(selection.owner.id) - revision;
97
+ observed = await invoke();
98
+ return;
99
+ }
100
+ if (observed.result.ok) return;
101
+ if (
102
+ !(await selection.owner.recover({
103
+ failure: observed.result,
104
+ previousRecoveries,
105
+ reason: selection.reason,
106
+ }))
107
+ ) {
108
+ declined = true;
109
+ return;
110
+ }
111
+ queue.recovered(selection.owner.id);
112
+ previousRecoveries++;
113
+ observed = await invoke();
114
+ },
115
+ options.signal,
116
+ );
117
+ if (declined) return observed;
118
+ }
119
+ return observed;
120
+ }
@@ -10,6 +10,8 @@
10
10
  * scope for v1.
11
11
  */
12
12
 
13
+ import { createHash } from 'node:crypto';
14
+
13
15
  export interface RuntimeCookie {
14
16
  name: string;
15
17
  value: string;
@@ -21,6 +23,8 @@ export interface RuntimeCookie {
21
23
  sameSite?: string;
22
24
  hostOnly?: boolean;
23
25
  creationIndex?: number;
26
+ /** Exact browser observation when an independent HTTP response changed this scope. */
27
+ httpResponse?: { browserSnapshotHash: string | null; deleted?: boolean };
24
28
  }
25
29
 
26
30
  export interface CookieLookupConstraints {
@@ -52,14 +56,95 @@ export class RuntimeCookieJar {
52
56
  return this.cookies.map((c) => ({ ...c }));
53
57
  }
54
58
 
59
+ /** Reconcile a complete browser snapshot, including cookies it removed. */
60
+ replace(cookies: RuntimeCookie[]): void {
61
+ this.cookies = [];
62
+ for (const cookie of cookies) this.setCookie(cookie);
63
+ }
64
+
65
+ /** Restore actual browser state while retaining HTTP changes whose browser
66
+ * baseline has not changed since that response, including deletion markers. */
67
+ restoreBrowserSnapshot(cookies: RuntimeCookie[]): void {
68
+ const observed = new RuntimeCookieJar(cookies);
69
+ const independent = this.cookies.filter(
70
+ (cookie) =>
71
+ cookie.httpResponse !== undefined &&
72
+ cookie.httpResponse.browserSnapshotHash === observed.scopeHash(cookie),
73
+ );
74
+ this.replace([...cookies, ...independent]);
75
+ }
76
+
77
+ private scopeHash(cookie: RuntimeCookie): string | null {
78
+ const matching = this.cookies.find((entry) => cookieScopeKey(entry) === cookieScopeKey(cookie));
79
+ if (!matching) return null;
80
+ const { name, value, domain, path, expires, sameSite, hostOnly } = matching;
81
+ return createHash('sha256')
82
+ .update(
83
+ JSON.stringify({
84
+ name,
85
+ value,
86
+ domain,
87
+ path,
88
+ expires,
89
+ sameSite,
90
+ hostOnly,
91
+ secure: Boolean(matching.secure),
92
+ httpOnly: Boolean(matching.httpOnly),
93
+ }),
94
+ )
95
+ .digest('hex');
96
+ }
97
+
98
+ /** Reconcile browser changes while retaining independent HTTP Set-Cookie
99
+ * changes that the browser has not observed. The caller owns the previous
100
+ * browser snapshot separately from this mixed-transport jar. */
101
+ reconcileBrowserSnapshot(cookies: RuntimeCookie[], previous: RuntimeCookieJar): void {
102
+ const next = new RuntimeCookieJar(cookies);
103
+ const scope = cookieScopeKey;
104
+ const before = new Map(previous.cookies.map((cookie) => [scope(cookie), cookie]));
105
+ const after = new Map(next.cookies.map((cookie) => [scope(cookie), cookie]));
106
+ const current = new Map(this.cookies.map((cookie) => [scope(cookie), cookie]));
107
+ const equal = (a: RuntimeCookie | undefined, b: RuntimeCookie | undefined) => {
108
+ const fields: (keyof RuntimeCookie)[] = [
109
+ 'name',
110
+ 'value',
111
+ 'domain',
112
+ 'path',
113
+ 'expires',
114
+ 'httpOnly',
115
+ 'secure',
116
+ 'sameSite',
117
+ 'hostOnly',
118
+ ];
119
+ return (
120
+ a === b ||
121
+ Boolean(
122
+ a &&
123
+ b &&
124
+ fields.every((field) =>
125
+ field === 'secure' || field === 'httpOnly'
126
+ ? Boolean(a[field]) === Boolean(b[field])
127
+ : a[field] === b[field],
128
+ ),
129
+ )
130
+ );
131
+ };
132
+ this.cookies = this.cookies.filter((cookie) => {
133
+ const key = scope(cookie);
134
+ return !before.has(key) || after.has(key) || !equal(cookie, before.get(key));
135
+ });
136
+ for (const [key, cookie] of after) {
137
+ if (!equal(cookie, before.get(key)) || equal(current.get(key), before.get(key)))
138
+ this.setCookie(cookie);
139
+ }
140
+ previous.replace(cookies);
141
+ }
142
+
55
143
  setCookie(cookie: RuntimeCookie): void {
56
144
  const normalized = normalizeCookie(cookie);
57
- const idx = this.cookies.findIndex(
58
- (c) =>
59
- c.name === normalized.name && c.domain === normalized.domain && c.path === normalized.path,
60
- );
145
+ const idx = this.cookies.findIndex((c) => cookieScopeKey(c) === cookieScopeKey(normalized));
61
146
 
62
- if (isExpired(normalized)) {
147
+ if (isExpired(normalized) && !normalized.httpResponse?.deleted) {
63
148
  if (idx >= 0) this.cookies.splice(idx, 1);
64
149
  return;
65
150
  }
@@ -75,9 +160,20 @@ export class RuntimeCookieJar {
75
160
  }
76
161
  }
77
162
 
78
- setCookieFromHeader(setCookie: string, requestUrl: string): void {
163
+ setCookieFromHeader(
164
+ setCookie: string,
165
+ requestUrl: string,
166
+ browserSnapshot?: RuntimeCookieJar,
167
+ ): void {
79
168
  const parsed = parseSetCookie(setCookie, requestUrl);
80
- if (parsed) this.setCookie(parsed);
169
+ if (parsed) {
170
+ if (browserSnapshot)
171
+ parsed.httpResponse = {
172
+ browserSnapshotHash: browserSnapshot.scopeHash(parsed),
173
+ ...(isExpired(parsed) ? { deleted: true } : {}),
174
+ };
175
+ this.setCookie(parsed);
176
+ }
81
177
  }
82
178
 
83
179
  getCookieHeader(url: string): string | null {
@@ -129,6 +225,7 @@ export class RuntimeCookieJar {
129
225
  const nowSeconds = Date.now() / 1000;
130
226
 
131
227
  return this.cookies.filter((c) => {
228
+ if (c.httpResponse?.deleted) return false;
132
229
  if (c.expires !== undefined && c.expires <= nowSeconds) return false;
133
230
  if (c.secure && !secure) return false;
134
231
  if (!domainMatches(c, host)) return false;
@@ -199,6 +296,19 @@ function parseSetCookie(setCookie: string, requestUrl: string): RuntimeCookie |
199
296
  return normalizeCookie(cookie);
200
297
  }
201
298
 
299
+ /** Chromium stores host-only and domain cookies as distinct scopes even when
300
+ * their names, normalized domains, and paths match. */
301
+ export function cookieScopeKey(
302
+ cookie: Pick<RuntimeCookie, 'name' | 'domain' | 'path' | 'hostOnly'>,
303
+ ): string {
304
+ return JSON.stringify([
305
+ normalizeDomain(cookie.domain),
306
+ cookie.hostOnly ?? !cookie.domain.startsWith('.'),
307
+ cookie.path || '/',
308
+ cookie.name,
309
+ ]);
310
+ }
311
+
202
312
  function normalizeCookie(cookie: RuntimeCookie): RuntimeCookie {
203
313
  const expires = cookie.expires === -1 ? undefined : cookie.expires;
204
314
  return {
@@ -57,6 +57,11 @@ interface ExtractionResult {
57
57
  replacements: Replacement[];
58
58
  }
59
59
 
60
+ /** Existing workflow references are not newly recorded secret values. */
61
+ export function isCredentialReference(value: string): boolean {
62
+ return /^\$\{credential\.[^}]+\}$/.test(value);
63
+ }
64
+
60
65
  /** Parsers are tried in this order on every request that has a body. Each
61
66
  * one is side-effect-free and returns `null` when its input doesn't fit
62
67
  * its expected framing — so trying JSON first on a form body, or form on
@@ -112,7 +117,7 @@ export function extractCredentials(session: Session): ExtractionResult {
112
117
  confirmedByDom,
113
118
  });
114
119
 
115
- replacements.push(
120
+ for (const replacement of [
116
121
  {
117
122
  requestSeq: req.seq,
118
123
  location: found.usernameLocation,
@@ -125,7 +130,9 @@ export function extractCredentials(session: Session): ExtractionResult {
125
130
  originalValue: found.passwordValue,
126
131
  placeholder: '${credential.password}',
127
132
  },
128
- );
133
+ ]) {
134
+ if (!isCredentialReference(replacement.originalValue)) replacements.push(replacement);
135
+ }
129
136
  }
130
137
 
131
138
  return { findings, replacements };
@@ -0,0 +1,147 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { abortableDelay } from './concurrency.ts';
13
+
14
+ interface Ticket {
15
+ pid: number;
16
+ started: string | null;
17
+ nonce: string;
18
+ /** null means the contender is still choosing its ticket number. */
19
+ ticket: number | null;
20
+ }
21
+
22
+ function processStart(pid: number): string | null {
23
+ if (process.platform === 'win32') return null;
24
+ try {
25
+ return (
26
+ execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
27
+ encoding: 'utf8',
28
+ stdio: ['ignore', 'pipe', 'ignore'],
29
+ timeout: 2_000,
30
+ }).trim() || null
31
+ );
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const ownStart = processStart(process.pid);
38
+
39
+ function ownerIsGone(owner: Ticket): boolean {
40
+ if (owner.pid === process.pid)
41
+ return owner.started !== null && ownStart !== null && owner.started !== ownStart;
42
+ try {
43
+ process.kill(owner.pid, 0);
44
+ } catch (error) {
45
+ return (error as NodeJS.ErrnoException).code === 'ESRCH';
46
+ }
47
+ if (!owner.started) return false;
48
+ const currentStart = processStart(owner.pid);
49
+ return currentStart !== null && currentStart !== owner.started;
50
+ }
51
+
52
+ function removeOwned(path: string, nonce: string): void {
53
+ try {
54
+ const value = JSON.parse(readFileSync(path, 'utf8')) as Ticket;
55
+ if (value.nonce === nonce) unlinkSync(path);
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
58
+ }
59
+ }
60
+
61
+ function readTickets(directory: string): Ticket[] {
62
+ const tickets: Ticket[] = [];
63
+ for (const name of readdirSync(directory)) {
64
+ if (!name.endsWith('.json')) continue;
65
+ const path = join(directory, name);
66
+ let owner: Ticket;
67
+ try {
68
+ owner = JSON.parse(readFileSync(path, 'utf8')) as Ticket;
69
+ } catch (error) {
70
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
71
+ throw error;
72
+ }
73
+ if (
74
+ !Number.isSafeInteger(owner.pid) ||
75
+ owner.pid <= 0 ||
76
+ !(owner.started === null || typeof owner.started === 'string') ||
77
+ name !== `${owner.nonce}.json` ||
78
+ !(owner.ticket === null || (Number.isSafeInteger(owner.ticket) && owner.ticket > 0))
79
+ ) {
80
+ throw new Error('Invalid credential lock owner metadata');
81
+ }
82
+ if (ownerIsGone(owner)) {
83
+ // Each owner has a unique path. Recovering this dead owner cannot delete
84
+ // a replacement owner's lock, even when several processes notice it.
85
+ removeOwned(path, owner.nonce);
86
+ } else tickets.push(owner);
87
+ }
88
+ return tickets;
89
+ }
90
+
91
+ /** A cross-process mutex for one durable credential namespace. Unique ticket
92
+ * paths avoid stale-owner cleanup racing a new lock holder. There is no lease
93
+ * timeout: a live owner keeps the lock until it finishes or is cancelled. */
94
+ export async function withCredentialLock<T>(
95
+ directory: string,
96
+ signal: AbortSignal | undefined,
97
+ work: () => Promise<T>,
98
+ pollMs = 20,
99
+ ): Promise<T> {
100
+ signal?.throwIfAborted();
101
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
102
+ const nonce = randomUUID();
103
+ const path = join(directory, `${nonce}.json`);
104
+ const temporary = join(directory, `${nonce}.tmp`);
105
+ const owner: Ticket = { pid: process.pid, started: ownStart, nonce, ticket: null };
106
+ const publish = () => {
107
+ writeFileSync(temporary, JSON.stringify(owner), { mode: 0o600 });
108
+ renameSync(temporary, path);
109
+ };
110
+ let result: T | undefined;
111
+ let failure: { error: unknown } | undefined;
112
+ try {
113
+ publish();
114
+ const tickets = readTickets(directory);
115
+ owner.ticket = Math.max(0, ...tickets.map(({ ticket }) => ticket ?? 0)) + 1;
116
+ publish();
117
+ while (true) {
118
+ signal?.throwIfAborted();
119
+ const ahead = readTickets(directory).some(
120
+ (other) =>
121
+ other.nonce !== nonce &&
122
+ (other.ticket === null ||
123
+ other.ticket < (owner.ticket ?? 0) ||
124
+ (other.ticket === owner.ticket && other.nonce < nonce)),
125
+ );
126
+ if (!ahead) break;
127
+ await abortableDelay(pollMs, signal);
128
+ }
129
+ signal?.throwIfAborted();
130
+ result = await work();
131
+ } catch (error) {
132
+ failure = { error };
133
+ } finally {
134
+ try {
135
+ removeOwned(path, nonce);
136
+ } catch (error) {
137
+ failure ??= { error };
138
+ }
139
+ try {
140
+ unlinkSync(temporary);
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') failure ??= { error };
143
+ }
144
+ }
145
+ if (failure) throw failure.error;
146
+ return result as T;
147
+ }