singleton-pipeline 0.4.0-beta.13 → 0.4.0-beta.14

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 (77) hide show
  1. package/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
  2. package/dist/packages/cli/src/commands/new.js +763 -0
  3. package/dist/packages/cli/src/commands/repl.js +557 -0
  4. package/dist/packages/cli/src/commands/usage.js +49 -0
  5. package/dist/packages/cli/src/executor/debug-loop.js +525 -0
  6. package/dist/packages/cli/src/executor/inputs.js +226 -0
  7. package/dist/packages/cli/src/executor/outputs.js +134 -0
  8. package/dist/packages/cli/src/executor/preflight.js +605 -0
  9. package/dist/packages/cli/src/executor/replay-loop.js +120 -0
  10. package/dist/packages/cli/src/executor/run-report.js +209 -0
  11. package/dist/packages/cli/src/executor/run-setup.js +114 -0
  12. package/dist/packages/cli/src/executor/security-review.js +97 -0
  13. package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
  14. package/dist/packages/cli/src/executor/step-runner.js +241 -0
  15. package/dist/packages/cli/src/executor.js +584 -0
  16. package/dist/packages/cli/src/index.js +107 -0
  17. package/dist/packages/cli/src/parser.js +89 -0
  18. package/dist/packages/cli/src/runners/_shared.js +96 -0
  19. package/dist/packages/cli/src/runners/claude.js +103 -0
  20. package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
  21. package/dist/packages/cli/src/runners/codex.js +141 -0
  22. package/dist/packages/cli/src/runners/copilot.js +209 -0
  23. package/dist/packages/cli/src/runners/index.js +18 -0
  24. package/dist/packages/cli/src/runners/opencode.js +240 -0
  25. package/dist/packages/cli/src/scanner.js +43 -0
  26. package/dist/packages/cli/src/security/policy.js +115 -0
  27. package/dist/packages/cli/src/sentinels.js +1 -0
  28. package/dist/packages/cli/src/shell.js +753 -0
  29. package/dist/packages/cli/src/theme.js +39 -0
  30. package/dist/packages/cli/src/timeline.js +238 -0
  31. package/dist/packages/cli/src/types.js +1 -0
  32. package/dist/packages/cli/src/usage/aggregator.js +44 -0
  33. package/dist/packages/cli/src/usage/reader.js +30 -0
  34. package/dist/packages/cli/src/usage/types.js +1 -0
  35. package/dist/packages/server/src/index.js +36 -0
  36. package/dist/packages/server/src/routes/agents.js +31 -0
  37. package/dist/packages/server/src/routes/files.js +45 -0
  38. package/dist/packages/server/src/routes/pipelines.js +74 -0
  39. package/docs/reference.md +28 -0
  40. package/package.json +15 -14
  41. package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
  42. package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
  43. package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
  44. package/packages/web/dist/index.html +2 -2
  45. package/packages/cli/package.json +0 -18
  46. package/packages/cli/src/commands/new.js +0 -786
  47. package/packages/cli/src/commands/repl.js +0 -548
  48. package/packages/cli/src/executor/debug-loop.js +0 -587
  49. package/packages/cli/src/executor/inputs.js +0 -202
  50. package/packages/cli/src/executor/outputs.js +0 -140
  51. package/packages/cli/src/executor/preflight.js +0 -459
  52. package/packages/cli/src/executor/replay-loop.js +0 -172
  53. package/packages/cli/src/executor/run-report.js +0 -189
  54. package/packages/cli/src/executor/run-setup.js +0 -93
  55. package/packages/cli/src/executor/security-review.js +0 -108
  56. package/packages/cli/src/executor/snapshot-manager.js +0 -335
  57. package/packages/cli/src/executor/step-runner.js +0 -266
  58. package/packages/cli/src/executor.js +0 -652
  59. package/packages/cli/src/index.js +0 -107
  60. package/packages/cli/src/parser.js +0 -78
  61. package/packages/cli/src/runners/_shared.js +0 -83
  62. package/packages/cli/src/runners/claude.js +0 -122
  63. package/packages/cli/src/runners/codex-instructions.js +0 -75
  64. package/packages/cli/src/runners/codex.js +0 -165
  65. package/packages/cli/src/runners/copilot.js +0 -224
  66. package/packages/cli/src/runners/index.js +0 -20
  67. package/packages/cli/src/runners/opencode.js +0 -265
  68. package/packages/cli/src/scanner.js +0 -47
  69. package/packages/cli/src/security/policy.js +0 -126
  70. package/packages/cli/src/shell.js +0 -732
  71. package/packages/cli/src/theme.js +0 -46
  72. package/packages/cli/src/timeline.js +0 -180
  73. package/packages/server/package.json +0 -11
  74. package/packages/server/src/index.js +0 -43
  75. package/packages/server/src/routes/agents.js +0 -32
  76. package/packages/server/src/routes/files.js +0 -42
  77. package/packages/server/src/routes/pipelines.js +0 -74
@@ -0,0 +1,605 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { parseAgentFileDetailed } from '../parser.js';
5
+ import { G } from '../shell.js';
6
+ import { getRunner } from '../runners/index.js';
7
+ import { discoverCodexProjectInstructions } from '../runners/codex-instructions.js';
8
+ import { assertWriteAllowed, resolveSecurityPolicyWithConfig, validateSecurityPolicy, } from '../security/policy.js';
9
+ import { buildUserMessage, escapePromptXml, parsePipeRef, resolveFileGlob } from './inputs.js';
10
+ import { isSingletonInternalPath } from './outputs.js';
11
+ const WINDOWS_ARGV_PROMPT_WARN_BYTES = 24 * 1024;
12
+ // Hard block threshold: past this, the prompt is very likely to crash the
13
+ // provider on Windows (CMD/CreateProcess ~32 KiB ceiling, Copilot documents
14
+ // ~32 KiB). We refuse to start the pipeline rather than let it fail mid-run
15
+ // with an opaque ENAMETOOLONG/EINVAL surfacing from the spawn syscall.
16
+ const WINDOWS_ARGV_PROMPT_BLOCK_BYTES = 28 * 1024;
17
+ /**
18
+ * @param {Partial<PipelineStep>} step
19
+ * @param {Partial<AgentConfig>} agent
20
+ * @returns {ProviderId}
21
+ */
22
+ export function resolveProvider(step, agent) {
23
+ return (step.provider || agent.provider || 'claude');
24
+ }
25
+ /**
26
+ * @param {Partial<PipelineStep>} step
27
+ * @param {Partial<AgentConfig>} agent
28
+ * @returns {string | null}
29
+ */
30
+ export function resolveModel(step, agent) {
31
+ return step.model || agent.model || null;
32
+ }
33
+ /**
34
+ * @param {Partial<PipelineStep>} step
35
+ * @param {Partial<AgentConfig>} agent
36
+ * @returns {string | null}
37
+ */
38
+ export function resolveRunnerAgent(step, agent) {
39
+ return step.runner_agent || step.opencode_agent || agent.runner_agent || agent.opencode_agent || null;
40
+ }
41
+ /**
42
+ * @param {Partial<PipelineStep>} step
43
+ * @param {Partial<AgentConfig>} agent
44
+ * @returns {string}
45
+ */
46
+ export function resolvePermissionMode(step, agent) {
47
+ return step.permission_mode || agent.permission_mode || '';
48
+ }
49
+ /**
50
+ * @param {string} cmd
51
+ * @param {string[]} args
52
+ * @param {{ cwd: string }} options
53
+ * @returns {Promise<CommandResult>}
54
+ */
55
+ function runCommand(cmd, args, { cwd }) {
56
+ return new Promise((resolve, reject) => {
57
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
58
+ let stdout = '';
59
+ let stderr = '';
60
+ child.stdout.on('data', (d) => (stdout += d.toString()));
61
+ child.stderr.on('data', (d) => (stderr += d.toString()));
62
+ child.on('error', reject);
63
+ child.on('close', (code) => {
64
+ if (code !== 0) {
65
+ reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
66
+ return;
67
+ }
68
+ resolve({ stdout, stderr });
69
+ });
70
+ });
71
+ }
72
+ async function resolveCopilotProjectRoot(cwd) {
73
+ try {
74
+ const { stdout } = await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd });
75
+ return stdout.trim() || cwd;
76
+ }
77
+ catch {
78
+ return cwd;
79
+ }
80
+ }
81
+ async function findCopilotRepoAgentProfile(cwd, runnerAgent) {
82
+ const name = String(runnerAgent || '').trim();
83
+ if (!name || name.includes('/') || name.includes('\\'))
84
+ return null;
85
+ const projectRoot = await resolveCopilotProjectRoot(cwd);
86
+ const file = path.join(projectRoot, '.github', 'agents', `${name}.agent.md`);
87
+ try {
88
+ await fs.access(file);
89
+ const raw = await fs.readFile(file, 'utf8');
90
+ return { file, projectRoot, tools: parseCopilotAgentTools(raw) };
91
+ }
92
+ catch {
93
+ const singletonRootFile = path.join(cwd, '.github', 'agents', `${name}.agent.md`);
94
+ try {
95
+ await fs.access(singletonRootFile);
96
+ const raw = await fs.readFile(singletonRootFile, 'utf8');
97
+ return {
98
+ file: singletonRootFile,
99
+ projectRoot,
100
+ notVisibleFromGitRoot: projectRoot !== cwd,
101
+ tools: parseCopilotAgentTools(raw),
102
+ };
103
+ }
104
+ catch {
105
+ return { file: null, projectRoot };
106
+ }
107
+ }
108
+ }
109
+ function parseCopilotAgentTools(raw) {
110
+ const match = String(raw || '').match(/^---\n([\s\S]*?)\n---/);
111
+ if (!match)
112
+ return [];
113
+ for (const line of match[1].split('\n')) {
114
+ const m = line.match(/^\s*tools\s*:\s*\[([^\]]*)\]\s*$/);
115
+ if (!m)
116
+ continue;
117
+ return m[1]
118
+ .split(',')
119
+ .map((token) => token.trim().replace(/^["']|["']$/g, ''))
120
+ .filter(Boolean);
121
+ }
122
+ return [];
123
+ }
124
+ function validateCopilotAgentTools({ label, runnerAgent, securityPolicy, tools, }) {
125
+ const errors = [];
126
+ const warnings = [];
127
+ const list = Array.isArray(tools) ? tools : [];
128
+ const writeEnabled = list.includes('write') || list.includes('edit');
129
+ const shellEnabled = list.includes('shell') || list.includes('bash');
130
+ if (securityPolicy.profile === 'restricted-write' || securityPolicy.profile === 'workspace-write') {
131
+ if (list.length && !writeEnabled) {
132
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" declares tools without write/edit; the step may be unable to modify allowed_paths.`);
133
+ }
134
+ if (shellEnabled) {
135
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" enables shell tools; Singleton cannot sandbox external side effects from shell commands.`);
136
+ }
137
+ }
138
+ if (securityPolicy.profile === 'read-only' && writeEnabled) {
139
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" enables write/edit tools; Singleton will override them with --deny-tool=write for security_profile "read-only".`);
140
+ }
141
+ return { errors, warnings };
142
+ }
143
+ async function findOpenCodeProjectAgentProfile(cwd, runnerAgent) {
144
+ const name = String(runnerAgent || '').trim();
145
+ if (!name || name.includes('/') || name.includes('\\'))
146
+ return null;
147
+ const file = path.join(cwd, '.opencode', 'agents', `${name}.md`);
148
+ try {
149
+ await fs.access(file);
150
+ const raw = await fs.readFile(file, 'utf8');
151
+ return { file, tools: parseOpenCodeAgentTools(raw) };
152
+ }
153
+ catch {
154
+ return { file: null };
155
+ }
156
+ }
157
+ function parseOpenCodeAgentTools(raw) {
158
+ const match = String(raw || '').match(/^---\n([\s\S]*?)\n---/);
159
+ if (!match)
160
+ return {};
161
+ const tools = {};
162
+ let inTools = false;
163
+ for (const line of match[1].split('\n')) {
164
+ if (/^\s*tools\s*:\s*$/.test(line)) {
165
+ inTools = true;
166
+ continue;
167
+ }
168
+ if (inTools && /^\S/.test(line))
169
+ break;
170
+ const toolMatch = line.match(/^\s{2,}([A-Za-z0-9_-]+)\s*:\s*(true|false)\s*$/);
171
+ if (inTools && toolMatch) {
172
+ tools[toolMatch[1]] = toolMatch[2] === 'true';
173
+ }
174
+ }
175
+ return tools;
176
+ }
177
+ function validateOpenCodeAgentTools({ label, runnerAgent, securityPolicy, tools, }) {
178
+ const errors = [];
179
+ const warnings = [];
180
+ const writeEnabled = tools.write === true || tools.edit === true;
181
+ const bashEnabled = tools.bash === true;
182
+ if (securityPolicy.profile === 'read-only') {
183
+ const enabled = ['write', 'edit', 'bash'].filter((name) => tools[name] === true);
184
+ if (enabled.length) {
185
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables legacy ${enabled.join(', ')} tools; Singleton will override them with native OpenCode permissions for security_profile "read-only".`);
186
+ }
187
+ }
188
+ if (securityPolicy.profile === 'restricted-write') {
189
+ warnings.push(`${label} uses OpenCode with security_profile "restricted-write"; Singleton will inject native OpenCode edit permissions for allowed_paths and still validate post-run changes.`);
190
+ if (!writeEnabled) {
191
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" does not enable write/edit tools; the step may be unable to modify allowed_paths.`);
192
+ }
193
+ if (bashEnabled) {
194
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables bash; Singleton cannot sandbox external side effects from shell commands.`);
195
+ }
196
+ }
197
+ if (securityPolicy.profile === 'workspace-write') {
198
+ if (!writeEnabled) {
199
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" does not enable write/edit tools; the step may behave as read-only.`);
200
+ }
201
+ if (bashEnabled) {
202
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables bash; keep workspace-write steps scoped and review post-run changes.`);
203
+ }
204
+ }
205
+ return { errors, warnings };
206
+ }
207
+ function formatSecurityHighlight({ label, provider, permissionMode, securityPolicy, }) {
208
+ const parts = [`${label}: security_profile "${securityPolicy.profile}"`];
209
+ if (provider === 'claude' && permissionMode) {
210
+ parts.push(`permission_mode "${permissionMode}"`);
211
+ }
212
+ if (securityPolicy.profile === 'restricted-write') {
213
+ parts.push(`allowed_paths ${securityPolicy.allowedPaths.join(', ') || '—'}`);
214
+ }
215
+ return parts.join(` ${G.bullet} `);
216
+ }
217
+ function shouldHighlightSecurity({ provider, permissionMode, securityPolicy, }) {
218
+ return securityPolicy.profile !== 'workspace-write' || (provider === 'claude' && Boolean(permissionMode));
219
+ }
220
+ function wrapProviderPromptEstimate(provider, { runnerAgent, systemPrompt, userMessage, }) {
221
+ if (provider === 'claude')
222
+ return systemPrompt;
223
+ if (provider === 'copilot')
224
+ return runnerAgent
225
+ ? userMessage
226
+ : ['<system>', systemPrompt, '</system>', '', '<user>', userMessage, '</user>', ''].join('\n');
227
+ if (provider === 'opencode') {
228
+ return ['<system>', systemPrompt, '</system>', '', '<user>', userMessage, '</user>', ''].join('\n');
229
+ }
230
+ return '';
231
+ }
232
+ /**
233
+ * @param {PipelineStep} step
234
+ * @param {{ cwd: string, inputValues: Record<string, string>, inputDefs: InputDef[] }} options
235
+ * @returns {Promise<Record<string, string>>}
236
+ */
237
+ async function estimateResolvedInputsForArgv(step, { cwd, inputValues, inputDefs }) {
238
+ /** @type {Record<string, string>} */
239
+ const resolved = {};
240
+ for (const [name, spec] of Object.entries(step.inputs || {})) {
241
+ if (typeof spec !== 'string') {
242
+ resolved[name] = escapePromptXml(spec);
243
+ continue;
244
+ }
245
+ if (spec.startsWith('$PIPE:')) {
246
+ resolved[name] = `(pipeline output reference: ${spec.slice('$PIPE:'.length).trim()})`;
247
+ continue;
248
+ }
249
+ if (spec.startsWith('$INPUT:')) {
250
+ const id = spec.slice('$INPUT:'.length).trim();
251
+ const val = inputValues[id];
252
+ const def = inputDefs.find((item) => item.id === id);
253
+ if (def?.subtype === 'file' && val && !String(val).startsWith('(')) {
254
+ resolved[name] = await estimateFilePromptBlock(`$FILE:${val}`, cwd);
255
+ }
256
+ else {
257
+ resolved[name] = escapePromptXml(val || `(input not provided: ${id})`);
258
+ }
259
+ continue;
260
+ }
261
+ if (spec.startsWith('$FILE:')) {
262
+ resolved[name] = await estimateFilePromptBlock(spec, cwd);
263
+ continue;
264
+ }
265
+ resolved[name] = escapePromptXml(spec);
266
+ }
267
+ return resolved;
268
+ }
269
+ async function estimateFilePromptBlock(spec, cwd) {
270
+ const files = await resolveFileGlob(spec, cwd);
271
+ if (files.length === 0)
272
+ return `(no files matched: ${spec})`;
273
+ return files.map((file) => {
274
+ const relPath = path.relative(cwd, file.path).split(path.sep).join('/');
275
+ return `<file path="${relPath}" source="user" content_escaped="true">\n${escapePromptXml(file.content)}\n</file>`;
276
+ }).join('\n\n');
277
+ }
278
+ function findBiggestInputContributor(resolvedInputs) {
279
+ let topName = null;
280
+ let topBytes = 0;
281
+ for (const [name, value] of Object.entries(resolvedInputs)) {
282
+ const bytes = Buffer.byteLength(String(value ?? ''), 'utf8');
283
+ if (bytes > topBytes) {
284
+ topBytes = bytes;
285
+ topName = name;
286
+ }
287
+ }
288
+ return topName ? { name: topName, bytes: topBytes } : null;
289
+ }
290
+ export async function getWindowsArgvPromptCheck({ platform = process.platform, label, provider, runnerAgent, step, agent, cwd, inputValues, inputDefs, securityPolicy, }) {
291
+ if (platform !== 'win32')
292
+ return null;
293
+ if (!['claude', 'copilot', 'opencode'].includes(provider))
294
+ return null;
295
+ const systemPrompt = agent.prompt || agent.description || '';
296
+ const outputNames = Object.keys(step.outputs || {});
297
+ const resolvedInputs = await estimateResolvedInputsForArgv(step, { cwd, inputValues, inputDefs });
298
+ const userMessage = buildUserMessage(resolvedInputs, outputNames, { projectRoot: cwd, stepDirRel: `.singleton/runs/<run-id>/<step-${label}>` }, securityPolicy);
299
+ const argvPrompt = wrapProviderPromptEstimate(provider, { runnerAgent, systemPrompt, userMessage });
300
+ const bytes = Buffer.byteLength(argvPrompt, 'utf8');
301
+ if (bytes <= WINDOWS_ARGV_PROMPT_WARN_BYTES)
302
+ return null;
303
+ const topContributor = findBiggestInputContributor(resolvedInputs);
304
+ const sizeLabel = `${Math.round(bytes / 1024)} KiB prompt argument`;
305
+ const inputHint = topContributor
306
+ ? ` Biggest contributor: input "${topContributor.name}" (~${Math.round(topContributor.bytes / 1024)} KiB).`
307
+ : '';
308
+ if (bytes >= WINDOWS_ARGV_PROMPT_BLOCK_BYTES) {
309
+ return {
310
+ level: 'error',
311
+ message: `${label} would exceed the Windows command-line ceiling for provider "${provider}" (${sizeLabel}, hard limit ~${Math.round(WINDOWS_ARGV_PROMPT_BLOCK_BYTES / 1024)} KiB).${inputHint} Shrink the input or move the large content to a $FILE: reference.`,
312
+ };
313
+ }
314
+ return {
315
+ level: 'warning',
316
+ message: `${label} may exceed Windows command-line length limits for provider "${provider}" (${sizeLabel}).${inputHint} Prefer smaller inputs or a provider path that uses stdin/files.`,
317
+ };
318
+ }
319
+ // Back-compat shim: older callers expect a plain message string. Returns the
320
+ // `.message` of the structured check, regardless of level. Prefer the new
321
+ // `getWindowsArgvPromptCheck` for callers that need to distinguish warning
322
+ // from error.
323
+ export async function getWindowsArgvPromptWarning(args) {
324
+ const check = await getWindowsArgvPromptCheck(args);
325
+ return check ? check.message : null;
326
+ }
327
+ function commandExists(command) {
328
+ return new Promise((resolve) => {
329
+ const lookup = process.platform === 'win32' ? 'where' : 'which';
330
+ const child = spawn(lookup, [command], { stdio: 'ignore' });
331
+ child.on('error', () => resolve(false));
332
+ child.on('close', (code) => resolve(code === 0));
333
+ });
334
+ }
335
+ export async function runPreflightChecks({ pipeline, cwd, inputDefs, inputValues, dryRun, securityConfig, }) {
336
+ const errors = [];
337
+ const warnings = [];
338
+ const infos = [];
339
+ const securityHighlights = [];
340
+ const stepAgents = new Map();
341
+ const availablePipeOutputs = new Set();
342
+ if (securityConfig) {
343
+ const relConfig = path.relative(cwd, securityConfig.file);
344
+ infos.push(`Project security config: ${relConfig} ${G.bullet} default_profile "${securityConfig.defaultProfile}".`);
345
+ }
346
+ for (const def of inputDefs) {
347
+ const value = inputValues[def.id];
348
+ if (!dryRun && !String(value || '').trim()) {
349
+ errors.push(`Missing input "${def.id}".`);
350
+ continue;
351
+ }
352
+ if (!dryRun && def.subtype === 'file' && String(value || '').trim()) {
353
+ const files = await resolveFileGlob(`$FILE:${value}`, cwd);
354
+ if (files.length === 0) {
355
+ errors.push(`Input file "${def.id}" does not resolve to any file: ${value}`);
356
+ }
357
+ }
358
+ }
359
+ const parsedAgents = [];
360
+ for (let i = 0; i < pipeline.steps.length; i += 1) {
361
+ const step = pipeline.steps[i];
362
+ const label = `Step ${i + 1} "${step.agent}"`;
363
+ if (!step.agent_file) {
364
+ errors.push(`${label} is missing agent_file.`);
365
+ continue;
366
+ }
367
+ const agentFilePath = path.isAbsolute(step.agent_file)
368
+ ? step.agent_file
369
+ : path.resolve(cwd, step.agent_file);
370
+ let raw;
371
+ try {
372
+ raw = await fs.readFile(agentFilePath, 'utf8');
373
+ }
374
+ catch {
375
+ errors.push(`${label} agent file not found: ${step.agent_file}`);
376
+ continue;
377
+ }
378
+ const { agent, error } = parseAgentFileDetailed(raw, agentFilePath);
379
+ if (!agent) {
380
+ errors.push(`${label} agent file is invalid: ${step.agent_file}${error ? ` (${error})` : ''}`);
381
+ continue;
382
+ }
383
+ parsedAgents.push({ step, agent });
384
+ stepAgents.set(step.agent, agent);
385
+ const securityPolicy = resolveSecurityPolicyWithConfig(step, agent, securityConfig ?? undefined);
386
+ for (const error of validateSecurityPolicy(securityPolicy)) {
387
+ errors.push(`${label} ${error}.`);
388
+ }
389
+ let provider;
390
+ try {
391
+ provider = resolveProvider(step, agent);
392
+ getRunner(provider);
393
+ }
394
+ catch (err) {
395
+ errors.push(`${label} uses unknown provider "${step.provider || agent.provider || ''}".`);
396
+ continue;
397
+ }
398
+ const model = resolveModel(step, agent);
399
+ if (!model)
400
+ warnings.push(`${label} has no model configured for provider "${provider}".`);
401
+ const runnerAgent = resolveRunnerAgent(step, agent);
402
+ if (provider === 'copilot' && !runnerAgent) {
403
+ warnings.push(`${label} uses provider "copilot" without runner_agent; Copilot will use its default agent.`);
404
+ }
405
+ if (provider === 'opencode' && !runnerAgent) {
406
+ warnings.push(`${label} uses provider "opencode" without runner_agent; OpenCode will use its default agent.`);
407
+ }
408
+ const permissionMode = resolvePermissionMode(step, agent);
409
+ if (provider === 'claude' && permissionMode && permissionMode !== 'bypassPermissions') {
410
+ errors.push(`${label} uses unsupported Claude permission_mode "${permissionMode}".`);
411
+ }
412
+ if (provider !== 'claude' && permissionMode) {
413
+ warnings.push(`${label} defines permission_mode "${permissionMode}", but provider "${provider}" ignores it.`);
414
+ }
415
+ if (provider === 'claude' && permissionMode === 'bypassPermissions') {
416
+ infos.push(`${label} runs Claude with permission_mode "${permissionMode}".`);
417
+ }
418
+ if (provider === 'claude' && !permissionMode) {
419
+ if (securityPolicy.profile === 'read-only') {
420
+ infos.push(`${label} runs Claude in read-only mode (Write/Edit/Bash disabled via --disallowedTools).`);
421
+ }
422
+ else if (securityPolicy.profile === 'restricted-write') {
423
+ warnings.push(`${label} uses Claude with security_profile "restricted-write"; Claude has no per-path tool filter, so Singleton relies on its post-run snapshot diff to reject writes outside allowed_paths.`);
424
+ }
425
+ else if (securityPolicy.profile === 'dangerous') {
426
+ warnings.push(`${label} uses Claude with security_profile "dangerous"; Singleton will pass --permission-mode bypassPermissions.`);
427
+ }
428
+ }
429
+ if (provider === 'codex') {
430
+ if (securityPolicy.profile === 'read-only') {
431
+ infos.push(`${label} runs Codex in --sandbox read-only.`);
432
+ }
433
+ else if (securityPolicy.profile === 'restricted-write') {
434
+ warnings.push(`${label} uses Codex with security_profile "restricted-write"; Codex has no per-path sandbox filter, so Singleton relies on its post-run snapshot diff to reject writes outside allowed_paths.`);
435
+ }
436
+ else if (securityPolicy.profile === 'workspace-write') {
437
+ infos.push(`${label} runs Codex in --sandbox workspace-write.`);
438
+ }
439
+ else if (securityPolicy.profile === 'dangerous') {
440
+ warnings.push(`${label} uses Codex with security_profile "dangerous"; Singleton will pass --sandbox danger-full-access.`);
441
+ }
442
+ }
443
+ if (provider === 'copilot' && runnerAgent) {
444
+ infos.push(`${label} runs Copilot with runner_agent "${runnerAgent}".`);
445
+ const repoAgentProfile = await findCopilotRepoAgentProfile(cwd, runnerAgent);
446
+ if (repoAgentProfile?.file && !repoAgentProfile.notVisibleFromGitRoot) {
447
+ infos.push(`${label} Copilot repo agent profile: ${path.relative(cwd, repoAgentProfile.file)}.`);
448
+ }
449
+ else if (repoAgentProfile?.file && repoAgentProfile.notVisibleFromGitRoot) {
450
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" exists at ${path.relative(cwd, repoAgentProfile.file)}, but Copilot will use git root ${repoAgentProfile.projectRoot}. Move the profile to ${path.relative(cwd, path.join(repoAgentProfile.projectRoot, '.github', 'agents'))} or run inside a standalone git repo.`);
451
+ }
452
+ else {
453
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" was not found in .github/agents; Copilot may still resolve a user-level or organization-level agent.`);
454
+ }
455
+ if (repoAgentProfile?.file) {
456
+ const toolValidation = validateCopilotAgentTools({
457
+ label,
458
+ runnerAgent,
459
+ securityPolicy,
460
+ tools: repoAgentProfile.tools || [],
461
+ });
462
+ errors.push(...toolValidation.errors);
463
+ warnings.push(...toolValidation.warnings);
464
+ }
465
+ }
466
+ if (provider === 'opencode') {
467
+ const opencodeRuntime = [
468
+ model ? `model "${model}"` : null,
469
+ runnerAgent ? `runner_agent "${runnerAgent}"` : 'default agent',
470
+ ].filter(Boolean).join(` ${G.bullet} `);
471
+ infos.push(`${label} runs OpenCode${opencodeRuntime ? ` with ${opencodeRuntime}` : ''}.`);
472
+ if (runnerAgent) {
473
+ const projectAgentProfile = await findOpenCodeProjectAgentProfile(cwd, runnerAgent);
474
+ if (projectAgentProfile?.file) {
475
+ infos.push(`${label} OpenCode project agent profile: ${path.relative(cwd, projectAgentProfile.file)}.`);
476
+ const toolValidation = validateOpenCodeAgentTools({
477
+ label,
478
+ runnerAgent,
479
+ securityPolicy,
480
+ tools: projectAgentProfile.tools || {},
481
+ });
482
+ errors.push(...toolValidation.errors);
483
+ warnings.push(...toolValidation.warnings);
484
+ }
485
+ else if (projectAgentProfile === null) {
486
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" cannot be validated as a local project agent name.`);
487
+ }
488
+ else {
489
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" was not found in .opencode/agents; OpenCode may still resolve a user-level agent.`);
490
+ }
491
+ }
492
+ if (securityPolicy.profile === 'dangerous') {
493
+ warnings.push(`${label} uses provider "opencode" with security_profile "dangerous"; Singleton will pass --dangerously-skip-permissions.`);
494
+ }
495
+ else if (securityPolicy.profile !== 'restricted-write') {
496
+ warnings.push(`${label} uses experimental provider "opencode"; Singleton enforces the security policy with write-time and post-run validation.`);
497
+ }
498
+ }
499
+ if (shouldHighlightSecurity({ provider, permissionMode, securityPolicy })) {
500
+ securityHighlights.push(formatSecurityHighlight({ label, provider, permissionMode, securityPolicy }));
501
+ }
502
+ const argvCheck = await getWindowsArgvPromptCheck({
503
+ label,
504
+ provider,
505
+ runnerAgent,
506
+ step,
507
+ agent,
508
+ cwd,
509
+ inputValues,
510
+ inputDefs,
511
+ securityPolicy,
512
+ });
513
+ if (argvCheck) {
514
+ if (argvCheck.level === 'error')
515
+ errors.push(argvCheck.message);
516
+ else
517
+ warnings.push(argvCheck.message);
518
+ }
519
+ for (const [name, spec] of Object.entries(step.inputs || {})) {
520
+ if (typeof spec !== 'string')
521
+ continue;
522
+ if (spec.startsWith('$INPUT:')) {
523
+ const id = spec.slice('$INPUT:'.length).trim();
524
+ if (!inputDefs.some((def) => def.id === id)) {
525
+ errors.push(`${label} input "${name}" references unknown $INPUT:${id}.`);
526
+ }
527
+ }
528
+ else if (spec.startsWith('$PIPE:')) {
529
+ const { ref, agentId, outName } = parsePipeRef(spec);
530
+ if (!stepAgents.has(agentId)) {
531
+ errors.push(`${label} input "${name}" references future or unknown $PIPE:${ref}.`);
532
+ }
533
+ else if (outName && !availablePipeOutputs.has(`${agentId}.${outName}`)) {
534
+ errors.push(`${label} input "${name}" references missing $PIPE output: ${ref}.`);
535
+ }
536
+ }
537
+ else if (spec.startsWith('$FILE:')) {
538
+ const files = await resolveFileGlob(spec, cwd);
539
+ if (files.length === 0) {
540
+ errors.push(`${label} input "${name}" matched no files for ${spec}.`);
541
+ }
542
+ }
543
+ }
544
+ for (const [outputName, rawSink] of Object.entries(step.outputs || {})) {
545
+ availablePipeOutputs.add(`${step.agent}.${outputName}`);
546
+ if (typeof rawSink !== 'string')
547
+ continue;
548
+ if (!rawSink.startsWith('$FILE:') && !rawSink.startsWith('$FILES:'))
549
+ continue;
550
+ let sink = rawSink;
551
+ for (const [id, val] of Object.entries(inputValues)) {
552
+ sink = sink.replaceAll(`$INPUT:${id}`, String(val));
553
+ }
554
+ const prefix = sink.startsWith('$FILE:') ? '$FILE:' : '$FILES:';
555
+ const rawPath = sink.slice(prefix.length).trim();
556
+ const absOut = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
557
+ if (isSingletonInternalPath(absOut, cwd))
558
+ continue;
559
+ try {
560
+ assertWriteAllowed(absOut, {
561
+ root: cwd,
562
+ agentName: step.agent,
563
+ outputName,
564
+ policy: securityPolicy,
565
+ });
566
+ }
567
+ catch (err) {
568
+ errors.push(err instanceof Error ? err.message : String(err));
569
+ }
570
+ }
571
+ }
572
+ const usedProviders = [...new Set(parsedAgents.map(({ step, agent }) => resolveProvider(step, agent)))];
573
+ if (dryRun) {
574
+ if (usedProviders.length) {
575
+ infos.push(`Dry-run: skipped provider CLI binary checks (${usedProviders.join(', ')}).`);
576
+ }
577
+ }
578
+ else {
579
+ for (const provider of usedProviders) {
580
+ try {
581
+ const runner = getRunner(provider);
582
+ if (runner.command) {
583
+ const exists = await commandExists(runner.command);
584
+ if (!exists)
585
+ errors.push(`Provider "${provider}" requires missing CLI binary: ${runner.command}`);
586
+ }
587
+ }
588
+ catch {
589
+ // already captured above
590
+ }
591
+ }
592
+ }
593
+ if (usedProviders.includes('codex')) {
594
+ const projectInstructions = await discoverCodexProjectInstructions(cwd, cwd);
595
+ infos.push(`Codex project instructions: ${projectInstructions.files.length} file${projectInstructions.files.length !== 1 ? 's' : ''} detected.`);
596
+ }
597
+ return {
598
+ ok: errors.length === 0,
599
+ errors,
600
+ warnings,
601
+ infos,
602
+ securityHighlights,
603
+ providerCount: usedProviders.length,
604
+ };
605
+ }