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
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { basename, dirname, resolve } from 'node:path';
|
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { IS_COMPILED_BINARY } from './imprint/is-compiled.ts';
|
|
8
8
|
import type { ProviderName } from './imprint/llm.ts';
|
|
9
|
-
import { isDebug } from './imprint/log.ts';
|
|
9
|
+
import { createLog, isDebug } from './imprint/log.ts';
|
|
10
10
|
import type { FreshTeachTerminalResult } from './imprint/master-teach-controller.ts';
|
|
11
11
|
import { shutdownTracing, traced } from './imprint/tracing.ts';
|
|
12
12
|
import { VERSION } from './imprint/version.ts';
|
|
@@ -142,7 +142,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
142
142
|
{
|
|
143
143
|
name: '--from-session <path>',
|
|
144
144
|
description:
|
|
145
|
-
'Use one exact recording. Repeat the flag to explicitly combine selected recordings. Without it, the newest raw recording is used.',
|
|
145
|
+
'Use one exact recording. Repeat the flag to explicitly combine selected recordings. Existing login references use stored credentials. Without it, the newest raw recording is used.',
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
name: '--from-candidates <run-id>',
|
|
@@ -157,7 +157,8 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
157
157
|
{ name: '--persist-profile', description: 'Reuse a stable Chrome profile for this site.' },
|
|
158
158
|
{
|
|
159
159
|
name: '--no-interactive',
|
|
160
|
-
description:
|
|
160
|
+
description:
|
|
161
|
+
'Disable terminal questions, including MFA/OTP input. Missing user input is reported as unavailable; the complete tool scope remains planned.',
|
|
161
162
|
},
|
|
162
163
|
{
|
|
163
164
|
name: '--agent codex',
|
|
@@ -172,7 +173,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
172
173
|
{
|
|
173
174
|
name: '--model <name>',
|
|
174
175
|
description:
|
|
175
|
-
'Override the teaching-agent model (e.g. claude-sonnet-4-6).
|
|
176
|
+
'Override the teaching-agent model (e.g. claude-sonnet-4-6). Codex defaults to gpt-6.1-sol; otherwise the provider default is used.',
|
|
176
177
|
},
|
|
177
178
|
{
|
|
178
179
|
name: '--timeout <duration>',
|
|
@@ -458,7 +459,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
458
459
|
},
|
|
459
460
|
audit: {
|
|
460
461
|
summary:
|
|
461
|
-
"Drive a headless agent against a site's MCP tools, exercise each one, and compute a deterministic accuracy score. Verdicts come from the agent; the score is computed by imprint.",
|
|
462
|
+
"Drive a headless agent against a site's MCP tools, exercise each one, and compute a deterministic accuracy score. Verdicts come from the agent; the score is computed by imprint. Browser tools inherit explicitly configured Chrome, headed-mode, proxy, display, and session-cache settings.",
|
|
462
463
|
usage: [
|
|
463
464
|
'imprint audit <site> [--strict] [--min-score <n>] [--out <path>] [--model <name>] [--timeout <duration>] [--json]',
|
|
464
465
|
],
|
|
@@ -851,22 +852,29 @@ async function main(argv: string[]): Promise<number> {
|
|
|
851
852
|
// piped, backgrounded) get visibility instead of silence.
|
|
852
853
|
let lastActivity = '';
|
|
853
854
|
const compileStart = Date.now();
|
|
855
|
+
const compileLog = createLog('compile');
|
|
854
856
|
const onDeadlineReached = process.stdin.isTTY
|
|
855
857
|
? async (): Promise<number | null> => {
|
|
856
|
-
const {
|
|
858
|
+
const { requestAgentInteraction } = await import('./imprint/user-interaction.ts');
|
|
857
859
|
const elapsed = Math.round((Date.now() - compileStart) / 60000);
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
`
|
|
862
|
-
|
|
863
|
-
|
|
860
|
+
const answer = await requestAgentInteraction({
|
|
861
|
+
action: 'prompt_user',
|
|
862
|
+
prompt: {
|
|
863
|
+
message: `Timeout reached after ${elapsed} minutes. Give the compiler 10 more minutes?`,
|
|
864
|
+
options: ['Continue for 10 minutes', 'Stop'],
|
|
865
|
+
sensitive: false,
|
|
866
|
+
},
|
|
864
867
|
});
|
|
865
|
-
|
|
866
|
-
|
|
868
|
+
return answer.status === 'answered' &&
|
|
869
|
+
'value' in answer &&
|
|
870
|
+
answer.value === 'Continue for 10 minutes'
|
|
871
|
+
? 10 * 60 * 1000
|
|
872
|
+
: null;
|
|
867
873
|
}
|
|
868
874
|
: undefined;
|
|
875
|
+
const { terminalUserPrompt } = await import('./imprint/terminal-user-prompt.ts');
|
|
869
876
|
const result = await generate({
|
|
877
|
+
onPrompt: terminalUserPrompt,
|
|
870
878
|
sessionPath,
|
|
871
879
|
outPath: values.out,
|
|
872
880
|
maxDurationMs,
|
|
@@ -878,9 +886,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
878
886
|
if (activity === lastActivity) return;
|
|
879
887
|
lastActivity = activity;
|
|
880
888
|
const retry = p.verificationCycle > 1 ? ` (retry ${p.verificationCycle - 1})` : '';
|
|
881
|
-
|
|
882
|
-
`[imprint compile] ${formatElapsed(p.elapsedMs)} — ${activity}${retry}\n`,
|
|
883
|
-
);
|
|
889
|
+
compileLog(`${formatElapsed(p.elapsedMs)} — ${activity}${retry}`);
|
|
884
890
|
},
|
|
885
891
|
});
|
|
886
892
|
console.log('');
|
|
@@ -1564,6 +1570,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1564
1570
|
|
|
1565
1571
|
try {
|
|
1566
1572
|
const { teach } = await import('./imprint/teach.ts');
|
|
1573
|
+
const { terminalUserPrompt } = await import('./imprint/terminal-user-prompt.ts');
|
|
1567
1574
|
const result = await traced(
|
|
1568
1575
|
'cli.teach',
|
|
1569
1576
|
'AGENT',
|
|
@@ -1591,13 +1598,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1591
1598
|
persistProfile: values['persist-profile'],
|
|
1592
1599
|
signal: ctrl.signal,
|
|
1593
1600
|
noInteractive: values['no-interactive'],
|
|
1601
|
+
onPrompt: values['no-interactive'] ? undefined : terminalUserPrompt,
|
|
1594
1602
|
provider: values.provider as ProviderName | undefined,
|
|
1595
1603
|
model: values.model,
|
|
1596
1604
|
maxDurationMs: teachTimeoutMs,
|
|
1597
1605
|
unbounded: values.unbounded,
|
|
1598
1606
|
keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
|
|
1599
1607
|
agent: values.agent === 'codex' ? 'codex' : undefined,
|
|
1600
|
-
onProgress: (
|
|
1608
|
+
onProgress: createLog('teach'),
|
|
1601
1609
|
}),
|
|
1602
1610
|
);
|
|
1603
1611
|
const summary = formatFreshTeachSummary(result);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Provider-neutral execution tool contract. */
|
|
2
|
+
export interface AgentTool {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
input_schema: {
|
|
6
|
+
type: 'object';
|
|
7
|
+
properties?: Record<string, unknown>;
|
|
8
|
+
required?: string[];
|
|
9
|
+
[k: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
handler: (
|
|
12
|
+
input: unknown,
|
|
13
|
+
request?: { signal?: AbortSignal },
|
|
14
|
+
) => Promise<{ result: string; isError?: boolean }>;
|
|
15
|
+
}
|
package/src/imprint/agent.ts
CHANGED
|
@@ -20,17 +20,8 @@ import {
|
|
|
20
20
|
} from './provider-retry.ts';
|
|
21
21
|
import { setSpanAttributes, traceToolIoEnabled, traced } from './tracing.ts';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
description: string;
|
|
26
|
-
input_schema: {
|
|
27
|
-
type: 'object';
|
|
28
|
-
properties?: Record<string, unknown>;
|
|
29
|
-
required?: string[];
|
|
30
|
-
[k: string]: unknown;
|
|
31
|
-
};
|
|
32
|
-
handler: (input: unknown) => Promise<{ result: string; isError?: boolean }>;
|
|
33
|
-
}
|
|
23
|
+
import type { AgentTool } from './agent-tool.ts';
|
|
24
|
+
export type { AgentTool } from './agent-tool.ts';
|
|
34
25
|
|
|
35
26
|
export interface AgentProgress {
|
|
36
27
|
/** 1-based turn number (turn 1 is the first LLM call). */
|
package/src/imprint/audit.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { imprintHomeDir } from './paths.ts';
|
|
|
28
28
|
import { type TeachState, loadTeachState } from './teach-state.ts';
|
|
29
29
|
import { type ResolvedTool, discoverTools } from './tool-loader.ts';
|
|
30
30
|
import { recordLlmUsageSpan, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
|
|
31
|
+
import { authActionOutcomes } from './types.ts';
|
|
31
32
|
|
|
32
33
|
const log = createLog('audit');
|
|
33
34
|
|
|
@@ -214,7 +215,11 @@ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPa
|
|
|
214
215
|
continue;
|
|
215
216
|
}
|
|
216
217
|
const pauses = auth
|
|
217
|
-
? Object.entries(auth.actions).
|
|
218
|
+
? Object.entries(auth.actions).flatMap(([name, action]) =>
|
|
219
|
+
authActionOutcomes(action).flatMap((outcome) =>
|
|
220
|
+
outcome.type === 'pause' ? [`${name}: ${outcome.message}`] : [],
|
|
221
|
+
),
|
|
222
|
+
)
|
|
218
223
|
: [];
|
|
219
224
|
if (pauses.length === 0) {
|
|
220
225
|
eligible.push(tool);
|
|
@@ -223,11 +228,7 @@ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPa
|
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
const receipt = readAuthVerificationReceiptStatus(tool.dir, tool.workflow);
|
|
226
|
-
const pauseSummary = pauses
|
|
227
|
-
.map(([name, action]) =>
|
|
228
|
-
action.outcome.type === 'pause' ? `${name}: ${action.outcome.message}` : name,
|
|
229
|
-
)
|
|
230
|
-
.join('; ');
|
|
231
|
+
const pauseSummary = pauses.join('; ');
|
|
231
232
|
skippedInteractiveAuth.push({
|
|
232
233
|
name: tool.workflow.toolName,
|
|
233
234
|
reason: `requires human continuation (${pauseSummary})`,
|
|
@@ -549,8 +550,23 @@ interface RunAuditOptions {
|
|
|
549
550
|
json?: boolean;
|
|
550
551
|
}
|
|
551
552
|
|
|
552
|
-
export function buildAuditMcpEnvironment(
|
|
553
|
+
export function buildAuditMcpEnvironment(
|
|
554
|
+
assetRoot = imprintHomeDir(),
|
|
555
|
+
parentEnv: NodeJS.ProcessEnv = process.env,
|
|
556
|
+
): Record<string, string> {
|
|
557
|
+
const browserEnv: Record<string, string> = {};
|
|
558
|
+
for (const name of [
|
|
559
|
+
'CHROMIUM_PATH',
|
|
560
|
+
'IMPRINT_CDP_HEADED',
|
|
561
|
+
'IMPRINT_JAR_MAX_AGE_SECONDS',
|
|
562
|
+
'IMPRINT_PROXY',
|
|
563
|
+
'DISPLAY',
|
|
564
|
+
]) {
|
|
565
|
+
const value = parentEnv[name];
|
|
566
|
+
if (value !== undefined) browserEnv[name] = value;
|
|
567
|
+
}
|
|
553
568
|
return {
|
|
569
|
+
...browserEnv,
|
|
554
570
|
// Codex MCP server `env` entries are an explicit environment, so relying on
|
|
555
571
|
// the audit driver's inherited IMPRINT_HOME can make an isolated audit
|
|
556
572
|
// silently fall back to ~/.imprint and expose stale tools.
|
|
@@ -560,6 +576,17 @@ export function buildAuditMcpEnvironment(assetRoot = imprintHomeDir()): Record<s
|
|
|
560
576
|
};
|
|
561
577
|
}
|
|
562
578
|
|
|
579
|
+
/** Keep Codex's explicit MCP environment identical to Claude's JSON config. */
|
|
580
|
+
export function buildAuditCodexMcpEnvArgs(
|
|
581
|
+
serverName: string,
|
|
582
|
+
env: Record<string, string>,
|
|
583
|
+
): string[] {
|
|
584
|
+
return Object.entries(env).flatMap(([name, value]) => [
|
|
585
|
+
'-c',
|
|
586
|
+
`mcp_servers.${serverName}.env.${name}=${JSON.stringify(value)}`,
|
|
587
|
+
]);
|
|
588
|
+
}
|
|
589
|
+
|
|
563
590
|
export function buildAuditMcpCommandArgs(site: string, toolNames: readonly string[]): string[] {
|
|
564
591
|
return ['run', CLI_PATH, 'mcp-server', site, '--include-tools', toolNames.join(',')];
|
|
565
592
|
}
|
|
@@ -1109,12 +1136,7 @@ ${buildAuditInitialPrompt(opts, unverifiedNote)}`;
|
|
|
1109
1136
|
`mcp_servers.${serverName}.default_tools_approval_mode=${JSON.stringify('approve')}`,
|
|
1110
1137
|
'-c',
|
|
1111
1138
|
`mcp_servers.${serverName}.tool_timeout_sec=300`,
|
|
1112
|
-
|
|
1113
|
-
`mcp_servers.${serverName}.env.IMPRINT_HOME=${JSON.stringify(mcpEnv.IMPRINT_HOME)}`,
|
|
1114
|
-
'-c',
|
|
1115
|
-
`mcp_servers.${serverName}.env.IMPRINT_AUDIT_PACING_MS=${JSON.stringify(mcpEnv.IMPRINT_AUDIT_PACING_MS)}`,
|
|
1116
|
-
'-c',
|
|
1117
|
-
`mcp_servers.${serverName}.env.IMPRINT_AUDIT_TOOL_DEADLINE_MS=${JSON.stringify(mcpEnv.IMPRINT_AUDIT_TOOL_DEADLINE_MS)}`,
|
|
1139
|
+
...buildAuditCodexMcpEnvArgs(serverName, mcpEnv),
|
|
1118
1140
|
'-c',
|
|
1119
1141
|
'shell_environment_policy.inherit=all',
|
|
1120
1142
|
'-',
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type CDP from 'chrome-remote-interface';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { CapturedRequest } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export const AUTH_DISCOVERY_POOL_KEY = 'auth-browser-discovery';
|
|
7
|
+
|
|
8
|
+
/** Browser mechanics only; the auth agent chooses the page, controls and factor. */
|
|
9
|
+
export const AuthBrowserActionSchema = z.discriminatedUnion('action', [
|
|
10
|
+
z.object({ action: z.literal('inspect') }),
|
|
11
|
+
z.object({ action: z.literal('navigate'), url: z.string().url() }),
|
|
12
|
+
z.object({ action: z.literal('click'), selector: z.string().min(1) }),
|
|
13
|
+
z.object({ action: z.literal('fill'), selector: z.string().min(1), value: z.string() }),
|
|
14
|
+
z.object({ action: z.literal('select'), selector: z.string().min(1), value: z.string() }),
|
|
15
|
+
z.object({ action: z.literal('press'), key: z.string().min(1) }),
|
|
16
|
+
z.object({ action: z.literal('wait'), milliseconds: z.number().int().min(0).max(30_000) }),
|
|
17
|
+
]);
|
|
18
|
+
export type AuthBrowserAction = z.infer<typeof AuthBrowserActionSchema>;
|
|
19
|
+
|
|
20
|
+
export interface AuthBrowserObservation {
|
|
21
|
+
sourceId: string;
|
|
22
|
+
page: {
|
|
23
|
+
url: string;
|
|
24
|
+
title: string;
|
|
25
|
+
bodyText: string;
|
|
26
|
+
controls: Array<{
|
|
27
|
+
selector: string;
|
|
28
|
+
tag: string;
|
|
29
|
+
type: string;
|
|
30
|
+
name: string;
|
|
31
|
+
text: string;
|
|
32
|
+
disabled: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
controlsTruncated: boolean;
|
|
35
|
+
};
|
|
36
|
+
requests: CapturedRequest[];
|
|
37
|
+
captureErrors: Array<{ seq: number; message: string }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Client = CDP.Client;
|
|
41
|
+
|
|
42
|
+
/** Kept attached between checkpoints, including while the human handles an OTP.
|
|
43
|
+
* Request identity is allocated on send, never on response completion. */
|
|
44
|
+
export class AuthBrowserDiscovery {
|
|
45
|
+
private readonly sourceId = randomUUID();
|
|
46
|
+
private readonly started = Date.now();
|
|
47
|
+
private sequence = 0;
|
|
48
|
+
private readonly inflight = new Map<string, CapturedRequest>();
|
|
49
|
+
private readonly changed = new Map<number, CapturedRequest>();
|
|
50
|
+
private readonly bodyReads = new Set<Promise<void>>();
|
|
51
|
+
private errors: AuthBrowserObservation['captureErrors'] = [];
|
|
52
|
+
private closed = false;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly client: Client,
|
|
56
|
+
private readonly timeoutMs: number,
|
|
57
|
+
) {
|
|
58
|
+
client.Network.requestWillBeSent((event) => {
|
|
59
|
+
if (this.closed) return;
|
|
60
|
+
const previous = this.inflight.get(event.requestId);
|
|
61
|
+
if (previous && event.redirectResponse) {
|
|
62
|
+
previous.response = {
|
|
63
|
+
status: event.redirectResponse.status,
|
|
64
|
+
headers: headers(event.redirectResponse.headers),
|
|
65
|
+
mimeType: event.redirectResponse.mimeType,
|
|
66
|
+
};
|
|
67
|
+
this.changed.set(previous.seq, previous);
|
|
68
|
+
}
|
|
69
|
+
if (!/^https?:/.test(event.request.url)) return;
|
|
70
|
+
const request: CapturedRequest = {
|
|
71
|
+
seq: this.sequence++,
|
|
72
|
+
timestamp: Date.now() - this.started,
|
|
73
|
+
method: event.request.method,
|
|
74
|
+
url: event.request.url,
|
|
75
|
+
headers: headers(event.request.headers),
|
|
76
|
+
body: event.request.postData,
|
|
77
|
+
resourceType: event.type ?? 'Other',
|
|
78
|
+
};
|
|
79
|
+
this.inflight.set(event.requestId, request);
|
|
80
|
+
this.changed.set(request.seq, request);
|
|
81
|
+
if (event.request.hasPostData && event.request.postData === undefined) {
|
|
82
|
+
this.track(async () => {
|
|
83
|
+
try {
|
|
84
|
+
const body = await this.command(
|
|
85
|
+
client.Network.getRequestPostData({ requestId: event.requestId }),
|
|
86
|
+
);
|
|
87
|
+
request.body = body.postData;
|
|
88
|
+
} catch {
|
|
89
|
+
this.errors.push({ seq: request.seq, message: 'Request body unavailable from CDP.' });
|
|
90
|
+
}
|
|
91
|
+
this.changed.set(request.seq, request);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
client.Network.responseReceived((event) => {
|
|
96
|
+
if (this.closed) return;
|
|
97
|
+
const request = this.inflight.get(event.requestId);
|
|
98
|
+
if (!request) return;
|
|
99
|
+
request.response = {
|
|
100
|
+
status: event.response.status,
|
|
101
|
+
headers: headers(event.response.headers),
|
|
102
|
+
mimeType: event.response.mimeType,
|
|
103
|
+
};
|
|
104
|
+
this.changed.set(request.seq, request);
|
|
105
|
+
});
|
|
106
|
+
client.Network.loadingFinished((event) => {
|
|
107
|
+
if (this.closed) return;
|
|
108
|
+
const request = this.inflight.get(event.requestId);
|
|
109
|
+
if (!request) return;
|
|
110
|
+
this.inflight.delete(event.requestId);
|
|
111
|
+
this.track(async () => {
|
|
112
|
+
try {
|
|
113
|
+
const body = await this.command(
|
|
114
|
+
client.Network.getResponseBody({ requestId: event.requestId }),
|
|
115
|
+
);
|
|
116
|
+
if (request.response) {
|
|
117
|
+
request.response.body = body.base64Encoded
|
|
118
|
+
? Buffer.from(body.body, 'base64').toString('utf8')
|
|
119
|
+
: body.body;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
this.errors.push({ seq: request.seq, message: 'Response body unavailable from CDP.' });
|
|
123
|
+
}
|
|
124
|
+
this.changed.set(request.seq, request);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
client.Network.loadingFailed((event) => {
|
|
128
|
+
if (this.closed) return;
|
|
129
|
+
const request = this.inflight.get(event.requestId);
|
|
130
|
+
if (!request) return;
|
|
131
|
+
this.inflight.delete(event.requestId);
|
|
132
|
+
this.errors.push({ seq: request.seq, message: event.errorText });
|
|
133
|
+
this.changed.set(request.seq, request);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
close(): void {
|
|
138
|
+
this.closed = true;
|
|
139
|
+
this.inflight.clear();
|
|
140
|
+
this.changed.clear();
|
|
141
|
+
this.errors = [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async interact(
|
|
145
|
+
action: AuthBrowserAction,
|
|
146
|
+
clickExpression: (selector: string) => string,
|
|
147
|
+
): Promise<AuthBrowserObservation> {
|
|
148
|
+
const { Input, Page } = this.client;
|
|
149
|
+
if (action.action === 'navigate') {
|
|
150
|
+
if (!/^https?:/.test(action.url)) throw new Error('Navigation requires an HTTP(S) URL.');
|
|
151
|
+
const result = await this.command(Page.navigate({ url: action.url }));
|
|
152
|
+
if (result.errorText) throw new Error(result.errorText);
|
|
153
|
+
} else if (action.action === 'click') {
|
|
154
|
+
const point = (await this.evaluate(clickExpression(action.selector))) as {
|
|
155
|
+
x?: number;
|
|
156
|
+
y?: number;
|
|
157
|
+
diagnostic?: unknown;
|
|
158
|
+
};
|
|
159
|
+
if (point.x === undefined || point.y === undefined)
|
|
160
|
+
throw new Error(`Click target unavailable: ${JSON.stringify(point.diagnostic)}`);
|
|
161
|
+
await this.command(
|
|
162
|
+
Input.dispatchMouseEvent({
|
|
163
|
+
type: 'mousePressed',
|
|
164
|
+
x: point.x,
|
|
165
|
+
y: point.y,
|
|
166
|
+
button: 'left',
|
|
167
|
+
clickCount: 1,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
await this.command(
|
|
171
|
+
Input.dispatchMouseEvent({
|
|
172
|
+
type: 'mouseReleased',
|
|
173
|
+
x: point.x,
|
|
174
|
+
y: point.y,
|
|
175
|
+
button: 'left',
|
|
176
|
+
clickCount: 1,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
} else if (action.action === 'fill' || action.action === 'select') {
|
|
180
|
+
await this.evaluate(`(() => {
|
|
181
|
+
const matches = document.querySelectorAll(${JSON.stringify(action.selector)});
|
|
182
|
+
if (matches.length !== 1) throw new Error('Expected exactly one input control');
|
|
183
|
+
const element = matches[0];
|
|
184
|
+
if (element.matches(':disabled')) throw new Error('Control is disabled');
|
|
185
|
+
element.focus();
|
|
186
|
+
if (${JSON.stringify(action.action)} === 'select') {
|
|
187
|
+
if (!(element instanceof HTMLSelectElement)) throw new Error('Control is not a select');
|
|
188
|
+
element.value = ${JSON.stringify(action.value)};
|
|
189
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
190
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
191
|
+
} else {
|
|
192
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) throw new Error('Control is not a text input');
|
|
193
|
+
element.select();
|
|
194
|
+
}
|
|
195
|
+
})()`);
|
|
196
|
+
if (action.action === 'fill') await this.command(Input.insertText({ text: action.value }));
|
|
197
|
+
} else if (action.action === 'press') {
|
|
198
|
+
const code = { Enter: 13, Tab: 9, Escape: 27, Backspace: 8 }[action.key];
|
|
199
|
+
await this.command(
|
|
200
|
+
Input.dispatchKeyEvent({
|
|
201
|
+
type: 'keyDown',
|
|
202
|
+
key: action.key,
|
|
203
|
+
windowsVirtualKeyCode: code,
|
|
204
|
+
...(action.key === 'Enter' ? { text: '\r' } : {}),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
await this.command(
|
|
208
|
+
Input.dispatchKeyEvent({ type: 'keyUp', key: action.key, windowsVirtualKeyCode: code }),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
// This is an observation interval, not a claim that the page has settled.
|
|
212
|
+
// The agent can inspect/wait again for slow responses or external actions.
|
|
213
|
+
await new Promise((resolve) =>
|
|
214
|
+
setTimeout(resolve, action.action === 'wait' ? action.milliseconds : 250),
|
|
215
|
+
);
|
|
216
|
+
const page = (await this.evaluate(`(() => {
|
|
217
|
+
const selector = (element) => {
|
|
218
|
+
if (element.id && document.querySelectorAll('#' + CSS.escape(element.id)).length === 1) return '#' + CSS.escape(element.id);
|
|
219
|
+
const parts = [];
|
|
220
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
|
|
221
|
+
const peers = node.parentElement ? [...node.parentElement.children].filter(p => p.tagName === node.tagName) : [node];
|
|
222
|
+
parts.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + (peers.indexOf(node) + 1) + ')');
|
|
223
|
+
}
|
|
224
|
+
return parts.join(' > ');
|
|
225
|
+
};
|
|
226
|
+
const controls = [...document.querySelectorAll('input:not([type=hidden]),button,select,textarea,a[href],[role=button],[role=radio]')].filter(e => e.getClientRects().length);
|
|
227
|
+
return {
|
|
228
|
+
url: location.href, title: document.title, bodyText: (document.body?.innerText ?? '').slice(0, 20000),
|
|
229
|
+
controls: controls.slice(0, 100).map(e => ({ selector: selector(e), tag: e.tagName.toLowerCase(), type: e.getAttribute('type') || '', name: e.getAttribute('name') || '', text: (e.getAttribute('aria-label') || e.labels?.[0]?.innerText || e.innerText || '').slice(0, 300), disabled: e.matches(':disabled') })),
|
|
230
|
+
controlsTruncated: controls.length > 100
|
|
231
|
+
};
|
|
232
|
+
})()`)) as AuthBrowserObservation['page'];
|
|
233
|
+
await Promise.all([...this.bodyReads]);
|
|
234
|
+
const requests = structuredClone([...this.changed.values()]);
|
|
235
|
+
this.changed.clear();
|
|
236
|
+
const captureErrors = this.errors;
|
|
237
|
+
this.errors = [];
|
|
238
|
+
return { sourceId: this.sourceId, page, requests, captureErrors };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private track(work: () => Promise<void>): void {
|
|
242
|
+
const promise = work();
|
|
243
|
+
this.bodyReads.add(promise);
|
|
244
|
+
void promise.finally(() => this.bodyReads.delete(promise));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async evaluate(expression: string): Promise<unknown> {
|
|
248
|
+
const result = await this.command(
|
|
249
|
+
this.client.Runtime.evaluate({ expression, returnByValue: true }),
|
|
250
|
+
);
|
|
251
|
+
if (result.exceptionDetails)
|
|
252
|
+
throw new Error(result.exceptionDetails.text || 'Browser interaction failed');
|
|
253
|
+
return result.result.value;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async command<T>(promise: Promise<T>): Promise<T> {
|
|
257
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
258
|
+
try {
|
|
259
|
+
return await Promise.race([
|
|
260
|
+
promise,
|
|
261
|
+
new Promise<never>((_, reject) => {
|
|
262
|
+
timer = setTimeout(
|
|
263
|
+
() => reject(new Error('Browser discovery CDP command timed out')),
|
|
264
|
+
this.timeoutMs,
|
|
265
|
+
);
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function headers(values: Record<string, unknown>): Record<string, string> {
|
|
275
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)]));
|
|
276
|
+
}
|