specrails-core 6.3.0 → 6.5.0

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 (102) hide show
  1. package/dist/agent-runtime/capabilities.js +22 -5
  2. package/dist/agent-runtime/cli-executor.js +8 -3
  3. package/dist/agent-runtime/cli.d.ts +1 -1
  4. package/dist/agent-runtime/cli.js +50 -3
  5. package/dist/agent-runtime/compact/environment.d.ts +49 -4
  6. package/dist/agent-runtime/compact/environment.js +191 -8
  7. package/dist/agent-runtime/config.js +68 -53
  8. package/dist/agent-runtime/engine/compiler.js +10 -2
  9. package/dist/agent-runtime/engine/contracts.d.ts +16 -0
  10. package/dist/agent-runtime/engine/pieces/control.js +11 -2
  11. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
  12. package/dist/agent-runtime/engine/pieces/role-turn.js +69 -3
  13. package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
  14. package/dist/agent-runtime/engine/pieces/verify.js +105 -30
  15. package/dist/agent-runtime/executor-types.d.ts +2 -0
  16. package/dist/agent-runtime/graph/nodes.js +9 -55
  17. package/dist/agent-runtime/prompts.d.ts +1 -1
  18. package/dist/agent-runtime/prompts.js +18 -12
  19. package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
  20. package/dist/agent-runtime/session/application/active-session.js +701 -0
  21. package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
  22. package/dist/agent-runtime/session/application/delegation.js +171 -0
  23. package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
  24. package/dist/agent-runtime/session/application/output-buffer.js +65 -0
  25. package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
  26. package/dist/agent-runtime/session/application/session-service.js +256 -0
  27. package/dist/agent-runtime/session/application/timers.d.ts +15 -0
  28. package/dist/agent-runtime/session/application/timers.js +35 -0
  29. package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
  30. package/dist/agent-runtime/session/domain/descriptor.js +32 -0
  31. package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
  32. package/dist/agent-runtime/session/domain/errors.js +16 -0
  33. package/dist/agent-runtime/session/domain/events.d.ts +147 -0
  34. package/dist/agent-runtime/session/domain/events.js +24 -0
  35. package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
  36. package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
  37. package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
  38. package/dist/agent-runtime/session/domain/interruption.js +39 -0
  39. package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
  40. package/dist/agent-runtime/session/domain/policy.js +104 -0
  41. package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
  42. package/dist/agent-runtime/session/domain/snapshot.js +234 -0
  43. package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
  44. package/dist/agent-runtime/session/domain/state-machines.js +41 -0
  45. package/dist/agent-runtime/session/domain/types.d.ts +130 -0
  46. package/dist/agent-runtime/session/domain/types.js +15 -0
  47. package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
  48. package/dist/agent-runtime/session/domain/usage.js +86 -0
  49. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
  50. package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
  51. package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
  52. package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
  53. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
  54. package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
  55. package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
  56. package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
  57. package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
  58. package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
  59. package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
  60. package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
  61. package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
  62. package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
  63. package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
  64. package/dist/agent-runtime/session/drivers/common/process.js +116 -0
  65. package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
  66. package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
  67. package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
  68. package/dist/agent-runtime/session/drivers/registry.js +38 -0
  69. package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
  70. package/dist/agent-runtime/session/host/protocol.js +88 -0
  71. package/dist/agent-runtime/session/host/server.d.ts +57 -0
  72. package/dist/agent-runtime/session/host/server.js +229 -0
  73. package/dist/agent-runtime/session/index.d.ts +45 -0
  74. package/dist/agent-runtime/session/index.js +74 -0
  75. package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
  76. package/dist/agent-runtime/session/journal/schema.js +44 -0
  77. package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
  78. package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
  79. package/dist/agent-runtime/session/ports.d.ts +192 -0
  80. package/dist/agent-runtime/session/ports.js +1 -0
  81. package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
  82. package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
  83. package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
  84. package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
  85. package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
  86. package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
  87. package/dist/agent-runtime/session/testing/index.d.ts +9 -0
  88. package/dist/agent-runtime/session/testing/index.js +9 -0
  89. package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
  90. package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
  91. package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
  92. package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
  93. package/dist/agent-runtime/verification-proposals.d.ts +9 -0
  94. package/dist/agent-runtime/verification-proposals.js +69 -0
  95. package/dist/agent-runtime/verification-repair.d.ts +26 -0
  96. package/dist/agent-runtime/verification-repair.js +88 -0
  97. package/dist/installer/util/registry.js +3 -3
  98. package/dist/shared/specrails-home.d.ts +21 -0
  99. package/dist/shared/specrails-home.js +36 -0
  100. package/integration-contract.json +43 -2
  101. package/package.json +11 -1
  102. package/schemas/agent-runtime.schema.json +9 -7
@@ -36,6 +36,16 @@ export async function cliCapabilities(provider, model, options = {}) {
36
36
  return { ...result, effortSupport: 'supported', supportedEfforts: levels };
37
37
  }
38
38
  else if (model && help.stdout.includes('--config')) {
39
+ // Ask the installed client for its own model catalog. Other Codex clients
40
+ // (the desktop app's app-server daemon, IDE extensions) share
41
+ // models_cache.json and rewrite it with their own version, so the cache
42
+ // is only a fallback for clients without `codex debug models`.
43
+ const catalog = await runner({ command: provider, args: ['debug', 'models'] }, { cwd: tmpdir(), env, timeoutMs: 10_000 }).catch(() => null);
44
+ if (catalog?.exitCode === 0 && catalog.stdout.length <= 2 * 1024 * 1024) {
45
+ const levels = codexModelEfforts(JSON.parse(catalog.stdout), model);
46
+ if (levels)
47
+ return { ...result, effortSupport: 'supported', supportedEfforts: levels };
48
+ }
39
49
  // Use the installed client's own bounded, fresh model catalog. Do not
40
50
  // infer reasoning support from a model prefix or another provider.
41
51
  const file = path.join(env.CODEX_HOME ?? path.join(env.HOME ?? homedir(), '.codex'), 'models_cache.json');
@@ -48,16 +58,23 @@ export async function cliCapabilities(provider, model, options = {}) {
48
58
  const version = await runner({ command: provider, args: ['--version'] }, { cwd: tmpdir(), env, timeoutMs: 10_000 });
49
59
  if (version.exitCode !== 0 || version.stdout.trim() !== `codex-cli ${cache.client_version}`)
50
60
  return result;
51
- const entry = Array.isArray(cache.models) ? cache.models.find((item) => item?.slug === model) : undefined;
52
- const levels = entry?.supported_reasoning_levels?.map((item) => item?.effort);
53
- if (Array.isArray(levels) && levels.length && levels.every(value => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(value))) {
54
- return { ...result, effortSupport: 'supported', supportedEfforts: [...new Set(levels)] };
55
- }
61
+ const levels = codexModelEfforts(cache, model);
62
+ if (levels)
63
+ return { ...result, effortSupport: 'supported', supportedEfforts: levels };
56
64
  }
57
65
  }
58
66
  catch { /* Missing/stale metadata is unknown, never optimistic support. */ }
59
67
  return result;
60
68
  }
69
+ /** Reasoning levels a Codex catalog (`{ models: [...] }`) declares for one model; null when unknown or malformed. */
70
+ function codexModelEfforts(catalog, model) {
71
+ const models = catalog?.models;
72
+ const entry = Array.isArray(models) ? models.find((item) => item?.slug === model) : undefined;
73
+ const levels = entry?.supported_reasoning_levels?.map?.((item) => item?.effort);
74
+ if (!Array.isArray(levels) || !levels.length || !levels.every(value => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(value)))
75
+ return null;
76
+ return [...new Set(levels)];
77
+ }
61
78
  export function assertEffortSupported(selection, capability) {
62
79
  if (selection.effort !== undefined && (capability.effortSupport !== 'supported' || !capability.supportedEfforts?.includes(selection.effort))) {
63
80
  throw new AgentExecutionError(`Requested effort '${selection.effort}' is not confirmed for ${capability.transport}. Select a supported effort or provider default.`, 'provider_capability_unsupported');
@@ -32,7 +32,7 @@ export function buildCliInvocation(provider, request, options = {}) {
32
32
  }
33
33
  const readOnly = requestPolicy(request).access === 'read';
34
34
  // Pin the product alias; explicit IDs remain reproducible across releases.
35
- const claudeModelIds = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5' };
35
+ const claudeModelIds = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' };
36
36
  const modelId = provider === 'claude' && request.model ? claudeModelIds[request.model] ?? request.model : request.model;
37
37
  const model = modelId ? ['--model', modelId] : [];
38
38
  const extraRoots = request.allowedRoots.filter(root => root !== request.cwd);
@@ -54,14 +54,19 @@ export function buildCliInvocation(provider, request, options = {}) {
54
54
  ...(resume ? ['--resume', resume] : []),
55
55
  ] };
56
56
  case 'codex': {
57
- const sandbox = readOnly ? 'read-only' : 'workspace-write';
57
+ // Writers run without Codex's OS sandbox, like the Claude developer
58
+ // (--dangerously-skip-permissions) and Desktop's legacy rail runs: the
59
+ // workspace-write seatbelt denies what real verification needs (a
60
+ // localhost listen, Chromium's Mach bootstrap registration, the browser
61
+ // cache). Read-only roles keep the read-only sandbox.
62
+ const sandbox = readOnly ? 'read-only' : 'danger-full-access';
58
63
  const common = ['--json', '--skip-git-repo-check', '-c', 'approval_policy="never"', ...model,
59
64
  ...(request.effort === undefined ? [] : ['-c', 'model_reasoning_effort=' + JSON.stringify(request.effort)]),
60
65
  ...(options.openspecBridge ? ['-c', 'mcp_servers.specrails_openspec.command=' + JSON.stringify(options.openspecBridge.command), '-c', 'mcp_servers.specrails_openspec.args=' + JSON.stringify(options.openspecBridge.args), '-c', 'mcp_servers.specrails_openspec.default_tools_approval_mode="approve"', '-c', 'mcp_servers.specrails_openspec.required=true'] : []),
61
66
  ];
62
67
  // `codex exec resume` has no --sandbox flag; the same policy travels as a config override.
63
68
  if (resume)
64
- return { command: 'codex', stdin: request.prompt, args: ['exec', 'resume', ...common, '-c', `sandbox_mode="${sandbox}"`, '-c', 'sandbox_workspace_write.writable_roots=' + JSON.stringify(readOnly ? [] : request.allowedRoots), resume, '-'] };
69
+ return { command: 'codex', stdin: request.prompt, args: ['exec', 'resume', ...common, '-c', `sandbox_mode="${sandbox}"`, ...(readOnly ? ['-c', 'sandbox_workspace_write.writable_roots=[]'] : []), resume, '-'] };
65
70
  return { command: 'codex', stdin: request.prompt, args: [
66
71
  'exec', ...common, '--sandbox', sandbox,
67
72
  ...(options.codexSchemaFile ? ['--output-schema', options.codexSchemaFile] : []),
@@ -10,7 +10,7 @@ export declare function runtimeFailure(error: unknown): {
10
10
  };
11
11
  };
12
12
  /** Machine operations are mirrored in integration-contract.json; help is presentation-only. */
13
- export declare const RUNTIME_CLI_OPERATIONS: readonly ["api", "validate", "run", "status", "resume", "prompts", "capabilities", "evidence", "recovery", "evaluate", "workflows", "fork", "signal", "cancel", "help"];
13
+ export declare const RUNTIME_CLI_OPERATIONS: readonly ["api", "validate", "run", "status", "resume", "prompts", "capabilities", "evidence", "recovery", "evaluate", "workflows", "fork", "signal", "cancel", "host", "help"];
14
14
  /** The host-facing summary: phase status, pending interrupts and usage without accumulated outputs. */
15
15
  export declare function compactState(state: WorkflowState | null): {
16
16
  runId: string;
@@ -18,14 +18,58 @@ import { configuredCapabilities } from './capabilities.js';
18
18
  import { createExecutorRegistry } from './executors.js';
19
19
  import { runRecovery } from './recovery.js';
20
20
  import { EngineError } from './engine/contracts.js';
21
+ /**
22
+ * Long-lived agent session host (docs/agent-sessions/protocol.md). Loaded lazily
23
+ * so batch operations never initialize session drivers or the journal.
24
+ */
25
+ async function runSessionHost(flags) {
26
+ if (flags.stdio !== true)
27
+ throw new Error('runtime host requires --stdio');
28
+ if (flags.scope !== undefined && typeof flags.scope !== 'string')
29
+ throw new Error('--scope requires a value');
30
+ const [{ createSessionRuntime, GLOBAL_SESSION_SCOPE }, { SessionHost }, { CliExecutor }] = await Promise.all([
31
+ import('./session/index.js'),
32
+ import('./session/host/server.js'),
33
+ import('./cli-executor.js'),
34
+ ]);
35
+ const scope = flags.scope ?? GLOBAL_SESSION_SCOPE;
36
+ let host = null;
37
+ const runtime = await createSessionRuntime({
38
+ scope,
39
+ drivers: {
40
+ executors: [
41
+ { id: 'gemini', displayName: 'Gemini CLI', executor: new CliExecutor('gemini') },
42
+ { id: 'kimi', displayName: 'Kimi Code', executor: new CliExecutor('kimi') },
43
+ ],
44
+ },
45
+ onLeaseLost: () => host?.leaseLost(),
46
+ });
47
+ host = new SessionHost({ service: runtime.service, scope, identity: { ...coreRuntimeIdentity(), coreVersion: CORE_PACKAGE_VERSION }, close: () => runtime.close() }, { input: process.stdin, output: process.stdout });
48
+ const stop = () => { void host?.stop(); };
49
+ process.on('SIGINT', stop);
50
+ process.on('SIGTERM', stop);
51
+ try {
52
+ await host.start();
53
+ return 0;
54
+ }
55
+ finally {
56
+ process.off('SIGINT', stop);
57
+ process.off('SIGTERM', stop);
58
+ // A stopped host must not be kept alive by its still-open stdin pipe.
59
+ process.stdin.destroy();
60
+ }
61
+ }
21
62
  /** Both runtime entry points emit the same machine-readable fatal result. */
22
63
  export function runtimeFailure(error) {
23
64
  return { type: 'runtime-result', status: 'failed', error: error instanceof EngineError
24
65
  ? { code: error.code, message: error.message, ...(error.details === undefined ? {} : { details: error.details }) }
25
- : error instanceof Error ? error.message : String(error) };
66
+ // Session errors keep their code so hosts can react (e.g. `journal_locked` must not be retried).
67
+ : error instanceof Error && error.name === 'SessionError' && typeof error.code === 'string'
68
+ ? { code: error.code, message: error.message }
69
+ : error instanceof Error ? error.message : String(error) };
26
70
  }
27
71
  /** Machine operations are mirrored in integration-contract.json; help is presentation-only. */
28
- export const RUNTIME_CLI_OPERATIONS = ['api', 'validate', 'run', 'status', 'resume', 'prompts', 'capabilities', 'evidence', 'recovery', 'evaluate', 'workflows', 'fork', 'signal', 'cancel', 'help'];
72
+ export const RUNTIME_CLI_OPERATIONS = ['api', 'validate', 'run', 'status', 'resume', 'prompts', 'capabilities', 'evidence', 'recovery', 'evaluate', 'workflows', 'fork', 'signal', 'cancel', 'host', 'help'];
29
73
  function read(file) { return JSON.parse(readFileSync(file, 'utf8')); }
30
74
  async function readStdin() {
31
75
  const chunks = [];
@@ -150,6 +194,7 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
150
194
  'specrails-core runtime fork --context <json> --from <nodePath> --run-id <newRunId> [--scope-id <scope>] [--visit <number>] [--state <json>]',
151
195
  'specrails-core runtime signal --context <json> --stdin [--request-id <id>]',
152
196
  'specrails-core runtime cancel --context <json> [--request-id <id>]',
197
+ 'specrails-core runtime host --stdio [--scope <projectKey|global>]',
153
198
  ] });
154
199
  return 0;
155
200
  }
@@ -158,9 +203,11 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
158
203
  emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION],
159
204
  engineVersion: 2, engines: RUNTIME_ENGINES, nodeKindsVersion: NODE_KINDS_VERSION, nodeKinds: validationPieceRegistry().kinds(),
160
205
  capabilities: { openRoles: 1, scopedRecovery: 1, efficientRoleExecution: 1, reproducibleVerification: 1, implementationEfficiencyMetrics: 1, compactAgentLoop: 1, configurableGuardrails: 1, compactOutputBudget: 1, roleThinkingControl: 1, repositoryScope: 1,
161
- engineV2: 1, workflowAgentSteps: 1, implementationSteps: 1, workflowDefinitions: 1, fanOut: 1, fork: 1, forkIdempotency: 1, steeringInbox: 1 }, guardrails: GUARDRAIL_CATALOG });
206
+ engineV2: 1, workflowAgentSteps: 1, implementationSteps: 1, workflowDefinitions: 1, fanOut: 1, fork: 1, forkIdempotency: 1, steeringInbox: 1, hostBlockers: 1, setupCommands: 1, sessions: 1 }, guardrails: GUARDRAIL_CATALOG });
162
207
  return 0;
163
208
  }
209
+ if (command === 'host')
210
+ return runSessionHost(flags);
164
211
  if (command === 'validate') {
165
212
  if (flags.stdin !== undefined && flags.stdin !== true)
166
213
  throw new Error('--stdin is a boolean flag');
@@ -1,16 +1,29 @@
1
1
  import type { spawnSync } from 'node:child_process';
2
- interface EnvironmentInstall {
2
+ /** `timeoutMs` overrides the default bound for one plan (a browser download is far larger than a dependency install). */
3
+ export interface EnvironmentInstall {
3
4
  ecosystem: 'node' | 'python' | 'go' | 'rust';
4
5
  root: string;
5
6
  command: string;
6
7
  args: string[];
8
+ timeoutMs?: number;
9
+ installs?: string;
7
10
  }
8
11
  /** `precondition`: the install failed for a reason only the host can repair (see hostPreconditionFailure). */
9
- interface InstallOutcome extends EnvironmentInstall {
12
+ export interface InstallOutcome extends EnvironmentInstall {
10
13
  ok: boolean;
11
14
  detail: string;
12
15
  precondition?: string;
13
16
  }
17
+ export type HostPreconditionKind = 'network' | 'credential' | 'environment-variable' | 'toolchain' | 'setup' | 'environment';
18
+ export interface HostPrecondition {
19
+ kind: HostPreconditionKind;
20
+ reason: string;
21
+ requiredAction: string;
22
+ }
23
+ /** True when a verification failure names a missing Playwright browser build. */
24
+ export declare function isPlaywrightBrowserFailure(output: string): boolean;
25
+ /** The browser the missing build path names (`chromium_headless_shell-1243` → `chromium`), undefined when none is parsable. */
26
+ export declare function playwrightBrowser(output: string): 'chromium' | 'firefox' | 'webkit' | undefined;
14
27
  /** True when a verification failure looks like a missing toolchain/dependency rather than a code defect. */
15
28
  export declare function isEnvironmentFailure(exitCode: number | null | undefined, output: string): boolean;
16
29
  /**
@@ -23,6 +36,10 @@ export declare function isEnvironmentFailure(exitCode: number | null | undefined
23
36
  * again). Returns a short host-facing reason, or undefined.
24
37
  */
25
38
  export declare function hostPreconditionFailure(output: string): string | undefined;
39
+ /** The blocker kind the matched precondition belongs to; undefined when the output is not a host precondition. */
40
+ export declare function hostPreconditionKind(output: string): HostPreconditionKind | undefined;
41
+ /** Classifies a host precondition with its kind, host-facing reason and the action that unblocks it. */
42
+ export declare function hostPrecondition(output: string): HostPrecondition | undefined;
26
43
  /** Packages a failure output names explicitly (`Try \`npm i --save-dev @types/jest\``); the host installs exactly those. */
27
44
  export declare function suggestedPackages(output: string): string[];
28
45
  /** Dependencies package.json declares that node_modules does not hold (bounded). */
@@ -30,7 +47,7 @@ export declare function missingNodeDependencies(root: string): string[];
30
47
  /** The installs a repository root needs, judged from its manifests and the absence of their install output. */
31
48
  export declare function plannedInstalls(root: string, failureOutput?: string): EnvironmentInstall[];
32
49
  /** Runs each planned install once, bounded; never throws. */
33
- export declare function installEnvironment(roots: readonly string[], io?: {
50
+ export interface InstallIo {
34
51
  spawn?: typeof spawnSync;
35
52
  timeoutMs?: number;
36
53
  failureOutput?: string;
@@ -41,7 +58,35 @@ export declare function installEnvironment(roots: readonly string[], io?: {
41
58
  detail?: string;
42
59
  text?: string;
43
60
  }) => void;
44
- }): InstallOutcome[];
61
+ /** The installs for one root; the failure-driven plan by default. */
62
+ plan?: (root: string) => EnvironmentInstall[];
63
+ }
64
+ export declare function installEnvironment(roots: readonly string[], io?: InstallIo): InstallOutcome[];
65
+ type PlaywrightBrowser = 'chromium' | 'firefox' | 'webkit';
66
+ /** Browsers the repository's Playwright config runs (by project names, devices or browserName); chromium when it names none. */
67
+ export declare function configuredPlaywrightBrowsers(root: string): PlaywrightBrowser[];
68
+ /**
69
+ * The configured browsers when any build the installed Playwright pins is
70
+ * missing from the host cache. `install --dry-run` names the exact install
71
+ * locations without downloading; an unreadable answer means "unknown", never
72
+ * an install.
73
+ */
74
+ export declare function missingPlaywrightBrowsers(root: string, spawn?: typeof spawnSync): PlaywrightBrowser[];
75
+ /**
76
+ * Host preparation before an agent edits a repository: the dependencies its
77
+ * manifest declares but are not installed, then the browser builds its
78
+ * Playwright pins. Agents run sandboxed (Codex cannot write the browser cache
79
+ * or reach it at all), so what they need must already be there. Only cheap,
80
+ * idempotent, project-local work happens here; other ecosystems keep their
81
+ * failure-driven repair at verification.
82
+ */
83
+ export declare function prepareEnvironment(roots: readonly string[], io?: Omit<InstallIo, 'plan' | 'failureOutput'>): InstallOutcome[];
84
+ /**
85
+ * A Node preload that answers IPv6 lookups with nothing when the host has an
86
+ * IPv4 address, so dual-stack clients connect over IPv4. Written once to the
87
+ * temp directory; undefined when it cannot be written.
88
+ */
89
+ export declare function ipv4FirstPreload(): string | undefined;
45
90
  /** The repository's own test command, judged from its manifest (npm/pnpm/yarn `test` script, pytest, go, cargo); undefined when none. */
46
91
  export declare function detectCheckCommand(root: string): {
47
92
  command: string;
@@ -1,10 +1,32 @@
1
1
  import crossSpawn from 'cross-spawn';
2
2
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
3
4
  import path from 'node:path';
4
5
  // npm/pnpm/yarn are `.cmd` shims on Windows: a bare `spawnSync('npm')` is
5
6
  // ENOENT there, which read as an environment failure of its own and sent the
6
7
  // install/check round in circles. cross-spawn resolves the shim on every OS.
7
8
  const defaultSpawn = crossSpawn.sync;
9
+ // A Playwright test run on a host whose browser cache was never populated (a
10
+ // fresh machine, a new Playwright version): the package is installed, the
11
+ // browser build it pins is not. `playwright install` is the documented fix.
12
+ const PLAYWRIGHT_SIGNATURES = [
13
+ /browserType\.launch: Executable doesn't exist at/i,
14
+ /Looks like Playwright was just installed or updated/i,
15
+ /ms-playwright[\\/](?:chromium|firefox|webkit|chromium_headless_shell)[-_]\d+/i,
16
+ ];
17
+ /** True when a verification failure names a missing Playwright browser build. */
18
+ export function isPlaywrightBrowserFailure(output) {
19
+ const tail = output.slice(-6000);
20
+ return PLAYWRIGHT_SIGNATURES.some(pattern => pattern.test(tail));
21
+ }
22
+ /** The browser the missing build path names (`chromium_headless_shell-1243` → `chromium`), undefined when none is parsable. */
23
+ export function playwrightBrowser(output) {
24
+ const match = /ms-playwright[\\/](chromium|firefox|webkit|chromium_headless_shell)[-_]\d+/i.exec(output.slice(-6000));
25
+ if (!match)
26
+ return undefined;
27
+ const name = match[1].toLowerCase();
28
+ return name === 'chromium_headless_shell' ? 'chromium' : name;
29
+ }
8
30
  const ENVIRONMENT_SIGNATURES = [
9
31
  /command not found/i,
10
32
  /is not recognized as an internal or external command/i,
@@ -21,6 +43,7 @@ const ENVIRONMENT_SIGNATURES = [
21
43
  /Module (?:[\w@./-]+) in the \w+ option was not found|Preset [\w@./-]+ not found|Cannot find module '(?:ts-jest|babel-jest|@swc\/jest|ts-node|tsx)'/i,
22
44
  // Yarn Berry refuses to run scripts in a checkout that was never installed.
23
45
  /doesn't seem to have been installed - running an install there might help|Couldn't find the node_modules state file - running an install might help/i,
46
+ ...PLAYWRIGHT_SIGNATURES,
24
47
  ];
25
48
  /** True when a verification failure looks like a missing toolchain/dependency rather than a code defect. */
26
49
  export function isEnvironmentFailure(exitCode, output) {
@@ -40,20 +63,39 @@ const REGISTRY = String.raw `(?:registry|npmjs\.org|yarnpkg\.com|npm\.pkg\.githu
40
63
  * again). Returns a short host-facing reason, or undefined.
41
64
  */
42
65
  export function hostPreconditionFailure(output) {
66
+ return hostPrecondition(output)?.reason;
67
+ }
68
+ /** The blocker kind the matched precondition belongs to; undefined when the output is not a host precondition. */
69
+ export function hostPreconditionKind(output) {
70
+ return hostPrecondition(output)?.kind;
71
+ }
72
+ const PLAYWRIGHT_CDN = String.raw `(?:cdn\.playwright\.dev|playwright\.azureedge\.net|playwright-akamai\.azureedge\.net|playwright-verizon\.azureedge\.net)`;
73
+ /** Classifies a host precondition with its kind, host-facing reason and the action that unblocks it. */
74
+ export function hostPrecondition(output) {
43
75
  const text = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').slice(-16_000);
44
76
  const variable = /Environment variable not found \(([A-Za-z_][A-Za-z0-9_]*)\)/.exec(text);
45
77
  if (variable)
46
- return `the environment variable ${variable[1]} is not available to verification commands, and a package-manager configuration file requires it`;
78
+ return { kind: 'environment-variable', reason: `the environment variable ${variable[1]} is not available to verification commands, and a package-manager configuration file requires it`,
79
+ requiredAction: `Make ${variable[1]} available to Specrails (for example in the login shell profile it loads), then retry the run.` };
47
80
  if (/\bYN0041\b/.test(text) || /YN0035[\s\S]{0,400}?Response Code: 40[13]\b/.test(text) || /Response code 40[13] \((?:Unauthorized|Forbidden)\)/i.test(text)
48
81
  || /npm (?:ERR!|error) code (?:E401|E403|ENEEDAUTH)\b/.test(text) || /ERR_PNPM_FETCH_40[13]\b/.test(text)
49
82
  || new RegExp(String.raw `\b40[13] (?:Unauthorized|Forbidden)\b[^\n]{0,80}https?://[^\s]*${REGISTRY}`, 'i').test(text)
50
83
  || /\b(?:401|403) Client Error: (?:Unauthorized|Forbidden) for url/.test(text))
51
- return 'the package registry rejected the credentials available to verification commands (HTTP 401/403)';
84
+ return { kind: 'credential', reason: 'the package registry rejected the credentials available to verification commands (HTTP 401/403)',
85
+ requiredAction: 'Refresh the registry credentials Specrails can use (for example a new token in the login shell profile it loads), then retry the run.' };
86
+ // The browser download is checked before registry reachability: its CDN is
87
+ // not a package registry, and the fix is a different command.
88
+ if (/Failed to download (?:Chrome for Testing|Chromium|Firefox|WebKit|chromium|firefox|webkit|FFMPEG|ffmpeg)\b/i.test(text)
89
+ || new RegExp(String.raw `Error: (?:getaddrinfo ENOTFOUND|connect ETIMEDOUT|connect ECONNREFUSED|ECONNRESET|ETIMEDOUT)[^\n]{0,300}${PLAYWRIGHT_CDN}`, 'i').test(text))
90
+ return { kind: 'network', reason: 'the Playwright browser download cannot reach its CDN from the verification environment',
91
+ requiredAction: 'Download the Playwright browsers with network access (`npx playwright install`), then retry the run.' };
52
92
  if (/npm (?:ERR!|error) code (?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ECONNRESET)\b/.test(text) || /ERR_PNPM_META_FETCH_FAIL\b/.test(text)
53
93
  || new RegExp(String.raw `\b(?:ENOTFOUND|EAI_AGAIN)\b[^\n]{0,200}${REGISTRY}|${REGISTRY}[^\n]{0,200}\b(?:ENOTFOUND|EAI_AGAIN)\b`, 'i').test(text))
54
- return 'the package registry cannot be reached from the verification environment';
94
+ return { kind: 'network', reason: 'the package registry cannot be reached from the verification environment',
95
+ requiredAction: 'Give the verification environment network access to the package registry, then retry the run.' };
55
96
  if (/fatal: could not read (?:Username|Password) for/.test(text) || /Permission denied \(publickey\)/.test(text))
56
- return 'git credentials needed to fetch a dependency are not available to verification commands';
97
+ return { kind: 'credential', reason: 'git credentials needed to fetch a dependency are not available to verification commands',
98
+ requiredAction: 'Make the git credentials that fetch the dependency (an SSH key or credential helper) available to Specrails, then retry the run.' };
57
99
  return undefined;
58
100
  }
59
101
  /** Packages a failure output names explicitly (`Try \`npm i --save-dev @types/jest\``); the host installs exactly those. */
@@ -91,6 +133,22 @@ export function missingNodeDependencies(root) {
91
133
  }
92
134
  return [...declared].filter(name => !existsSync(path.join(root, 'node_modules', name))).slice(0, 20);
93
135
  }
136
+ /** Browser builds are ~150 MB each; the default 5-minute install bound is too short on a slow link. */
137
+ const PLAYWRIGHT_INSTALL_TIMEOUT_MS = 10 * 60_000;
138
+ /** True when package.json declares `@playwright/test` or `playwright`; only those repositories get a browser install plan. */
139
+ function declaresPlaywright(root) {
140
+ let manifest;
141
+ try {
142
+ manifest = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ return ['dependencies', 'devDependencies', 'optionalDependencies'].some(field => {
148
+ const block = manifest[field];
149
+ return !!block && typeof block === 'object' && ['@playwright/test', 'playwright'].some(name => Object.hasOwn(block, name));
150
+ });
151
+ }
94
152
  /** The installs a repository root needs, judged from its manifests and the absence of their install output. */
95
153
  export function plannedInstalls(root, failureOutput = '') {
96
154
  const plans = [];
@@ -107,6 +165,14 @@ export function plannedInstalls(root, failureOutput = '') {
107
165
  const runner = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : 'npm';
108
166
  plans.push({ ecosystem: 'node', root, command: runner, args: runner === 'npm' ? ['install', '--no-audit', '--no-fund', '--loglevel=error'] : ['install'] });
109
167
  }
168
+ // After the dependency plan: a fresh worktree needs node_modules before
169
+ // `playwright install` can run at all.
170
+ if (isPlaywrightBrowserFailure(failureOutput) && declaresPlaywright(root)) {
171
+ const runner = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : 'npx';
172
+ const browser = playwrightBrowser(failureOutput);
173
+ plans.push({ ecosystem: 'node', root, command: runner, args: [...(runner === 'pnpm' ? ['exec'] : []), 'playwright', 'install', ...(browser ? [browser] : [])],
174
+ timeoutMs: PLAYWRIGHT_INSTALL_TIMEOUT_MS, installs: `Playwright ${browser ?? 'browsers'}` });
175
+ }
110
176
  if ((has('requirements.txt') || has('pyproject.toml')) && !has('.venv') && !has('venv')) {
111
177
  const python = process.platform === 'win32' ? 'python' : 'python3';
112
178
  plans.push({ ecosystem: 'python', root, command: python, args: has('requirements.txt') ? ['-m', 'pip', 'install', '-q', '-r', 'requirements.txt'] : ['-m', 'pip', 'install', '-q', '-e', '.'] });
@@ -143,16 +209,15 @@ function relaxManifestPin(root, name, version) {
143
209
  writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n');
144
210
  return changed;
145
211
  }
146
- /** Runs each planned install once, bounded; never throws. */
147
212
  export function installEnvironment(roots, io = {}) {
148
213
  const spawn = io.spawn ?? defaultSpawn;
149
214
  const outcomes = [];
150
215
  for (const root of roots) {
151
- for (const plan of plannedInstalls(root, io.failureOutput ?? '')) {
216
+ for (const plan of io.plan ? io.plan(root) : plannedInstalls(root, io.failureOutput ?? '')) {
152
217
  io.onEvent?.({ kind: 'tool-start', tool: plan.command, detail: `${plan.args.join(' ')} (${path.basename(root)})` });
153
218
  const run = () => {
154
219
  try {
155
- return spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
220
+ return spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
156
221
  }
157
222
  catch (error) {
158
223
  return { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
@@ -180,9 +245,24 @@ export function installEnvironment(roots, io = {}) {
180
245
  io.onEvent?.({ kind: 'text', text: `Environment: ${unpublished[1]}@${unpublished[2]} is not published; relaxed the pin in package.json and retrying ${plan.command} ${plan.args.join(' ')}` });
181
246
  result = run();
182
247
  }
248
+ // Playwright's downloader tries IPv6 first; on a host whose DNS returns
249
+ // an AAAA record without an IPv6 route, Node's family auto-selection
250
+ // times the request out before IPv4 is tried. Retry once preferring IPv4.
251
+ if (result.status !== 0 && plan.installs?.startsWith('Playwright') && NETWORK_TIMEOUT.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`)) {
252
+ const preload = ipv4FirstPreload();
253
+ if (preload) {
254
+ io.onEvent?.({ kind: 'text', text: `Environment: the browser download timed out; retrying ${plan.command} ${plan.args.join(' ')} over IPv4` });
255
+ try {
256
+ result = spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(preload)}`.trim() } });
257
+ }
258
+ catch (error) {
259
+ result = { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
260
+ }
261
+ }
262
+ }
183
263
  io.onEvent?.({ kind: 'tool-end', tool: plan.command });
184
264
  const ok = result.status === 0;
185
- const detail = ok ? `installed ${plan.ecosystem} dependencies in ${path.basename(root)}` : `${plan.command} ${plan.args.join(' ')} failed in ${path.basename(root)}: ${String(result.stderr || result.error?.message || `exit ${result.status}`).trim().slice(0, 400)}`;
265
+ const detail = ok ? `installed ${plan.installs ?? `${plan.ecosystem} dependencies`} in ${path.basename(root)}` : `${plan.command} ${plan.args.join(' ')} failed in ${path.basename(root)}: ${String(result.stderr || result.error?.message || `exit ${result.status}`).trim().slice(0, 400)}`;
186
266
  io.onEvent?.({ kind: 'text', text: `Environment: ${detail}` });
187
267
  const precondition = ok ? undefined : hostPreconditionFailure(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
188
268
  outcomes.push({ ...plan, ok, detail, ...(precondition ? { precondition } : {}) });
@@ -190,6 +270,109 @@ export function installEnvironment(roots, io = {}) {
190
270
  }
191
271
  return outcomes;
192
272
  }
273
+ const PLAYWRIGHT_CONFIGS = ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.cts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
274
+ /** Browsers the repository's Playwright config runs (by project names, devices or browserName); chromium when it names none. */
275
+ export function configuredPlaywrightBrowsers(root) {
276
+ const file = PLAYWRIGHT_CONFIGS.map(name => path.join(root, name)).find(candidate => existsSync(candidate));
277
+ let source = '';
278
+ try {
279
+ if (file)
280
+ source = readFileSync(file, 'utf8').slice(0, 200_000);
281
+ }
282
+ catch { /* unreadable config: the default browser */ }
283
+ const browsers = [];
284
+ if (/\b(?:chromium|chrome|edge|pixel|galaxy)\b/i.test(source))
285
+ browsers.push('chromium');
286
+ if (/\bfirefox\b/i.test(source))
287
+ browsers.push('firefox');
288
+ if (/\b(?:webkit|safari|iphone|ipad)\b/i.test(source))
289
+ browsers.push('webkit');
290
+ return browsers.length ? browsers : ['chromium'];
291
+ }
292
+ /** How to run the repository's own Playwright CLI; undefined until its package is installed (never downloads one). */
293
+ function playwrightCli(root) {
294
+ const bin = path.join(root, 'node_modules', '.bin');
295
+ if (!['playwright', 'playwright.cmd'].some(name => existsSync(path.join(bin, name))))
296
+ return undefined;
297
+ if (existsSync(path.join(root, 'pnpm-lock.yaml')))
298
+ return { command: 'pnpm', args: ['exec', 'playwright'] };
299
+ if (existsSync(path.join(root, 'yarn.lock')))
300
+ return { command: 'yarn', args: ['playwright'] };
301
+ return { command: 'npx', args: ['--no', 'playwright'] };
302
+ }
303
+ /**
304
+ * The configured browsers when any build the installed Playwright pins is
305
+ * missing from the host cache. `install --dry-run` names the exact install
306
+ * locations without downloading; an unreadable answer means "unknown", never
307
+ * an install.
308
+ */
309
+ export function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
310
+ if (!declaresPlaywright(root))
311
+ return [];
312
+ const cli = playwrightCli(root);
313
+ if (!cli)
314
+ return [];
315
+ const browsers = configuredPlaywrightBrowsers(root);
316
+ let result;
317
+ try {
318
+ result = spawn(cli.command, [...cli.args, 'install', '--dry-run', ...browsers], { cwd: root, encoding: 'utf8', timeout: 30_000, windowsHide: true, env: process.env });
319
+ }
320
+ catch {
321
+ return [];
322
+ }
323
+ if (result.status !== 0)
324
+ return [];
325
+ const locations = [...String(result.stdout ?? '').matchAll(/Install location:\s+(.+?)\s*$/gm)].map(match => match[1]);
326
+ return locations.length && locations.some(location => !existsSync(location)) ? browsers : [];
327
+ }
328
+ /**
329
+ * Host preparation before an agent edits a repository: the dependencies its
330
+ * manifest declares but are not installed, then the browser builds its
331
+ * Playwright pins. Agents run sandboxed (Codex cannot write the browser cache
332
+ * or reach it at all), so what they need must already be there. Only cheap,
333
+ * idempotent, project-local work happens here; other ecosystems keep their
334
+ * failure-driven repair at verification.
335
+ */
336
+ export function prepareEnvironment(roots, io = {}) {
337
+ const spawn = io.spawn ?? defaultSpawn;
338
+ const dependencies = installEnvironment(roots, { ...io, plan: root => plannedInstalls(root).filter(plan => plan.ecosystem === 'node') });
339
+ const browsers = installEnvironment(roots, { ...io, plan: root => {
340
+ const missing = missingPlaywrightBrowsers(root, spawn);
341
+ const cli = missing.length ? playwrightCli(root) : undefined;
342
+ return cli ? [{ ecosystem: 'node', root, command: cli.command, args: [...cli.args, 'install', ...missing], timeoutMs: PLAYWRIGHT_INSTALL_TIMEOUT_MS, installs: `Playwright ${missing.join(', ')}` }] : [];
343
+ } });
344
+ return [...dependencies, ...browsers];
345
+ }
346
+ const NETWORK_TIMEOUT = /timed out after \d+ ?ms|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i;
347
+ /**
348
+ * A Node preload that answers IPv6 lookups with nothing when the host has an
349
+ * IPv4 address, so dual-stack clients connect over IPv4. Written once to the
350
+ * temp directory; undefined when it cannot be written.
351
+ */
352
+ export function ipv4FirstPreload() {
353
+ const file = path.join(tmpdir(), 'specrails-ipv4-first-v1.cjs');
354
+ if (existsSync(file))
355
+ return file;
356
+ try {
357
+ writeFileSync(file, [
358
+ "const dns = require('node:dns')",
359
+ 'const lookup = dns.promises.lookup.bind(dns.promises)',
360
+ 'dns.promises.lookup = async (hostname, options) => {',
361
+ " const family = typeof options === 'number' ? options : options && options.family",
362
+ ' if (family === 6) {',
363
+ ' const v4 = await lookup(hostname, { all: true, family: 4 }).catch(() => [])',
364
+ " if (v4.length) { if (options && typeof options === 'object' && options.all) return []; throw Object.assign(new Error('IPv6 skipped: ' + hostname), { code: 'ENOTFOUND' }) }",
365
+ ' }',
366
+ ' return lookup(hostname, options)',
367
+ '}',
368
+ '',
369
+ ].join('\n'));
370
+ return file;
371
+ }
372
+ catch {
373
+ return undefined;
374
+ }
375
+ }
193
376
  /** The repository's own test command, judged from its manifest (npm/pnpm/yarn `test` script, pytest, go, cargo); undefined when none. */
194
377
  export function detectCheckCommand(root) {
195
378
  const has = (file) => existsSync(path.join(root, file));