devharness 0.9.16 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/build/helpers/env-file.d.ts +35 -0
  2. package/build/helpers/env-file.d.ts.map +1 -0
  3. package/build/helpers/env-file.js +53 -0
  4. package/build/helpers/env-file.js.map +1 -0
  5. package/build/tools/interpolation.d.ts +20 -3
  6. package/build/tools/interpolation.d.ts.map +1 -1
  7. package/build/tools/interpolation.js +52 -14
  8. package/build/tools/interpolation.js.map +1 -1
  9. package/build/tools/replay-env-file.test.d.ts +2 -0
  10. package/build/tools/replay-env-file.test.d.ts.map +1 -0
  11. package/build/tools/replay-env-file.test.js +137 -0
  12. package/build/tools/replay-env-file.test.js.map +1 -0
  13. package/build/tools/replay-env-token.test.d.ts +2 -0
  14. package/build/tools/replay-env-token.test.d.ts.map +1 -0
  15. package/build/tools/replay-env-token.test.js +113 -0
  16. package/build/tools/replay-env-token.test.js.map +1 -0
  17. package/build/tools/replay-executor.d.ts +18 -0
  18. package/build/tools/replay-executor.d.ts.map +1 -1
  19. package/build/tools/replay-executor.js +10 -6
  20. package/build/tools/replay-executor.js.map +1 -1
  21. package/build/tools/replay-nested-variables.test.d.ts +2 -0
  22. package/build/tools/replay-nested-variables.test.d.ts.map +1 -0
  23. package/build/tools/replay-nested-variables.test.js +139 -0
  24. package/build/tools/replay-nested-variables.test.js.map +1 -0
  25. package/build/tools/replay-tools.d.ts +4 -0
  26. package/build/tools/replay-tools.d.ts.map +1 -1
  27. package/build/tools/replay-tools.js +186 -7
  28. package/build/tools/replay-tools.js.map +1 -1
  29. package/build/tools/replay-typed-text-variables.test.d.ts +2 -0
  30. package/build/tools/replay-typed-text-variables.test.d.ts.map +1 -0
  31. package/build/tools/replay-typed-text-variables.test.js +119 -0
  32. package/build/tools/replay-typed-text-variables.test.js.map +1 -0
  33. package/build/tools/replay-variable-key-validation.test.d.ts +2 -0
  34. package/build/tools/replay-variable-key-validation.test.d.ts.map +1 -0
  35. package/build/tools/replay-variable-key-validation.test.js +124 -0
  36. package/build/tools/replay-variable-key-validation.test.js.map +1 -0
  37. package/docs/instructions.md +3 -0
  38. package/docs/replay.md +82 -2
  39. package/package.json +1 -1
  40. package/plugin/skills/devharness/SKILL.md +2 -2
  41. package/plugin/skills/devharness/references/sequences.md +33 -3
@@ -38,6 +38,7 @@ getKnownToolNames?: () => string[]): {
38
38
  description: z.ZodOptional<z.ZodString>;
39
39
  expectedOutcome: z.ZodOptional<z.ZodString>;
40
40
  startUrl: z.ZodOptional<z.ZodString>;
41
+ envFile: z.ZodOptional<z.ZodString>;
41
42
  baseUrl: z.ZodOptional<z.ZodString>;
42
43
  indices: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
43
44
  lines: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
@@ -133,6 +134,7 @@ getKnownToolNames?: () => string[]): {
133
134
  preferCoordinates?: boolean | undefined;
134
135
  preferSelectors?: boolean | undefined;
135
136
  stepCount?: number | undefined;
137
+ envFile?: string | undefined;
136
138
  indices?: number[] | undefined;
137
139
  sequenceId?: string | undefined;
138
140
  runId?: string | undefined;
@@ -191,6 +193,7 @@ getKnownToolNames?: () => string[]): {
191
193
  preferCoordinates?: boolean | undefined;
192
194
  preferSelectors?: boolean | undefined;
193
195
  stepCount?: number | undefined;
196
+ envFile?: string | undefined;
194
197
  indices?: number[] | undefined;
195
198
  sequenceId?: string | undefined;
196
199
  runId?: string | undefined;
@@ -256,6 +259,7 @@ getKnownToolNames?: () => string[]): {
256
259
  preferCoordinates?: boolean | undefined;
257
260
  preferSelectors?: boolean | undefined;
258
261
  stepCount?: number | undefined;
262
+ envFile?: string | undefined;
259
263
  indices?: number[] | undefined;
260
264
  sequenceId?: string | undefined;
261
265
  runId?: string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"replay-tools.d.ts","sourceRoot":"","sources":["../../src/tools/replay-tools.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,eAAe,EAAyD,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAgFnD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,EACnC,cAAc,EAAE,MAAM,EAAE,GACvB,eAAe,EAAE,CAWnB;AA4wGD,wBAAgB,iBAAiB,CAC/B,eAAe,EAAE,eAAe,EAChC,eAAe,EAAE,eAAe,EAChC,oBAAoB,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,EACjE,iBAAiB,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;AACxE;;;;;GAKG;AACH,iBAAiB,CAAC,EAAE,MAAM,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwDnC"}
1
+ {"version":3,"file":"replay-tools.d.ts","sourceRoot":"","sources":["../../src/tools/replay-tools.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,eAAe,EAAyD,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAgFnD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,EACnC,cAAc,EAAE,MAAM,EAAE,GACvB,eAAe,EAAE,CAWnB;AA88GD,wBAAgB,iBAAiB,CAC/B,eAAe,EAAE,eAAe,EAChC,eAAe,EAAE,eAAe,EAChC,oBAAoB,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,EACjE,iBAAiB,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;AACxE;;;;;GAKG;AACH,iBAAiB,CAAC,EAAE,MAAM,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwDnC"}
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import { z } from 'zod';
5
5
  import { selectSuiteFiles, sequenceFolders } from '../helpers/sequence-tree.js';
6
+ import { promises as fs } from 'fs';
7
+ import { join } from 'path';
6
8
  import { createTool } from '../validation-helpers.js';
7
9
  import { createSuccessResponse, createErrorResponse } from '../messages.js';
8
10
  import { showReplayOverlay } from '../interaction-recorder.js';
@@ -117,6 +119,10 @@ import { readHistoryLines, getHistoryFilePath } from '../debug-logger.js';
117
119
  import { startRecording, eventsToCommands, generateCondensedTimeline, isCommentEvent, } from '../interaction-recorder.js';
118
120
  import { addIssue, initializeTracker, saveIssueSequence, } from '../issue-tracker.js';
119
121
  import { configManager } from '../config.js';
122
+ import { hasTemplateToken } from './interpolation.js';
123
+ import { parseEnvFile } from '../helpers/env-file.js';
124
+ import { getProjectDir } from '../helpers/paths.js';
125
+ import { isAbsolute } from 'path';
120
126
  // =============================================================================
121
127
  // Schema Definition
122
128
  // =============================================================================
@@ -133,6 +139,7 @@ const replaySchema = z.object({
133
139
  description: z.string().optional(),
134
140
  expectedOutcome: z.string().optional(),
135
141
  startUrl: z.string().optional().describe('create: sequence start URL. run: replace the stored startUrl for this run only (e.g. a freshly minted link)'),
142
+ envFile: z.string().optional().describe("run/runAll: a KEY=value file supplying the {{env:NAME}} tokens this run resolves. A relative path resolves against the project directory (the one holding .devharness); absolute is used as-is. Its values WIN over the server's own environment, and the server's environment is never modified - two runs may name different files. A missing file, or a line that is neither blank, a # comment, nor NAME=value, fails before anything runs. Keeps a credential out of the sequence file and out of this call, and changing it needs no client restart"),
136
143
  baseUrl: z.string().optional().describe('run/runAll: retarget at another deployment — every absolute URL (startUrl, command params, a declared connection\u2019s launch url) keeps its path/query but takes this origin, in the sequence itself and in every sequence it reaches through a conditional or forEach. On runAll it applies to every sequence in the suite. Not preserved across a mid-run pause/step resume'),
137
144
  indices: z.array(z.number()).optional().describe('Command indices'),
138
145
  lines: z.array(z.number()).optional().describe('Log line numbers'),
@@ -155,7 +162,7 @@ const replaySchema = z.object({
155
162
  requiredSockets: z.array(z.string()).optional().describe("declare: URL substrings of the WebSockets this sequence's assertions ride on, e.g. ['/api/sync/socket']. Match the app's own path, not the origin, so it survives baseUrl. Replaces the whole list; [] clears it"),
156
163
  connections: z.record(z.string()).optional().describe("run: rebind a multi-connection sequence's recorded references onto this session - { \"<recorded reference>\": \"<reference here>\" }. Only needed when steps carry their own connectionReason (replay({action:'get', outputFormat:'commands'}) shows which)"),
157
164
  record: z.boolean().optional(),
158
- variables: z.record(z.string()).optional(),
165
+ variables: z.record(z.string()).optional().describe("run/runAll: replace the text of recorded input-type steps. Keys are BUILT from the selector - var_<0-based step index>_<selector, non-alphanumerics replaced by _> - so '#password' at step 3 is 'var_3__password', two underscores; read them off `get` or off the prompt a run returns rather than composing them. A key naming no typed-text step is rejected (runAll checks against the whole suite's union). Reaches sequences a conditional or forEach nests into. For a credential prefer {{env:NAME}} in the step itself, which keeps the value out of the sequence file and out of this call; an explicit value here still wins over the environment"),
159
166
  stepTimeout: z.number().optional().describe('Per-step ms (default 30000). A step exceeding min(stepTimeout, remaining totalTimeout) fails the run at that step. wait steps are exempt (own timeoutMs) but still capped by totalTimeout'),
160
167
  totalTimeout: z.number().optional().describe('Total ms'),
161
168
  startFrom: z.number().optional().describe('Start step (1-indexed)'),
@@ -765,6 +772,92 @@ function collectNestedRebindableReferences(commands, recorder, depth = 0, seen =
765
772
  }
766
773
  return { references, complete };
767
774
  }
775
+ /**
776
+ * Read the run's `envFile` into the values {{env:NAME}} resolves against.
777
+ *
778
+ * Loaded here, before any side effects, so a missing file or a malformed line
779
+ * fails the run as a parameter error rather than as a step failure halfway
780
+ * through a flow that has already logged in. The values stay on the run's
781
+ * context: writing them into process.env would leak one run's credentials into
782
+ * every concurrent background run, which name their own files.
783
+ */
784
+ async function loadRunEnv(envFile) {
785
+ const path = isAbsolute(envFile) ? envFile : join(getProjectDir(), envFile);
786
+ let text;
787
+ try {
788
+ text = await fs.readFile(path, 'utf-8');
789
+ }
790
+ catch (err) {
791
+ return { error: `Could not read envFile "${envFile}" (resolved to ${path}): ${err?.code === 'ENOENT' ? 'no such file' : err?.message || String(err)}` };
792
+ }
793
+ const { values, problems } = parseEnvFile(text);
794
+ if (problems.length > 0) {
795
+ const shown = problems.slice(0, 3).map(p => `line ${p.line}: ${p.text}`).join('; ');
796
+ return { error: `envFile "${envFile}" (resolved to ${path}) has ${problems.length} line(s) that are neither blank, a # comment, nor NAME=value with a name matching [A-Za-z_][A-Za-z0-9_]* - ${shown}${problems.length > 3 ? ', ...' : ''}. A skipped line reads as a set variable, so the run stops here.` };
797
+ }
798
+ return { values };
799
+ }
800
+ /**
801
+ * Every `variables` key a run could substitute on: this sequence's typed-text
802
+ * steps plus those of every sequence it reaches through a `conditional`'s
803
+ * `then` or a `forEach`'s `do`.
804
+ *
805
+ * Resolution is memory-only and best-effort, the same rule
806
+ * collectNestedRebindableReferences follows: a helper that lives on disk and
807
+ * has not been loaded is not loaded here (that would register it as a side
808
+ * effect of validation), so `complete: false` says "this list may be short"
809
+ * and the caller must not call a missing key a typo. `runAll` loads the whole
810
+ * tree before it runs anything, so a suite run always gets a complete list.
811
+ */
812
+ function collectVariableKeys(commands, recorder, depth = 0, seen = new Set()) {
813
+ const keys = new Set(Object.keys(extractTextVariables(commands)));
814
+ // Must track the executor's own cap: with a raised maxConditionalDepth, keys
815
+ // at runtime-reachable depths would be omitted while `complete` still claimed
816
+ // the list was exhaustive, and a valid key would be rejected as a typo.
817
+ if (depth >= configManager.getReplayConfig().maxConditionalDepth) {
818
+ return { keys, complete: false };
819
+ }
820
+ let complete = true;
821
+ for (const cmd of commands) {
822
+ const named = cmd.tool === 'conditional' ? cmd.params?.then
823
+ : cmd.tool === 'forEach' ? cmd.params?.do
824
+ : undefined;
825
+ if (typeof named !== 'string' || !named || seen.has(named))
826
+ continue;
827
+ seen.add(named);
828
+ const nested = recorder.listSequences().find(sq => sq.name === named);
829
+ if (!nested) {
830
+ complete = false;
831
+ continue;
832
+ }
833
+ const deeper = collectVariableKeys(nested.commands, recorder, depth + 1, seen);
834
+ for (const key of deeper.keys)
835
+ keys.add(key);
836
+ complete = complete && deeper.complete;
837
+ }
838
+ return { keys, complete };
839
+ }
840
+ /**
841
+ * Keys the caller supplied that name no typed-text step anywhere the run can
842
+ * reach. Empty when the key list could not be resolved in full, because a key
843
+ * valid for an unloaded helper is not a typo.
844
+ *
845
+ * A `variables` key is matched exactly and nothing else looks at it, so an
846
+ * unmatched key used to be dropped in silence: the step ran on its RECORDED
847
+ * text while the call read as an override. For a recorded credential that
848
+ * means the old password reaching the live app with the run reporting success.
849
+ * Same rule the `connections` rebinding already applies to a reference that
850
+ * names no recorded step.
851
+ */
852
+ function unmatchedVariableKeys(supplied, commands, recorder) {
853
+ const { keys, complete } = collectVariableKeys(commands, recorder);
854
+ if (!complete)
855
+ return { unmatched: [], known: [...keys].sort() };
856
+ return {
857
+ unmatched: Object.keys(supplied).filter(k => !keys.has(k)),
858
+ known: [...keys].sort(),
859
+ };
860
+ }
768
861
  /**
769
862
  * Run every sequence in a folder, in filename order, and report one line each.
770
863
  *
@@ -838,6 +931,35 @@ async function handleRunAll(args, recorder, executeToolCall, getPageForConnectio
838
931
  (folders.length ? `Available folders: ${folders.join(', ')}.` : 'No subfolders exist yet — sequences are all at the top level.')
839
932
  });
840
933
  }
934
+ // One `variables` map covers the whole suite, so a key is a typo only when it
935
+ // matches NO member. Checked once, before anything runs: a supplied key that
936
+ // lands nowhere is dropped in silence by the executor and every sequence runs
937
+ // on its recorded text, which for a credential means the recorded one reaching
938
+ // the live app with the suite reporting green. The whole tree is in memory by
939
+ // now, so the union is exhaustive.
940
+ if (args.variables && Object.keys(args.variables).length > 0) {
941
+ const suiteKeys = new Set();
942
+ for (const entry of selected) {
943
+ const seq = recorder.listSequences().find(sq => sq.name === entry.name);
944
+ if (!seq)
945
+ continue;
946
+ for (const key of collectVariableKeys(seq.commands, recorder).keys)
947
+ suiteKeys.add(key);
948
+ }
949
+ const unmatched = Object.keys(args.variables).filter(k => !suiteKeys.has(k));
950
+ if (unmatched.length > 0) {
951
+ const known = [...suiteKeys].sort();
952
+ return createErrorResponse('INVALID_PARAMETER', {
953
+ parameter: 'variables',
954
+ value: unmatched.join(', '),
955
+ message: `${unmatched.length > 1 ? 'Those keys name' : `"${unmatched[0]}" names`} no typed-text step in any sequence this run selects. ` +
956
+ `The key is BUILT from the selector - var_<0-based step index>_<selector, non-alphanumerics replaced by _> - so "#password" at step 3 is "var_3__password", with two underscores. ` +
957
+ (known.length
958
+ ? `Substitutable across this suite: ${known.join(', ')}.`
959
+ : `No sequence here has typed text to substitute.`),
960
+ });
961
+ }
962
+ }
841
963
  const keepGoing = args.continueOnFailure !== false;
842
964
  const results = [];
843
965
  for (const [index, entry] of selected.entries()) {
@@ -878,7 +1000,7 @@ async function handleRunAll(args, recorder, executeToolCall, getPageForConnectio
878
1000
  stepTo: undefined,
879
1001
  stepCount: undefined,
880
1002
  startUrl: undefined,
881
- }, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
1003
+ }, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort, { validateVariableKeys: false });
882
1004
  const text = (res?.content || []).map((c) => c?.text || '').join('\n');
883
1005
  // performRun stamps _meta.replay on every terminal response, so trust that
884
1006
  // over the prose. Regexing for a line starting with "Error:" both misses
@@ -1281,7 +1403,13 @@ function socketFailures(before, after, required, requiredOn) {
1281
1403
  }
1282
1404
  return { settled: out, absent };
1283
1405
  }
1284
- async function handleRun(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort) {
1406
+ async function handleRun(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort,
1407
+ /**
1408
+ * `validateVariableKeys: false` for a sequence running as part of a suite:
1409
+ * `runAll` validates the one map it holds against the whole suite's keys and
1410
+ * a per-sequence check would reject a key meant for a different member.
1411
+ */
1412
+ opts) {
1285
1413
  // Load sequence
1286
1414
  const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
1287
1415
  if (!loadResult.success) {
@@ -1303,9 +1431,15 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1303
1431
  if (!connectionReason && !analysis.hasLaunchBeforeConnection && needsConnection) {
1304
1432
  connectionReason = deriveConnectionReference(sequence.name);
1305
1433
  }
1306
- // Handle variable extraction and prompting
1434
+ // Handle variable extraction and prompting. A step whose recorded text
1435
+ // carries an interpolation token ({{env:NAME}}, {{var:...}}) is already
1436
+ // parameterised - its value arrives at run time by definition - so it does
1437
+ // not hold the run open for an answer. It stays in the extracted list, so a
1438
+ // caller can still override it and the key validation still accepts it.
1307
1439
  const extractedVariables = extractTextVariables(commands);
1308
- if (Object.keys(extractedVariables).length > 0 && args.variables === undefined) {
1440
+ const needsAnswer = Object.values(extractedVariables)
1441
+ .some(v => !hasTemplateToken(v.value));
1442
+ if (needsAnswer && args.variables === undefined) {
1309
1443
  const idParam = args.sequenceId || args.name;
1310
1444
  // Tag it: this response is a PROMPT, not a run. runAll has to be able to
1311
1445
  // tell "asked you a question" from "executed and passed", or a suite goes
@@ -1315,6 +1449,43 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1315
1449
  _meta: { tool: 'replay', action: 'run', timestamp: Date.now(), replay: { success: false, prompted: true } }
1316
1450
  };
1317
1451
  }
1452
+ // The envFile is read before any side effects: a missing file or a malformed
1453
+ // line is a parameter error, not a step failure halfway through a flow that
1454
+ // has already logged in.
1455
+ let runEnv;
1456
+ if (args.envFile) {
1457
+ const loaded = await loadRunEnv(args.envFile);
1458
+ if ('error' in loaded) {
1459
+ return createErrorResponse('INVALID_PARAMETER', {
1460
+ parameter: 'envFile',
1461
+ value: args.envFile,
1462
+ message: loaded.error,
1463
+ });
1464
+ }
1465
+ runEnv = loaded.values;
1466
+ }
1467
+ // Same rule for the typed-text substitutions, before any side effects. A key
1468
+ // matching no step is dropped in silence by the executor and the step runs on
1469
+ // its RECORDED text, so a mistyped key reads as an override while the recorded
1470
+ // value - a password among them - reaches the live app.
1471
+ //
1472
+ // Skipped for a suite member: `runAll` hands ONE map to every sequence and
1473
+ // checks it against the union of the whole suite, so a key valid for another
1474
+ // sequence must not fail this one.
1475
+ if (args.variables && opts?.validateVariableKeys !== false) {
1476
+ const { unmatched, known } = unmatchedVariableKeys(args.variables, commands, recorder);
1477
+ if (unmatched.length > 0) {
1478
+ return createErrorResponse('INVALID_PARAMETER', {
1479
+ parameter: 'variables',
1480
+ value: unmatched.join(', '),
1481
+ message: `${unmatched.length > 1 ? 'Those keys name' : `"${unmatched[0]}" names`} no typed-text step in "${sequence.name}" or any sequence it reaches. ` +
1482
+ `The key is BUILT from the selector - var_<0-based step index>_<selector, non-alphanumerics replaced by _> - so "#password" at step 3 is "var_3__password", with two underscores. ` +
1483
+ (known.length
1484
+ ? `Substitutable here: ${known.join(', ')}.`
1485
+ : `This sequence has no typed text to substitute.`),
1486
+ });
1487
+ }
1488
+ }
1318
1489
  // Validate the connection rebinding before any side effects. A key that names
1319
1490
  // no recorded reference is a typo the user needs to hear about now: silently
1320
1491
  // ignoring it would leave the step on its recorded reference and, in the worst
@@ -1404,6 +1575,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1404
1575
  sequence, analysis, connectionReason, needsConnection,
1405
1576
  launchedConnections: new Set(),
1406
1577
  ...(connectionMap && { connectionMap }),
1578
+ ...(runEnv && { runEnv }),
1407
1579
  };
1408
1580
  // wait: true - pre-0.7 blocking behaviour, driven by the MCP request signal.
1409
1581
  // Also what nested `replay run` STEPS use (the executor injects wait: true),
@@ -1615,7 +1787,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1615
1787
  * record's terminal status - never derived by parsing the response.
1616
1788
  */
1617
1789
  async function performRun(deps, abortSignal, runId, onProgress) {
1618
- const { args, recorder, executeToolCall, getPageForConnection, getConnectionPort, sequence, analysis, connectionReason, needsConnection, connectionMap, launchedConnections } = deps;
1790
+ const { args, recorder, executeToolCall, getPageForConnection, getConnectionPort, sequence, analysis, connectionReason, needsConnection, connectionMap, launchedConnections, runEnv } = deps;
1619
1791
  // Build execution context
1620
1792
  const ctx = {
1621
1793
  executeToolCall,
@@ -1627,7 +1799,14 @@ async function performRun(deps, abortSignal, runId, onProgress) {
1627
1799
  ...(connectionMap && { connectionMap }),
1628
1800
  // Carried on the context so nested sequences inherit the retarget; the
1629
1801
  // top-level sequence was already rebased in handleRun.
1630
- ...(args.baseUrl && { rebaseOrigin: args.baseUrl })
1802
+ ...(args.baseUrl && { rebaseOrigin: args.baseUrl }),
1803
+ // Same reason: a shared login helper reached by a conditional is exactly
1804
+ // where a supplied credential has to land.
1805
+ ...(args.variables && { variables: args.variables }),
1806
+ // Held on the context rather than written into process.env: concurrent
1807
+ // background runs may name different files, and a global write would let
1808
+ // one run's credentials resolve inside the other.
1809
+ ...(runEnv && { runEnv })
1631
1810
  };
1632
1811
  // Ensure connection is ready
1633
1812
  let didAutoLaunch = false;