autokap 1.8.6 → 1.8.7

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.
@@ -4,13 +4,28 @@
4
4
  * Deterministic evaluation of postconditions after each opcode.
5
5
  * No LLM calls — purely structural checks against AKTree, URL, and screenshots.
6
6
  */
7
- import type { RuntimeAdapter, PostconditionSpec } from './execution-types.js';
7
+ import type { RuntimeAdapter, PostconditionSpec, ProgressSnapshot } from './execution-types.js';
8
8
  /**
9
9
  * Evaluates whether a postcondition holds.
10
10
  * Retries internally up to postcondition.waitMs (polling).
11
11
  * Returns true if the condition is satisfied, false otherwise.
12
12
  */
13
- export declare function evaluatePostcondition(adapter: RuntimeAdapter, spec: PostconditionSpec): Promise<{
13
+ export interface PostconditionResult {
14
14
  passed: boolean;
15
15
  reason: string;
16
- }>;
16
+ /**
17
+ * AUT-240 (decision 2): the check could not be verified deterministically
18
+ * (an AKTree probe kept throwing) and was assumed-OK as a last resort. The
19
+ * capture is flagged low-confidence rather than failed.
20
+ */
21
+ lowConfidence?: boolean;
22
+ }
23
+ export declare function evaluatePostcondition(adapter: RuntimeAdapter, spec: PostconditionSpec): Promise<PostconditionResult>;
24
+ /**
25
+ * Evaluate a postcondition with extend-on-progress (AUT-240, Layer C): the poll
26
+ * gets a generous budget up to the global deadline and the progress watchdog
27
+ * cuts it only when the page is genuinely stuck. Replaces the old clamp-to-
28
+ * remaining-budget that could starve the check to ~1ms after a slow action.
29
+ * Shared by the runner (main path) and the recovery chain (retry re-check).
30
+ */
31
+ export declare function evaluatePostconditionWithProgress(adapter: RuntimeAdapter, spec: PostconditionSpec, startedAtMs: number, globalDeadlineMs: number, getProgress: (() => Promise<ProgressSnapshot | null>) | undefined): Promise<PostconditionResult>;
@@ -4,12 +4,7 @@
4
4
  * Deterministic evaluation of postconditions after each opcode.
5
5
  * No LLM calls — purely structural checks against AKTree, URL, and screenshots.
6
6
  */
7
- import { serializeAKTree } from './ak-tree.js';
8
- /**
9
- * Evaluates whether a postcondition holds.
10
- * Retries internally up to postcondition.waitMs (polling).
11
- * Returns true if the condition is satisfied, false otherwise.
12
- */
7
+ import { runWithProgressBudget } from './wait-contract.js';
13
8
  export async function evaluatePostcondition(adapter, spec) {
14
9
  const maxWait = spec.waitMs ?? 5000;
15
10
  const pollInterval = 500;
@@ -31,6 +26,29 @@ export async function evaluatePostcondition(adapter, spec) {
31
26
  // Final check after timeout
32
27
  return checkOnce(adapter, spec, context);
33
28
  }
29
+ /**
30
+ * Evaluate a postcondition with extend-on-progress (AUT-240, Layer C): the poll
31
+ * gets a generous budget up to the global deadline and the progress watchdog
32
+ * cuts it only when the page is genuinely stuck. Replaces the old clamp-to-
33
+ * remaining-budget that could starve the check to ~1ms after a slow action.
34
+ * Shared by the runner (main path) and the recovery chain (retry re-check).
35
+ */
36
+ export async function evaluatePostconditionWithProgress(adapter, spec, startedAtMs, globalDeadlineMs, getProgress) {
37
+ // Immediate specs need no adaptive budget.
38
+ if (spec.type === 'always') {
39
+ return evaluatePostcondition(adapter, spec);
40
+ }
41
+ const compiledWaitMs = spec.waitMs ?? 5000;
42
+ const waited = await runWithProgressBudget((budgetMs) => evaluatePostcondition(adapter, { ...spec, waitMs: Math.max(1, Math.round(budgetMs)) }), { startedAtMs, globalDeadlineMs, minBudgetMs: compiledWaitMs, getProgress });
43
+ if (waited.result)
44
+ return waited.result;
45
+ return {
46
+ passed: false,
47
+ reason: waited.cut === 'stuck'
48
+ ? `not met (page stuck, no progress for ${Math.round(waited.waitedMs)}ms)`
49
+ : 'not met (global wait deadline reached)',
50
+ };
51
+ }
34
52
  async function checkOnce(adapter, spec, context) {
35
53
  switch (spec.type) {
36
54
  case 'route_matches':
@@ -117,16 +135,15 @@ async function checkElementVisible(adapter, selector) {
117
135
  catch {
118
136
  // Fall through to AKTree check
119
137
  }
120
- // Fallback: check AKTree
138
+ // Fallback: a visible node matching the selector in the AKTree.
139
+ // (AUT-240, Layer A: the old `serializeAKTree().includes(selector)` fallback
140
+ // was dropped — a substring match on the serialized tree produced false
141
+ // positives.)
121
142
  try {
122
143
  const tree = await adapter.getAKTree();
123
144
  if (hasVisibleNodeWithSelector(tree, selector)) {
124
145
  return { passed: true, reason: `element "${selector}" is visible in AKTree` };
125
146
  }
126
- const serialized = serializeAKTree(tree);
127
- if (serialized.includes(selector.replace(/[[\]"]/g, ''))) {
128
- return { passed: true, reason: `element pattern "${selector}" found in serialized AKTree` };
129
- }
130
147
  return { passed: false, reason: `element "${selector}" not visible` };
131
148
  }
132
149
  catch {
@@ -147,6 +164,23 @@ async function checkElementAbsent(adapter, selector) {
147
164
  }
148
165
  }
149
166
  async function checkTextContains(adapter, selector, expectedText) {
167
+ const expected = normalizeText(expectedText);
168
+ // Playwright-first (AUT-240, Layer A): read the live DOM text.
169
+ if (adapter.getTextContent) {
170
+ try {
171
+ const live = await adapter.getTextContent(selector);
172
+ if (live !== null && normalizeText(live).includes(expected)) {
173
+ return { passed: true, reason: `element "${selector}" contains "${expectedText}" (Playwright)` };
174
+ }
175
+ // Element found but text didn't match (or selector missed): fall through
176
+ // to the AKTree, which may surface label/value/aria text the raw
177
+ // textContent omits.
178
+ }
179
+ catch {
180
+ // Fall through to AKTree.
181
+ }
182
+ }
183
+ // Fallback: AKTree (label / value / own text).
150
184
  try {
151
185
  const tree = await adapter.getAKTree();
152
186
  const node = findNodeBySelector(tree, selector);
@@ -158,9 +192,8 @@ async function checkTextContains(adapter, selector, expectedText) {
158
192
  node.value || '',
159
193
  node.attributes.__ownText || '',
160
194
  ].join(' '));
161
- const expected = normalizeText(expectedText);
162
195
  if (nodeText.includes(expected)) {
163
- return { passed: true, reason: `element "${selector}" contains "${expectedText}"` };
196
+ return { passed: true, reason: `element "${selector}" contains "${expectedText}" (AKTree)` };
164
197
  }
165
198
  return { passed: false, reason: `element "${selector}" text "${nodeText}" does not contain "${expectedText}"` };
166
199
  }
@@ -168,24 +201,39 @@ async function checkTextContains(adapter, selector, expectedText) {
168
201
  return { passed: false, reason: `error checking text: ${err}` };
169
202
  }
170
203
  }
204
+ function evaluateOverlayTree(tree) {
205
+ if (tree.overlays.length === 0) {
206
+ return { passed: true, reason: 'no overlays detected' };
207
+ }
208
+ const blocking = tree.overlays.filter(o => o.blocksInteraction);
209
+ if (blocking.length === 0) {
210
+ return { passed: true, reason: 'overlays present but none blocking interaction' };
211
+ }
212
+ return { passed: false, reason: `${blocking.length} blocking overlay(s) still present` };
213
+ }
171
214
  async function checkOverlayDismissed(adapter) {
172
215
  try {
173
- const tree = await adapter.getAKTree();
174
- // Check if any overlays are reported in the tree
175
- if (tree.overlays.length === 0) {
176
- return { passed: true, reason: 'no overlays detected' };
177
- }
178
- // Check if remaining overlays are blocking
179
- const blocking = tree.overlays.filter(o => o.blocksInteraction);
180
- if (blocking.length === 0) {
181
- return { passed: true, reason: 'overlays present but none blocking interaction' };
182
- }
183
- return { passed: false, reason: `${blocking.length} blocking overlay(s) still present` };
216
+ return evaluateOverlayTree(await adapter.getAKTree());
184
217
  }
185
218
  catch {
186
- // If AKTree is unavailable (e.g. page.evaluate failure), assume overlays are dismissed.
187
- // The overlay dismissal itself ran; we just can't verify via AKTree.
188
- return { passed: true, reason: 'overlay check skipped (AKTree unavailable), assuming dismissed' };
219
+ // AUT-240 (decision 2): "assume OK, but smart". A first `page.evaluate`
220
+ // hiccup (e.g. navigation in flight) is no longer assumed-OK immediately
221
+ // settle the page and retry the AKTree once.
222
+ try {
223
+ if (adapter.waitForVisuallyStable) {
224
+ await adapter.waitForVisuallyStable({ maxWaitMs: 2000 });
225
+ }
226
+ return evaluateOverlayTree(await adapter.getAKTree());
227
+ }
228
+ catch {
229
+ // Still unverifiable: assume dismissed as a last resort, but flag
230
+ // low-confidence so the post-capture verification scrutinizes it.
231
+ return {
232
+ passed: true,
233
+ reason: 'overlay check unverifiable after settle; assuming dismissed (low-confidence)',
234
+ lowConfidence: true,
235
+ };
236
+ }
189
237
  }
190
238
  }
191
239
  async function checkScreenshotStable(adapter, threshold, context) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Capture Agent — Program content hashing (run provenance)
3
+ *
4
+ * Stable content hash of an ExecutionProgram, persisted as `program_hash` on
5
+ * each run so a screenshot can be traced back to the exact program bytes that
6
+ * produced it. Isolated from program-migrations.ts to keep `node:crypto` out of
7
+ * the schema validation import chain.
8
+ */
9
+ import type { ExecutionProgram } from './execution-types.js';
10
+ /** sha256 of the canonicalized program (stable across runs of the same program). */
11
+ export declare function hashProgram(program: ExecutionProgram): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Capture Agent — Program content hashing (run provenance)
3
+ *
4
+ * Stable content hash of an ExecutionProgram, persisted as `program_hash` on
5
+ * each run so a screenshot can be traced back to the exact program bytes that
6
+ * produced it. Isolated from program-migrations.ts to keep `node:crypto` out of
7
+ * the schema validation import chain.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ /** Deterministic JSON serialization with object keys sorted recursively. */
11
+ function stableStringify(value) {
12
+ if (value === undefined)
13
+ return 'null';
14
+ if (value === null || typeof value !== 'object')
15
+ return JSON.stringify(value);
16
+ if (Array.isArray(value))
17
+ return `[${value.map(stableStringify).join(',')}]`;
18
+ const obj = value;
19
+ const entries = Object.keys(obj)
20
+ .sort()
21
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
22
+ return `{${entries.join(',')}}`;
23
+ }
24
+ /** sha256 of the canonicalized program (stable across runs of the same program). */
25
+ export function hashProgram(program) {
26
+ return createHash('sha256').update(stableStringify(program)).digest('hex');
27
+ }
28
+ //# sourceMappingURL=program-hash.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Capture Agent — Program FORM migrations (migrate-on-read)
3
+ *
4
+ * Old presets are stored at whatever `programSchemaVersion` (FORM) was current
5
+ * when they were authored. `upgradeProgram` runs a chain of pure
6
+ * `migrate_vN→vN+1` functions to bring any stored program up to the current
7
+ * form BEFORE strict schema validation, so the runner only ever sees one shape.
8
+ *
9
+ * Properties of this layer (decisions locked in AUT-242):
10
+ * - Compat forever: the chain is kept indefinitely; no support window.
11
+ * - Migrate-on-read only: programs are NEVER rewritten back to storage. The
12
+ * stored form changes only when the generator recompiles (create/modify).
13
+ * - Pure + idempotent: a program already at the current form is a no-op.
14
+ *
15
+ * This module is intentionally free of Node-only imports so it can be pulled
16
+ * into the schema validation chain on any runtime. Content hashing
17
+ * (`node:crypto`) lives in program-hash.ts.
18
+ */
19
+ /**
20
+ * Reads the FORM version a raw (pre-migration) program was stored at.
21
+ * Absent / non-finite ⇒ 0 (the oldest form). Used to stamp run provenance
22
+ * (`program_schema_version_origin`) before `upgradeProgram` bumps it.
23
+ */
24
+ export declare function readOriginSchemaVersion(raw: unknown): number;
25
+ /**
26
+ * Brings any stored program up to {@link CURRENT_PROGRAM_SCHEMA_VERSION} (form)
27
+ * before strict validation. Pure: clones, never mutates `raw`. Idempotent: a
28
+ * program already at the current form is returned with only its version stamped.
29
+ * Non-object input is returned untouched so the schema raises a clean error.
30
+ */
31
+ export declare function upgradeProgram(raw: unknown): unknown;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Capture Agent — Program FORM migrations (migrate-on-read)
3
+ *
4
+ * Old presets are stored at whatever `programSchemaVersion` (FORM) was current
5
+ * when they were authored. `upgradeProgram` runs a chain of pure
6
+ * `migrate_vN→vN+1` functions to bring any stored program up to the current
7
+ * form BEFORE strict schema validation, so the runner only ever sees one shape.
8
+ *
9
+ * Properties of this layer (decisions locked in AUT-242):
10
+ * - Compat forever: the chain is kept indefinitely; no support window.
11
+ * - Migrate-on-read only: programs are NEVER rewritten back to storage. The
12
+ * stored form changes only when the generator recompiles (create/modify).
13
+ * - Pure + idempotent: a program already at the current form is a no-op.
14
+ *
15
+ * This module is intentionally free of Node-only imports so it can be pulled
16
+ * into the schema validation chain on any runtime. Content hashing
17
+ * (`node:crypto`) lives in program-hash.ts.
18
+ */
19
+ import { CURRENT_PROGRAM_SCHEMA_VERSION } from './engine-version.js';
20
+ /** Canonical video capture/delivery resolution (1920×1080 @1×). */
21
+ const VIDEO_RESOLUTION = { width: 1920, height: 1080 };
22
+ function isRecord(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
24
+ }
25
+ /**
26
+ * Reads the FORM version a raw (pre-migration) program was stored at.
27
+ * Absent / non-finite ⇒ 0 (the oldest form). Used to stamp run provenance
28
+ * (`program_schema_version_origin`) before `upgradeProgram` bumps it.
29
+ */
30
+ export function readOriginSchemaVersion(raw) {
31
+ if (isRecord(raw) &&
32
+ typeof raw.programSchemaVersion === 'number' &&
33
+ Number.isFinite(raw.programSchemaVersion)) {
34
+ return raw.programSchemaVersion;
35
+ }
36
+ return 0;
37
+ }
38
+ /**
39
+ * v0 → v1: fold the legacy video capture-resolution normalization (formerly the
40
+ * runtime `normalizeVideoCaptureProgram` in cli-runner.ts) into the program
41
+ * FORM. Legacy video presets carrying viewport=2560×1440 / DPR=1.3333 /
42
+ * captureResolution=2560×1440 are normalized to 1920×1080 @1×. Non-video
43
+ * programs pass through unchanged. Defensive on malformed shapes — anything it
44
+ * can't normalize is left for the strict schema to reject with a clean error.
45
+ */
46
+ function migrate_0_to_1(prog) {
47
+ if (prog.mediaMode !== 'video')
48
+ return prog;
49
+ const artifactPlan = isRecord(prog.artifactPlan) ? prog.artifactPlan : undefined;
50
+ const format = artifactPlan && isRecord(artifactPlan.format) ? artifactPlan.format : {};
51
+ const variants = Array.isArray(prog.variants)
52
+ ? prog.variants.map((v) => isRecord(v) ? { ...v, viewport: { ...VIDEO_RESOLUTION }, deviceScaleFactor: 1 } : v)
53
+ : prog.variants;
54
+ return {
55
+ ...prog,
56
+ outputScale: 1,
57
+ variants,
58
+ artifactPlan: {
59
+ ...(artifactPlan ?? {}),
60
+ format: {
61
+ ...format,
62
+ captureResolution: { ...VIDEO_RESOLUTION },
63
+ deliveryResolution: { ...VIDEO_RESOLUTION },
64
+ },
65
+ },
66
+ };
67
+ }
68
+ /** Ordered FORM migrations. `MIGRATIONS[n]` upgrades a vN program to vN+1. */
69
+ const MIGRATIONS = {
70
+ 0: migrate_0_to_1,
71
+ };
72
+ /**
73
+ * Brings any stored program up to {@link CURRENT_PROGRAM_SCHEMA_VERSION} (form)
74
+ * before strict validation. Pure: clones, never mutates `raw`. Idempotent: a
75
+ * program already at the current form is returned with only its version stamped.
76
+ * Non-object input is returned untouched so the schema raises a clean error.
77
+ */
78
+ export function upgradeProgram(raw) {
79
+ if (!isRecord(raw))
80
+ return raw;
81
+ let prog = { ...raw };
82
+ let v = readOriginSchemaVersion(raw);
83
+ while (v < CURRENT_PROGRAM_SCHEMA_VERSION) {
84
+ const migrate = MIGRATIONS[v];
85
+ if (!migrate)
86
+ break; // no path forward; let the schema reject a wrong shape
87
+ prog = migrate(prog);
88
+ v += 1;
89
+ }
90
+ prog.programSchemaVersion = CURRENT_PROGRAM_SCHEMA_VERSION;
91
+ return prog;
92
+ }
93
+ //# sourceMappingURL=program-migrations.js.map
@@ -13,6 +13,13 @@ export interface SignedExecutionProgramEnvelope {
13
13
  signature: string;
14
14
  meta?: {
15
15
  stale?: boolean;
16
+ /**
17
+ * FORM version the stored program was at BEFORE the server migrated it
18
+ * (0 = legacy / no version field). Debug-only run provenance; lives in the
19
+ * UNSIGNED meta because it carries no security weight — the server reads it
20
+ * from the raw preset config before `extractExecutionProgram` normalizes.
21
+ */
22
+ programSchemaVersionOrigin?: number;
16
23
  };
17
24
  }
18
25
  export declare const ProgramSecurityMetadataSchema: z.ZodObject<{
@@ -31,6 +38,8 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
31
38
  program: z.ZodObject<{
32
39
  presetId: z.ZodString;
33
40
  programVersion: z.ZodNumber;
41
+ programSchemaVersion: z.ZodOptional<z.ZodNumber>;
42
+ engineVersion: z.ZodOptional<z.ZodNumber>;
34
43
  mediaMode: z.ZodEnum<{
35
44
  video: "video";
36
45
  clip: "clip";
@@ -141,6 +150,7 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
141
150
  domain: z.ZodString;
142
151
  path: z.ZodOptional<z.ZodString>;
143
152
  }, z.core.$strict>>>;
153
+ scenario: z.ZodOptional<z.ZodString>;
144
154
  }, z.core.$strict>;
145
155
  steps: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
146
156
  url: z.ZodString;
@@ -1133,6 +1143,7 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
1133
1143
  signature: z.ZodString;
1134
1144
  meta: z.ZodOptional<z.ZodObject<{
1135
1145
  stale: z.ZodOptional<z.ZodBoolean>;
1146
+ programSchemaVersionOrigin: z.ZodOptional<z.ZodNumber>;
1136
1147
  }, z.core.$strict>>;
1137
1148
  }, z.core.$strict>;
1138
1149
  export declare function normalizeAllowedOrigins(origins: Iterable<string>): string[];
@@ -30,6 +30,7 @@ export const SignedExecutionProgramEnvelopeSchema = z.object({
30
30
  signature: z.string().regex(/^[a-f0-9]{64}$/i),
31
31
  meta: z.object({
32
32
  stale: z.boolean().optional(),
33
+ programSchemaVersionOrigin: z.number().int().nonnegative().optional(),
33
34
  }).strict().optional(),
34
35
  }).strict();
35
36
  function stableNormalize(value) {
@@ -9,7 +9,7 @@
9
9
  * 5. LLM Healer (last resort)
10
10
  */
11
11
  import { resolveSelector } from './selector-resolver.js';
12
- import { evaluatePostcondition } from './postcondition.js';
12
+ import { evaluatePostcondition, evaluatePostconditionWithProgress } from './postcondition.js';
13
13
  import { LLMHealer } from './llm-healer.js';
14
14
  import { serializeAKTree } from './ak-tree.js';
15
15
  import { executeOpcodeCoreAction } from './opcode-actions.js';
@@ -35,7 +35,7 @@ export class RecoveryChainImpl {
35
35
  const retryBudget = Math.max(0, Math.min(recovery.retries, options.maxDeterministicRetries ?? recovery.retries));
36
36
  if (retryBudget > 0) {
37
37
  logger.debug(`[recovery ${opcodeIndex}] strategy 1 (retry x${retryBudget})`);
38
- const result = await retryOpcode(failedOpcode, adapter, retryBudget, options.remainingTimeMs, options.currentVariant, this.credentials, options.suppressPageReloads);
38
+ const result = await retryOpcode(failedOpcode, adapter, retryBudget, options.remainingTimeMs, options.currentVariant, this.credentials, options.suppressPageReloads, options.globalDeadlineMs, options.getProgress);
39
39
  logger.debug(`[recovery ${opcodeIndex}] strategy 1 → recovered=${result.recovered}, reason=${result.reason}`);
40
40
  if (result.recovered)
41
41
  return result;
@@ -86,7 +86,7 @@ export class RecoveryChainImpl {
86
86
  }
87
87
  }
88
88
  // ── Strategy 1: Deterministic retry ─────────────────────────────────
89
- async function retryOpcode(opcode, adapter, maxRetries, remainingTimeMs, currentVariant, credentials, suppressPageReloads = false) {
89
+ async function retryOpcode(opcode, adapter, maxRetries, remainingTimeMs, currentVariant, credentials, suppressPageReloads = false, globalDeadlineMs, getProgress) {
90
90
  const deadlineMs = typeof remainingTimeMs === 'number'
91
91
  ? Date.now() + remainingTimeMs
92
92
  : Number.POSITIVE_INFINITY;
@@ -97,7 +97,11 @@ async function retryOpcode(opcode, adapter, maxRetries, remainingTimeMs, current
97
97
  await sleep(500 * (i + 1)); // backoff
98
98
  try {
99
99
  await executeRawAction(opcode, adapter, currentVariant, credentials, suppressPageReloads);
100
- const postcondition = await evaluatePostcondition(adapter, clampPostconditionWait(opcode.postcondition, deadlineMs));
100
+ // AUT-240 (Phase 5): the re-check extends-on-progress up to the global
101
+ // deadline instead of replaying a fixed clamped budget. Without the global
102
+ // deadline (legacy callers), `getProgress` is dropped so it degrades to the
103
+ // compiled postcondition budget.
104
+ const postcondition = await evaluatePostconditionWithProgress(adapter, opcode.postcondition, Date.now(), globalDeadlineMs ?? deadlineMs, globalDeadlineMs !== undefined ? getProgress : undefined);
101
105
  if (postcondition.passed) {
102
106
  return { recovered: true, strategy: 'retry', reason: `retry ${i + 1}/${maxRetries} succeeded` };
103
107
  }
@@ -363,11 +367,4 @@ async function resolveSelectorCoordinates(adapter, selector) {
363
367
  function sleep(ms) {
364
368
  return new Promise(resolve => setTimeout(resolve, ms));
365
369
  }
366
- function clampPostconditionWait(spec, deadlineMs) {
367
- const remainingTimeMs = Math.max(1, deadlineMs - Date.now());
368
- return {
369
- ...spec,
370
- waitMs: Math.max(1, Math.min(spec.waitMs ?? remainingTimeMs, remainingTimeMs)),
371
- };
372
- }
373
370
  //# sourceMappingURL=recovery-chain.js.map
@@ -0,0 +1,36 @@
1
+ import type { PreconditionSpec } from './execution-types.js';
2
+ /**
3
+ * AUT-239 — signing side of the AutoKap Scenario cookie.
4
+ *
5
+ * The runner (this CLI) signs; the client app's `@autokap/scenario` package
6
+ * verifies. At runtime these live in two separate processes/deployments, so the
7
+ * only thing they share is the WIRE FORMAT, not a module. We therefore keep a
8
+ * tiny local signer here rather than taking a runtime dependency on the
9
+ * published `@autokap/scenario` package. `scenario-cookie.test.ts` locks the
10
+ * format against the real resolver so the two can never drift.
11
+ *
12
+ * Format: `<id>.<base64url(HMAC-SHA256(secret, id))>`.
13
+ */
14
+ export declare const SCENARIO_COOKIE_NAME = "__ak_scenario";
15
+ export declare function signScenarioCookie(id: string, secret: string): string;
16
+ /** A cookie ready for Playwright's `context.addCookies`. */
17
+ export interface PreconditionCookie {
18
+ name: string;
19
+ value: string;
20
+ domain: string;
21
+ path?: string;
22
+ secure: boolean;
23
+ }
24
+ /**
25
+ * Build the cookie set injected before navigation: the preset's seed cookies
26
+ * plus, when configured, the signed scenario switch.
27
+ *
28
+ * `secure` is derived from the target protocol — a Secure cookie is never sent
29
+ * over http://localhost, so the runner must NOT default it to true (that would
30
+ * silently drop seed/scenario cookies in local captures and fall back to real
31
+ * data). Returns a `warning` when a scenario is requested but unsignable.
32
+ */
33
+ export declare function buildPreconditionCookies(preconditions: Pick<PreconditionSpec, 'cookies' | 'scenario'>, baseUrl: string, secret: string | undefined): {
34
+ cookies: PreconditionCookie[];
35
+ warning?: string;
36
+ };
@@ -0,0 +1,62 @@
1
+ import { createHmac } from 'node:crypto';
2
+ /**
3
+ * AUT-239 — signing side of the AutoKap Scenario cookie.
4
+ *
5
+ * The runner (this CLI) signs; the client app's `@autokap/scenario` package
6
+ * verifies. At runtime these live in two separate processes/deployments, so the
7
+ * only thing they share is the WIRE FORMAT, not a module. We therefore keep a
8
+ * tiny local signer here rather than taking a runtime dependency on the
9
+ * published `@autokap/scenario` package. `scenario-cookie.test.ts` locks the
10
+ * format against the real resolver so the two can never drift.
11
+ *
12
+ * Format: `<id>.<base64url(HMAC-SHA256(secret, id))>`.
13
+ */
14
+ export const SCENARIO_COOKIE_NAME = '__ak_scenario';
15
+ export function signScenarioCookie(id, secret) {
16
+ const sig = createHmac('sha256', secret).update(id).digest('base64url');
17
+ return `${id}.${sig}`;
18
+ }
19
+ /**
20
+ * Build the cookie set injected before navigation: the preset's seed cookies
21
+ * plus, when configured, the signed scenario switch.
22
+ *
23
+ * `secure` is derived from the target protocol — a Secure cookie is never sent
24
+ * over http://localhost, so the runner must NOT default it to true (that would
25
+ * silently drop seed/scenario cookies in local captures and fall back to real
26
+ * data). Returns a `warning` when a scenario is requested but unsignable.
27
+ */
28
+ export function buildPreconditionCookies(preconditions, baseUrl, secret) {
29
+ let secure = true;
30
+ let hostname = '';
31
+ try {
32
+ const url = new URL(baseUrl);
33
+ secure = url.protocol === 'https:';
34
+ hostname = url.hostname;
35
+ }
36
+ catch {
37
+ /* baseUrl is validated upstream; fall back to Secure on parse failure */
38
+ }
39
+ const cookies = (preconditions.cookies ?? []).map((c) => ({
40
+ ...c,
41
+ secure,
42
+ }));
43
+ let warning;
44
+ if (preconditions.scenario) {
45
+ if (!secret) {
46
+ warning =
47
+ `preconditions.scenario="${preconditions.scenario}" set but AUTOKAP_SCENARIO_SECRET ` +
48
+ `is missing — scenario cookie NOT injected; capture will use real data`;
49
+ }
50
+ else {
51
+ cookies.push({
52
+ name: SCENARIO_COOKIE_NAME,
53
+ value: signScenarioCookie(preconditions.scenario, secret),
54
+ domain: hostname,
55
+ path: '/',
56
+ secure,
57
+ });
58
+ }
59
+ }
60
+ return { cookies, warning };
61
+ }
62
+ //# sourceMappingURL=scenario-cookie.js.map
@@ -16,6 +16,27 @@ export interface SecurityDecision {
16
16
  reason?: string;
17
17
  target?: InteractiveElement | null;
18
18
  }
19
+ /**
20
+ * Is `candidateUrl` part of the same first-party site as `scopeUrl`? Shares the
21
+ * navigation site-scope model (`isWithinProjectScope`): exact-host for IPs /
22
+ * localhost / shared-hosting suffixes (so sibling `*.vercel.app` previews are
23
+ * NOT same-site), sub-domain family match for real registrable domains.
24
+ *
25
+ * Used to scope the adaptive-wait progress signal (AUT-240) to the app's own
26
+ * traffic, so third-party telemetry (PostHog beacons, analytics/ad pixels,
27
+ * Sentry, …) no longer reads as "the page is making progress" and the stuck
28
+ * watchdog can still cut a wait whose condition will never be met.
29
+ *
30
+ * Fail-OPEN by design: a false "first-party" only makes the watchdog slightly
31
+ * more patient (still bounded by the per-media cap), whereas a false "foreign"
32
+ * could suppress a real progress signal and cut a legitimately-slow page early.
33
+ * So when in doubt we count it as first-party:
34
+ * - unparseable / non-http(s) `scopeUrl` (e.g. `about:blank` before the first
35
+ * navigation commits) ⇒ true (don't filter);
36
+ * - non-http(s) `candidateUrl` (`data:` / `blob:` / `about:`) ⇒ true (in-page
37
+ * resource).
38
+ */
39
+ export declare function isFirstPartyUrl(scopeUrl: string | null | undefined, candidateUrl: string): boolean;
19
40
  export declare function evaluateResolvedActionSecurity(action: ActionType, args: Record<string, unknown>, context: SecurityContext, target: InteractiveElement | null): SecurityDecision;
20
41
  export declare function evaluateActionSecurity(action: ActionType, args: Record<string, unknown>, context: SecurityContext): SecurityDecision;
21
42
  export declare function describeSecurityTarget(target: InteractiveElement | null | undefined): string;
package/dist/security.js CHANGED
@@ -109,6 +109,16 @@ function isSharedHostingHostname(hostname) {
109
109
  function normalizeScopeHostname(hostname) {
110
110
  return hostname.startsWith('www.') ? hostname.slice(4) : hostname;
111
111
  }
112
+ function scopeFromParsedUrl(parsed) {
113
+ const hostname = parsed.hostname.toLowerCase();
114
+ const exactHost = isLocalHostname(hostname) || isIpv4Hostname(hostname) || isIpv6Hostname(hostname) || isSharedHostingHostname(hostname);
115
+ const scopeHostname = exactHost ? hostname : normalizeScopeHostname(hostname);
116
+ return {
117
+ hostname: scopeHostname,
118
+ port: effectivePort(parsed),
119
+ exactHost,
120
+ };
121
+ }
112
122
  function buildNavigationScopes(context) {
113
123
  const sources = [
114
124
  context.rootUrl,
@@ -119,18 +129,46 @@ function buildNavigationScopes(context) {
119
129
  const parsed = parseHttpUrl(value);
120
130
  if (!parsed)
121
131
  continue;
122
- const hostname = parsed.hostname.toLowerCase();
123
- const exactHost = isLocalHostname(hostname) || isIpv4Hostname(hostname) || isIpv6Hostname(hostname) || isSharedHostingHostname(hostname);
124
- const scopeHostname = exactHost ? hostname : normalizeScopeHostname(hostname);
125
- const scope = {
126
- hostname: scopeHostname,
127
- port: effectivePort(parsed),
128
- exactHost,
129
- };
132
+ const scope = scopeFromParsedUrl(parsed);
130
133
  dedup.set(`${scope.hostname}:${scope.port}:${scope.exactHost ? 'exact' : 'family'}`, scope);
131
134
  }
132
135
  return Array.from(dedup.values());
133
136
  }
137
+ /**
138
+ * Is `candidateUrl` part of the same first-party site as `scopeUrl`? Shares the
139
+ * navigation site-scope model (`isWithinProjectScope`): exact-host for IPs /
140
+ * localhost / shared-hosting suffixes (so sibling `*.vercel.app` previews are
141
+ * NOT same-site), sub-domain family match for real registrable domains.
142
+ *
143
+ * Used to scope the adaptive-wait progress signal (AUT-240) to the app's own
144
+ * traffic, so third-party telemetry (PostHog beacons, analytics/ad pixels,
145
+ * Sentry, …) no longer reads as "the page is making progress" and the stuck
146
+ * watchdog can still cut a wait whose condition will never be met.
147
+ *
148
+ * Fail-OPEN by design: a false "first-party" only makes the watchdog slightly
149
+ * more patient (still bounded by the per-media cap), whereas a false "foreign"
150
+ * could suppress a real progress signal and cut a legitimately-slow page early.
151
+ * So when in doubt we count it as first-party:
152
+ * - unparseable / non-http(s) `scopeUrl` (e.g. `about:blank` before the first
153
+ * navigation commits) ⇒ true (don't filter);
154
+ * - non-http(s) `candidateUrl` (`data:` / `blob:` / `about:`) ⇒ true (in-page
155
+ * resource).
156
+ */
157
+ export function isFirstPartyUrl(scopeUrl, candidateUrl) {
158
+ const scopeParsed = parseHttpUrl(scopeUrl ?? undefined);
159
+ if (!scopeParsed)
160
+ return true;
161
+ let candidate;
162
+ try {
163
+ candidate = new URL(candidateUrl);
164
+ }
165
+ catch {
166
+ return true;
167
+ }
168
+ if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:')
169
+ return true;
170
+ return isWithinProjectScope(candidate, [scopeFromParsedUrl(scopeParsed)]);
171
+ }
134
172
  function isWithinProjectScope(candidate, scopes) {
135
173
  const hostname = candidate.hostname.toLowerCase();
136
174
  const port = effectivePort(candidate);
package/dist/version.d.ts CHANGED
@@ -1 +1,2 @@
1
+ /** npm package version of the CLI, transmitted as the `x-autokap-cli-version` header. */
1
2
  export declare const APP_VERSION: string;