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.
- package/README.md +66 -2
- package/package.json +1 -1
- package/prompts/auth-compile-agent.md +173 -22
- package/prompts/compile-agent.md +45 -8
- package/prompts/component-recovery.md +40 -0
- package/prompts/master-teach-decision.md +28 -3
- package/prompts/master-teach-focused-planner.md +18 -5
- package/src/cli.ts +26 -18
- package/src/imprint/agent-tool.ts +15 -0
- package/src/imprint/agent.ts +2 -11
- package/src/imprint/audit.ts +35 -13
- package/src/imprint/auth-browser-discovery.ts +276 -0
- package/src/imprint/auth-compile-agent.ts +513 -224
- package/src/imprint/auth-compile-tools.ts +213 -25
- package/src/imprint/auth-discovery-evidence.ts +81 -0
- package/src/imprint/auth-plan.ts +28 -0
- package/src/imprint/auth-verifier.ts +234 -20
- package/src/imprint/authentication-interaction.ts +137 -0
- package/src/imprint/backend-ladder.ts +271 -43
- package/src/imprint/build-plan.ts +2 -22
- package/src/imprint/cdp-browser-fetch.ts +242 -60
- package/src/imprint/chromium.ts +93 -29
- package/src/imprint/claude-cli-compile.ts +2 -0
- package/src/imprint/codex-cli-compile.ts +99 -46
- package/src/imprint/compile-agent-types.ts +9 -5
- package/src/imprint/compile-agent.ts +33 -0
- package/src/imprint/compile-tool-executor.ts +783 -0
- package/src/imprint/compile-tools.ts +42 -9
- package/src/imprint/compile-verification.ts +2 -0
- package/src/imprint/compile.ts +3 -0
- package/src/imprint/compiler-log.ts +16 -0
- package/src/imprint/component-recovery.ts +120 -0
- package/src/imprint/cookie-jar.ts +117 -7
- package/src/imprint/credential-extract.ts +9 -2
- package/src/imprint/credential-lock.ts +147 -0
- package/src/imprint/credential-observation.ts +261 -0
- package/src/imprint/credential-store.ts +284 -44
- package/src/imprint/live-component-recovery.ts +73 -0
- package/src/imprint/llm.ts +4 -3
- package/src/imprint/log.ts +16 -1
- package/src/imprint/login.ts +9 -0
- package/src/imprint/master-teach-agent-contracts.ts +40 -3
- package/src/imprint/master-teach-agents.ts +229 -14
- package/src/imprint/master-teach-checks.ts +8 -1
- package/src/imprint/master-teach-controller.ts +681 -138
- package/src/imprint/master-teach-prompt-projections.ts +2 -0
- package/src/imprint/mcp-cdp-sessions.ts +7 -2
- package/src/imprint/mcp-compile-server.ts +62 -850
- package/src/imprint/mcp-server.ts +667 -91
- package/src/imprint/mcp-session-recovery.ts +167 -0
- package/src/imprint/native-teach-agents.ts +232 -108
- package/src/imprint/runtime.ts +416 -47
- package/src/imprint/stealth-chromium.ts +67 -7
- package/src/imprint/teach-browser-resources.ts +88 -0
- package/src/imprint/teach-stage-progress.ts +38 -0
- package/src/imprint/terminal-user-prompt.ts +42 -0
- package/src/imprint/types.ts +51 -18
- 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(
|
|
2174
|
-
|
|
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(
|
|
3268
|
-
|
|
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(
|
|
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 });
|
package/src/imprint/compile.ts
CHANGED
|
@@ -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(
|
|
163
|
+
setCookieFromHeader(
|
|
164
|
+
setCookie: string,
|
|
165
|
+
requestUrl: string,
|
|
166
|
+
browserSnapshot?: RuntimeCookieJar,
|
|
167
|
+
): void {
|
|
79
168
|
const parsed = parseSetCookie(setCookie, requestUrl);
|
|
80
|
-
if (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
|
-
|
|
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
|
+
}
|