devharness 0.9.15 → 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 (52) hide show
  1. package/build/github/issue-body.d.ts +4 -4
  2. package/build/helpers/env-file.d.ts +35 -0
  3. package/build/helpers/env-file.d.ts.map +1 -0
  4. package/build/helpers/env-file.js +53 -0
  5. package/build/helpers/env-file.js.map +1 -0
  6. package/build/tools/interpolation.d.ts +20 -3
  7. package/build/tools/interpolation.d.ts.map +1 -1
  8. package/build/tools/interpolation.js +52 -14
  9. package/build/tools/interpolation.js.map +1 -1
  10. package/build/tools/replay-env-file.test.d.ts +2 -0
  11. package/build/tools/replay-env-file.test.d.ts.map +1 -0
  12. package/build/tools/replay-env-file.test.js +137 -0
  13. package/build/tools/replay-env-file.test.js.map +1 -0
  14. package/build/tools/replay-env-token.test.d.ts +2 -0
  15. package/build/tools/replay-env-token.test.d.ts.map +1 -0
  16. package/build/tools/replay-env-token.test.js +113 -0
  17. package/build/tools/replay-env-token.test.js.map +1 -0
  18. package/build/tools/replay-executor.d.ts +27 -0
  19. package/build/tools/replay-executor.d.ts.map +1 -1
  20. package/build/tools/replay-executor.js +25 -7
  21. package/build/tools/replay-executor.js.map +1 -1
  22. package/build/tools/replay-nested-rebase.test.d.ts +15 -0
  23. package/build/tools/replay-nested-rebase.test.d.ts.map +1 -0
  24. package/build/tools/replay-nested-rebase.test.js +139 -0
  25. package/build/tools/replay-nested-rebase.test.js.map +1 -0
  26. package/build/tools/replay-nested-variables.test.d.ts +2 -0
  27. package/build/tools/replay-nested-variables.test.d.ts.map +1 -0
  28. package/build/tools/replay-nested-variables.test.js +139 -0
  29. package/build/tools/replay-nested-variables.test.js.map +1 -0
  30. package/build/tools/replay-rebase.test.js +25 -0
  31. package/build/tools/replay-rebase.test.js.map +1 -1
  32. package/build/tools/replay-run-all-parity.test.d.ts +2 -0
  33. package/build/tools/replay-run-all-parity.test.d.ts.map +1 -0
  34. package/build/tools/replay-run-all-parity.test.js +90 -0
  35. package/build/tools/replay-run-all-parity.test.js.map +1 -0
  36. package/build/tools/replay-tools.d.ts +25 -21
  37. package/build/tools/replay-tools.d.ts.map +1 -1
  38. package/build/tools/replay-tools.js +202 -14
  39. package/build/tools/replay-tools.js.map +1 -1
  40. package/build/tools/replay-typed-text-variables.test.d.ts +2 -0
  41. package/build/tools/replay-typed-text-variables.test.d.ts.map +1 -0
  42. package/build/tools/replay-typed-text-variables.test.js +119 -0
  43. package/build/tools/replay-typed-text-variables.test.js.map +1 -0
  44. package/build/tools/replay-variable-key-validation.test.d.ts +2 -0
  45. package/build/tools/replay-variable-key-validation.test.d.ts.map +1 -0
  46. package/build/tools/replay-variable-key-validation.test.js +124 -0
  47. package/build/tools/replay-variable-key-validation.test.js.map +1 -0
  48. package/docs/instructions.md +5 -2
  49. package/docs/replay.md +99 -6
  50. package/package.json +1 -1
  51. package/plugin/skills/devharness/SKILL.md +2 -2
  52. package/plugin/skills/devharness/references/sequences.md +33 -3
@@ -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,7 +139,8 @@ 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)'),
136
- baseUrl: z.string().optional().describe('run: retarget the sequence at another deployment every absolute URL (startUrl + command params) keeps its path/query but takes this origin. Not preserved across a mid-run pause/step resume'),
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"),
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'),
139
146
  sequenceId: z.string().optional(),
@@ -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)'),
@@ -184,7 +191,7 @@ const replaySchema = z.object({
184
191
  strict: z.enum(['errors', 'warnings']).optional().describe("run/runAll: fail the run when it PRODUCES console output - 'errors' fails on new console errors, 'warnings' also fails on new warnings. Counted per connection and diffed against the start of the run, so pre-existing noise is not blamed on this sequence. A sequence can be functionally correct and still be logging; strict is how you separate those questions"),
185
192
  folder: z.string().optional().describe("runAll: sequences subfolder to run, relative to the sequences dir (e.g. 'spine'). Omit to run every sequence outside folders whose name starts with '_'. The whole tree is always LOADED first so name references (a conditional's then, a forEach's do) resolve wherever the helper lives"),
186
193
  continueOnFailure: z.boolean().optional().describe('runAll: keep going after a sequence fails and report every result (default true). false stops at the first failure'),
187
- killChromeOnFinish: z.boolean().optional().describe("run: after finishing (skipped on pause/abort), kill the browsers this run owns - its own connection plus any a launchChrome step actually created. A step that reached an already-bound reference only borrowed that browser and it is left running, so an instance you launched yourself survives. Also skipped for any browser whose port another live connection shares (a launchChrome step usually opens a tab in the same instance), and the run reports which connection kept it alive."),
194
+ killChromeOnFinish: z.boolean().optional().describe("run/runAll: after finishing (skipped on pause/abort), kill the browsers this run owns - its own connection plus any a launchChrome step actually created. A step that reached an already-bound reference only borrowed that browser and it is left running, so an instance you launched yourself survives. Also skipped for any browser whose port another live connection shares (a launchChrome step usually opens a tab in the same instance), and the run reports which connection kept it alive. On runAll only the LAST sequence carries it, so a preamble's browser survives between sequences and a suite that stops early leaves the browsers up."),
188
195
  }).strict();
189
196
  async function handleHistory(args, recorder) {
190
197
  const limit = args.limit || 50;
@@ -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,9 +931,44 @@ 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
- for (const entry of selected) {
965
+ for (const [index, entry] of selected.entries()) {
966
+ // killChromeOnFinish means the SUITE's finish here, not each sequence's: a
967
+ // teardown between sequences destroys the state a _helpers preamble just
968
+ // established. Only the last sequence carries it, so a suite that stops
969
+ // early (continueOnFailure: false, a cancel) leaves the browsers up for
970
+ // the failure to be read in.
971
+ const isLast = index === selected.length - 1;
844
972
  if (abortSignal?.aborted) {
845
973
  results.push({ filename: entry.filename, name: entry.name, ok: false, detail: 'cancelled before it ran' });
846
974
  continue;
@@ -863,16 +991,16 @@ async function handleRunAll(args, recorder, executeToolCall, getPageForConnectio
863
991
  // no-op that a caller then has to notice.
864
992
  variables: args.variables ?? {},
865
993
  // Per-run args that are actively wrong when fanned across a suite:
866
- // killChromeOnFinish would tear down the browser between sequences and
867
- // destroy the state a _helpers preamble just established, and
868
994
  // startFrom/stepTo/stepCount/startUrl mean something only for one
869
- // specific sequence.
870
- killChromeOnFinish: undefined,
995
+ // specific sequence. baseUrl does carry - it retargets every
996
+ // sequence at the same deployment, which is what a suite run of a
997
+ // recorded set against another environment needs.
998
+ killChromeOnFinish: isLast ? args.killChromeOnFinish : undefined,
871
999
  startFrom: undefined,
872
1000
  stepTo: undefined,
873
1001
  stepCount: undefined,
874
1002
  startUrl: undefined,
875
- }, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
1003
+ }, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort, { validateVariableKeys: false });
876
1004
  const text = (res?.content || []).map((c) => c?.text || '').join('\n');
877
1005
  // performRun stamps _meta.replay on every terminal response, so trust that
878
1006
  // over the prose. Regexing for a line starting with "Error:" both misses
@@ -1275,7 +1403,13 @@ function socketFailures(before, after, required, requiredOn) {
1275
1403
  }
1276
1404
  return { settled: out, absent };
1277
1405
  }
1278
- 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) {
1279
1413
  // Load sequence
1280
1414
  const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
1281
1415
  if (!loadResult.success) {
@@ -1297,9 +1431,15 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1297
1431
  if (!connectionReason && !analysis.hasLaunchBeforeConnection && needsConnection) {
1298
1432
  connectionReason = deriveConnectionReference(sequence.name);
1299
1433
  }
1300
- // 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.
1301
1439
  const extractedVariables = extractTextVariables(commands);
1302
- 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) {
1303
1443
  const idParam = args.sequenceId || args.name;
1304
1444
  // Tag it: this response is a PROMPT, not a run. runAll has to be able to
1305
1445
  // tell "asked you a question" from "executed and passed", or a suite goes
@@ -1309,6 +1449,43 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1309
1449
  _meta: { tool: 'replay', action: 'run', timestamp: Date.now(), replay: { success: false, prompted: true } }
1310
1450
  };
1311
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
+ }
1312
1489
  // Validate the connection rebinding before any side effects. A key that names
1313
1490
  // no recorded reference is a typo the user needs to hear about now: silently
1314
1491
  // ignoring it would leave the step on its recorded reference and, in the worst
@@ -1398,6 +1575,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1398
1575
  sequence, analysis, connectionReason, needsConnection,
1399
1576
  launchedConnections: new Set(),
1400
1577
  ...(connectionMap && { connectionMap }),
1578
+ ...(runEnv && { runEnv }),
1401
1579
  };
1402
1580
  // wait: true - pre-0.7 blocking behaviour, driven by the MCP request signal.
1403
1581
  // Also what nested `replay run` STEPS use (the executor injects wait: true),
@@ -1609,7 +1787,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
1609
1787
  * record's terminal status - never derived by parsing the response.
1610
1788
  */
1611
1789
  async function performRun(deps, abortSignal, runId, onProgress) {
1612
- 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;
1613
1791
  // Build execution context
1614
1792
  const ctx = {
1615
1793
  executeToolCall,
@@ -1618,7 +1796,17 @@ async function performRun(deps, abortSignal, runId, onProgress) {
1618
1796
  logPrefix: 'run',
1619
1797
  variableStore: {},
1620
1798
  launchedConnections,
1621
- ...(connectionMap && { connectionMap })
1799
+ ...(connectionMap && { connectionMap }),
1800
+ // Carried on the context so nested sequences inherit the retarget; the
1801
+ // top-level sequence was already rebased in handleRun.
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 })
1622
1810
  };
1623
1811
  // Ensure connection is ready
1624
1812
  let didAutoLaunch = false;