diff --git a/src/orchestrator.js b/src/orchestrator.js index bdf2fd19..3be3bdd1 100644 --- a/src/orchestrator.js +++ b/src/orchestrator.js @@ -3462,7 +3462,7 @@ Continue from where you left off. Review your previous output to understand what // Phase 2: Build mock cluster config with proposed agents // Collect all agents that would exist after operations complete - const existingAgentConfigs = cluster.config.agents || []; + const existingAgentConfigs = this._prepareExistingAgentConfigsForValidation(cluster); const proposedAgentConfigs = this._buildProposedAgentConfigs(existingAgentConfigs, operations); // Phase 3: Validate proposed cluster config @@ -3532,16 +3532,22 @@ Continue from where you left off. Review your previous output to understand what if (op.action === 'add_agents' && op.agents) { for (const agentConfig of op.agents) { const existingIdx = proposedAgentConfigs.findIndex((a) => a.id === agentConfig.id); + const proposedAgentConfig = JSON.parse(JSON.stringify(agentConfig)); if (existingIdx === -1) { - proposedAgentConfigs.push(agentConfig); + proposedAgentConfigs.push(proposedAgentConfig); + } else { + proposedAgentConfigs[existingIdx] = proposedAgentConfig; } } } else if (op.action === 'load_config' && op.config) { const loadedAgentConfigs = this._resolveLoadConfigAgents(op.config); for (const agentConfig of loadedAgentConfigs) { const existingIdx = proposedAgentConfigs.findIndex((a) => a.id === agentConfig.id); + const proposedAgentConfig = JSON.parse(JSON.stringify(agentConfig)); if (existingIdx === -1) { - proposedAgentConfigs.push(agentConfig); + proposedAgentConfigs.push(proposedAgentConfig); + } else { + proposedAgentConfigs[existingIdx] = proposedAgentConfig; } } } else if (op.action === 'remove_agents' && op.agentIds) { @@ -3562,38 +3568,42 @@ Continue from where you left off. Review your previous output to understand what return proposedAgentConfigs; } + _prepareExistingAgentConfigsForValidation(cluster) { + const existingAgentConfigs = JSON.parse(JSON.stringify(cluster?.config?.agents || [])); + + // The CLI validates --model before startup, then materializes it into every + // initial agent config and persists the override separately on the cluster. + // Only those already-accepted configs have that provenance. Strip their + // runtime model projection before validating a later operation chain; new + // add/load/update payloads stay untouched and must satisfy the authored + // modelLevel-only policy even when their value equals the active override. + if (cluster?.modelOverride) { + for (const agentConfig of existingAgentConfigs) { + delete agentConfig.model; + } + } + + return existingAgentConfigs; + } + _resolveLoadConfigAgents(config) { + return this._resolveLoadConfig(config).loadedConfig.agents; + } + + _resolveLoadConfig(config) { if (!config) { throw new Error('load_config operation missing config'); } const templatesDir = path.join(__dirname, '..', 'cluster-templates'); - let loadedConfig; - - // Parameterized template - resolve with TemplateResolver - if (typeof config === 'object' && config.base) { - const { base, params } = config; - const resolver = new TemplateResolver(templatesDir); - loadedConfig = resolver.resolve(base, params || {}); - } else if (typeof config === 'string') { - // Static config - load directly from file - const configPath = path.join(templatesDir, `${config}.json`); - if (!fs.existsSync(configPath)) { - throw new Error(`Config not found: ${config} (looked in ${configPath})`); - } - const configContent = fs.readFileSync(configPath, 'utf8'); - loadedConfig = JSON.parse(configContent); - } else { - throw new Error( - `Invalid config format: expected string or {base, params}, got ${typeof config}` - ); - } + const resolver = new TemplateResolver(templatesDir); + const resolvedConfig = resolver.resolveConfigReference(config); - if (!loadedConfig.agents || !Array.isArray(loadedConfig.agents)) { + if (!resolvedConfig.loadedConfig.agents || !Array.isArray(resolvedConfig.loadedConfig.agents)) { throw new Error(`Config has no agents array`); } - return loadedConfig.agents; + return resolvedConfig; } _hasCompletionHandler(agentConfigs) { @@ -3954,44 +3964,17 @@ Continue from where you left off. Review your previous output to understand what */ async _opLoadConfig(cluster, op, context) { const { config } = op; - if (!config) { - throw new Error('load_config operation missing config'); - } + const resolvedConfig = this._resolveLoadConfig(config); + const loadedConfig = resolvedConfig.loadedConfig; - const templatesDir = path.join(__dirname, '..', 'cluster-templates'); - let loadedConfig; - - // Check if config is parameterized ({ base, params }) or static (string) - if (typeof config === 'object' && config.base) { - // Parameterized template - resolve with TemplateResolver - const { base, params } = config; - this._log(` Loading parameterized template: ${base}`); - this._log(` Params: ${JSON.stringify(params)}`); - - const resolver = new TemplateResolver(templatesDir); - loadedConfig = resolver.resolve(base, params); - - this._log(` ✓ Resolved template: ${base} → ${loadedConfig.agents?.length || 0} agent(s)`); - } else if (typeof config === 'string') { - // Static config - load directly from file - const configPath = path.join(templatesDir, `${config}.json`); - - if (!fs.existsSync(configPath)) { - throw new Error(`Config not found: ${config} (looked in ${configPath})`); - } - - this._log(` Loading static config: ${config}`); - - const configContent = fs.readFileSync(configPath, 'utf8'); - loadedConfig = JSON.parse(configContent); - } else { - throw new Error( - `Invalid config format: expected string or {base, params}, got ${typeof config}` + if (resolvedConfig.kind === 'parameterized') { + this._log(` Loading parameterized template: ${resolvedConfig.name}`); + this._log(` Params: ${JSON.stringify(resolvedConfig.params)}`); + this._log( + ` ✓ Resolved template: ${resolvedConfig.name} → ${loadedConfig.agents.length} agent(s)` ); - } - - if (!loadedConfig.agents || !Array.isArray(loadedConfig.agents)) { - throw new Error(`Config has no agents array`); + } else { + this._log(` Loading static config: ${resolvedConfig.name}`); } this._log(` Found ${loadedConfig.agents.length} agent(s)`); diff --git a/src/template-resolver.js b/src/template-resolver.js index 64d4ea7d..7515b8fc 100644 --- a/src/template-resolver.js +++ b/src/template-resolver.js @@ -16,6 +16,32 @@ const fs = require('fs'); const path = require('path'); const COMPARISON_OPERATORS = ['==', '!=', '<=', '>=', '<', '>']; +const TEMPLATE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +function invalidTemplateNameError(label, name) { + return new Error( + `Invalid ${label} name ${JSON.stringify(name)}: use only letters, numbers, hyphens, and underscores` + ); +} + +function resolveNamedTemplatePath(directory, name, label) { + if (typeof name !== 'string' || !TEMPLATE_NAME_PATTERN.test(name)) { + throw invalidTemplateNameError(label, name); + } + + const templateRoot = path.resolve(directory); + const templatePath = path.resolve(templateRoot, `${name}.json`); + const relativePath = path.relative(templateRoot, templatePath); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw invalidTemplateNameError(label, name); + } + + return templatePath; +} function isIdentifierChar(char) { if (!char) return false; @@ -97,6 +123,49 @@ class TemplateResolver { this.baseTemplatesDir = path.join(templatesDir, 'base-templates'); } + /** + * Load a static or parameterized cluster config from a safe named reference. + * Names are identifiers, never filesystem paths. + * + * @param {string | {base: string, params?: Object}} config + * @returns {{kind: 'static' | 'parameterized', name: string, params: Object | null, loadedConfig: Object}} + */ + resolveConfigReference(config) { + if (typeof config === 'string') { + const configPath = resolveNamedTemplatePath(this.templatesDir, config, 'config'); + if (!fs.existsSync(configPath)) { + throw new Error(`Config not found: ${config} (looked in ${configPath})`); + } + + return { + kind: 'static', + name: config, + params: null, + loadedConfig: JSON.parse(fs.readFileSync(configPath, 'utf8')), + }; + } + + if ( + config && + typeof config === 'object' && + !Array.isArray(config) && + Object.prototype.hasOwnProperty.call(config, 'base') + ) { + const { base, params } = config; + const resolvedParams = params || {}; + return { + kind: 'parameterized', + name: base, + params: resolvedParams, + loadedConfig: this.resolve(base, resolvedParams), + }; + } + + throw new Error( + `Invalid config format: expected string or {base, params}, got ${typeof config}` + ); + } + /** * Resolve a template with parameters * @param {string} baseName - Name of base template (without .json) @@ -105,7 +174,7 @@ class TemplateResolver { */ resolve(baseName, params) { // Load base template - const templatePath = path.join(this.baseTemplatesDir, `${baseName}.json`); + const templatePath = resolveNamedTemplatePath(this.baseTemplatesDir, baseName, 'base template'); if (!fs.existsSync(templatePath)) { throw new Error(`Base template not found: ${baseName} (looked in ${templatePath})`); } @@ -409,7 +478,7 @@ class TemplateResolver { * @returns {any} */ getTemplateInfo(baseName) { - const templatePath = path.join(this.baseTemplatesDir, `${baseName}.json`); + const templatePath = resolveNamedTemplatePath(this.baseTemplatesDir, baseName, 'base template'); if (!fs.existsSync(templatePath)) { return null; } diff --git a/src/template-validation/simulate-random-topology.js b/src/template-validation/simulate-random-topology.js index a50c22d8..3dde24de 100644 --- a/src/template-validation/simulate-random-topology.js +++ b/src/template-validation/simulate-random-topology.js @@ -1,6 +1,3 @@ -const fs = require('node:fs'); -const path = require('node:path'); - const Ledger = require('../ledger'); const MessageBus = require('../message-bus'); const LogicEngine = require('../logic-engine'); @@ -227,16 +224,8 @@ function parseOperations(raw) { } function resolveConfigOperation({ configOp, templatesDir }) { - if (typeof configOp === 'object' && configOp?.base) { - const resolver = new TemplateResolver(templatesDir); - return resolver.resolve(configOp.base, configOp.params || {}); - } - if (typeof configOp === 'string') { - const configPath = path.join(templatesDir, `${configOp}.json`); - const configContent = fs.readFileSync(configPath, 'utf8'); - return JSON.parse(configContent); - } - throw new Error(`Unsupported load_config payload: ${JSON.stringify(configOp)}`); + const resolver = new TemplateResolver(templatesDir); + return resolver.resolveConfigReference(configOp).loadedConfig; } function applyClusterOperation({ state, messageBus, operation, sourceMessage, templatesDir }) { diff --git a/tests/e2e/cluster-lifecycle.test.js b/tests/e2e/cluster-lifecycle.test.js index dd4ba099..0e9dee1e 100644 --- a/tests/e2e/cluster-lifecycle.test.js +++ b/tests/e2e/cluster-lifecycle.test.js @@ -113,4 +113,76 @@ describe('e2e: cluster lifecycle (daemon mode + observability commands)', functi fs.rmSync(issueDir, { recursive: true, force: true }); }); + + it('preserves a CLI model override through detached conductor load_config', async function () { + const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-')); + const issuePath = path.join(issueDir, 'model-override.md'); + const scenarioFile = path.join(issueDir, 'conductor-trivial-task.json'); + let clusterId = null; + fs.writeFileSync(issuePath, '# Model override\n\nExercise dynamic config loading.\n'); + fs.writeFileSync( + scenarioFile, + JSON.stringify({ + messages: [ + JSON.stringify({ + complexity: 'TRIVIAL', + taskType: 'TASK', + reasoning: 'Use the single-worker route.', + }), + ], + exitCode: 0, + }) + ); + + try { + const configPath = path.join( + __dirname, + '..', + '..', + 'cluster-templates', + 'conductor-bootstrap.json' + ); + const startResult = runZeroshot( + env, + ['run', issuePath, '-d', '--config', configPath, '--model', 'sonnet'], + { + FAKE_AGENT_SCENARIO: scenarioFile, + timeout: 15000, + } + ); + assert.strictEqual( + startResult.status, + 0, + `zeroshot run -d exited ${startResult.status}\nSTDOUT:\n${startResult.stdout}\nSTDERR:\n${startResult.stderr}` + ); + + const clusterIdMatch = /Started (\S+)/.exec(startResult.stdout); + assert.ok(clusterIdMatch, `expected "Started " in stdout, got:\n${startResult.stdout}`); + clusterId = clusterIdMatch[1]; + const cluster = await pollUntil(() => { + const current = readCluster(env, clusterId); + return current?.state === 'stopped' ? current : null; + }, 30000); + + assert.strictEqual(cluster.modelOverride, 'sonnet'); + assert.ok( + cluster.config.agents.some((agent) => agent.id === 'worker'), + 'detached conductor should load the worker config' + ); + for (const agent of cluster.config.agents) { + assert.strictEqual( + agent.model, + 'sonnet', + `persisted agent ${agent.id} should retain the CLI model override` + ); + } + assert.strictEqual(cluster.failureInfo, null); + } finally { + const cluster = clusterId ? readCluster(env, clusterId) : null; + if (cluster && !['stopped', 'killed'].includes(cluster.state)) { + runZeroshot(env, ['kill', clusterId], { timeout: 15000 }); + } + fs.rmSync(issueDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/load-config-path-security.test.js b/tests/load-config-path-security.test.js new file mode 100644 index 00000000..33935fe9 --- /dev/null +++ b/tests/load-config-path-security.test.js @@ -0,0 +1,84 @@ +const assert = require('assert'); +const path = require('path'); + +const Orchestrator = require('../src/orchestrator'); +const TemplateResolver = require('../src/template-resolver'); + +const UNSAFE_STATIC_CONFIG_NAMES = [ + ['traversal', '../package'], + ['backslash traversal', String.raw`..\package`], + ['absolute path', path.resolve(__dirname, '..', 'package')], + ['forward-slash nested path', 'nested/config'], + ['backslash nested path', String.raw`nested\config`], + ['Windows drive path', String.raw`C:\outside`], + ['UNC path', String.raw`\\server\share`], +]; + +const UNSAFE_BASE_TEMPLATE_NAMES = [ + ['traversal', '../../package'], + ['backslash traversal', String.raw`..\package`], + ['absolute path', path.resolve(__dirname, '..', 'package')], + ['forward-slash nested path', 'nested/template'], + ['backslash nested path', String.raw`nested\template`], + ['Windows drive path', String.raw`C:\outside`], + ['UNC path', String.raw`\\server\share`], +]; + +describe('load_config path security', function () { + let orchestrator; + + beforeEach(function () { + orchestrator = new Orchestrator({ quiet: true, skipLoad: true }); + }); + + afterEach(function () { + orchestrator.close(); + }); + + for (const [description, configName] of UNSAFE_STATIC_CONFIG_NAMES) { + it(`should reject a static ${description} during validation and execution`, async function () { + assert.throws(() => orchestrator._resolveLoadConfigAgents(configName), /Invalid config name/); + await assert.rejects( + orchestrator._opLoadConfig({}, { config: configName }, {}), + /Invalid config name/ + ); + }); + } + + for (const [description, base] of UNSAFE_BASE_TEMPLATE_NAMES) { + it(`should reject a parameterized ${description} during validation and execution`, async function () { + const config = { base, params: {} }; + assert.throws( + () => orchestrator._resolveLoadConfigAgents(config), + /Invalid base template name/ + ); + await assert.rejects( + orchestrator._opLoadConfig({}, { config }, {}), + /Invalid base template name/ + ); + }); + } + + it('preserves the missing agents error after shared resolution', async function () { + const originalResolve = TemplateResolver.prototype.resolveConfigReference; + TemplateResolver.prototype.resolveConfigReference = () => ({ + kind: 'static', + name: 'no-agents', + params: null, + loadedConfig: {}, + }); + + try { + assert.throws( + () => orchestrator._resolveLoadConfigAgents('no-agents'), + /Config has no agents array/ + ); + await assert.rejects( + orchestrator._opLoadConfig({}, { config: 'no-agents' }, {}), + /Config has no agents array/ + ); + } finally { + TemplateResolver.prototype.resolveConfigReference = originalResolve; + } + }); +}); diff --git a/tests/model-override.test.js b/tests/model-override.test.js index 08eb3f9a..5212d8d0 100644 --- a/tests/model-override.test.js +++ b/tests/model-override.test.js @@ -6,6 +6,17 @@ const { applyModelOverrideToConfig } = require('../cli/index'); const Orchestrator = require('../src/orchestrator'); const MockTaskRunner = require('./helpers/mock-task-runner'); +const CUSTOM_CODEX_MODEL = 'gpt-5.6-sol'; + +async function waitFor(predicate, timeoutMs = 5000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Condition not met within ${timeoutMs}ms`); +} + describe('Model Override (--model flag)', () => { let orchestrator; let storageDir; @@ -142,6 +153,201 @@ describe('Model Override (--model flag)', () => { ); }); + it('should carry a catalogued CLI model through conductor load_config', async function () { + const config = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'cluster-templates', 'conductor-bootstrap.json')) + ); + config.defaultProvider = 'codex'; + + orchestrator.taskRunner.when('junior-conductor').withModel(CUSTOM_CODEX_MODEL).returns({ + complexity: 'TRIVIAL', + taskType: 'TASK', + reasoning: 'Exercise the single-worker load_config path.', + }); + orchestrator.taskRunner.when('worker').withModel(CUSTOM_CODEX_MODEL).returns({ + summary: 'completed', + result: 'ok', + }); + + const result = await orchestrator.start( + config, + { text: 'test task' }, + { modelOverride: CUSTOM_CODEX_MODEL } + ); + const cluster = orchestrator.clusters.get(result.id); + + await waitFor(() => cluster.agents.some((agent) => agent.id === 'worker')); + + const loadedAgents = cluster.agents.filter( + (agent) => !['junior-conductor', 'senior-conductor'].includes(agent.id) + ); + assert.ok(loadedAgents.length > 0, 'load_config should add runtime agents'); + for (const agent of loadedAgents) { + assert.strictEqual( + agent._selectModel(), + CUSTOM_CODEX_MODEL, + `Dynamically loaded agent ${agent.id} should use the CLI model override` + ); + } + }); + + it('should reject an authored raw model matching the active CLI override', async function () { + const result = await orchestrator.start( + { + defaultProvider: 'codex', + agents: [ + { + id: 'conductor', + modelLevel: 'level2', + role: 'conductor', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + { text: 'test task' }, + { modelOverride: CUSTOM_CODEX_MODEL } + ); + + await assert.rejects( + orchestrator._handleOperations( + result.id, + [ + { + action: 'add_agents', + agents: [ + { + id: 'worker', + model: CUSTOM_CODEX_MODEL, + role: 'implementation', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + ], + 'conductor' + ), + /uses 'model: "gpt-5.6-sol"'/ + ); + }); + + for (const authoredModel of [CUSTOM_CODEX_MODEL, 'gpt-5.4']) { + it(`should validate a same-ID add_agents replacement with authored model ${authoredModel}`, async function () { + const result = await orchestrator.start( + { + defaultProvider: 'codex', + agents: [ + { + id: 'conductor', + modelLevel: 'level2', + role: 'conductor', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + { text: 'test task' }, + { modelOverride: CUSTOM_CODEX_MODEL } + ); + + await assert.rejects( + orchestrator._handleOperations( + result.id, + [ + { + action: 'add_agents', + agents: [ + { + id: 'conductor', + model: authoredModel, + role: 'conductor', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + ], + 'conductor' + ), + (error) => error.message.includes(`uses 'model: "${authoredModel}"'`) + ); + }); + } + + it('should validate a same-ID load_config replacement before execution', async function () { + const result = await orchestrator.start( + { + defaultProvider: 'codex', + agents: [ + { + id: 'conductor', + modelLevel: 'level2', + role: 'conductor', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + { text: 'test task' }, + { modelOverride: CUSTOM_CODEX_MODEL } + ); + const originalResolveLoadConfigAgents = orchestrator._resolveLoadConfigAgents; + orchestrator._resolveLoadConfigAgents = () => [ + { + id: 'conductor', + model: CUSTOM_CODEX_MODEL, + role: 'conductor', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ]; + + try { + await assert.rejects( + orchestrator._handleOperations( + result.id, + [{ action: 'load_config', config: 'test-config' }], + 'conductor' + ), + /uses 'model: "gpt-5.6-sol"'/ + ); + } finally { + orchestrator._resolveLoadConfigAgents = originalResolveLoadConfigAgents; + } + }); + + it('should reject authored raw models when no CLI override provenance exists', async function () { + const result = await orchestrator.start( + { + agents: [ + { + id: 'conductor', + modelLevel: 'level2', + role: 'conductor', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + { text: 'test task' } + ); + + await assert.rejects( + orchestrator._handleOperations( + result.id, + [ + { + action: 'add_agents', + agents: [ + { + id: 'worker', + model: 'gpt-5.6-sol', + role: 'implementation', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ], + }, + ], + 'conductor' + ), + /uses 'model: "gpt-5.6-sol"'/ + ); + }); + it('should not override models when modelOverride is not provided', async function () { const config = { agents: [ diff --git a/tests/template-resolver.test.js b/tests/template-resolver.test.js index 85df7891..06e0dc83 100644 --- a/tests/template-resolver.test.js +++ b/tests/template-resolver.test.js @@ -3,6 +3,8 @@ */ const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); const TemplateResolver = require('../src/template-resolver'); const { DEFAULT_MAX_ITERATIONS } = require('../src/agent/agent-config'); @@ -161,6 +163,138 @@ describe('TemplateResolver', function () { }); }); +describe('TemplateResolver config references', function () { + let tempDir; + let templatesDir; + let resolver; + + beforeEach(function () { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-template-reference-')); + templatesDir = path.join(tempDir, 'cluster-templates'); + fs.mkdirSync(path.join(templatesDir, 'base-templates'), { recursive: true }); + + fs.writeFileSync( + path.join(templatesDir, 'safe-config_1.json'), + JSON.stringify({ agents: [{ id: 'static-agent' }] }) + ); + fs.writeFileSync( + path.join(templatesDir, 'no-agents.json'), + JSON.stringify({ name: 'no agents' }) + ); + fs.writeFileSync( + path.join(templatesDir, 'base-templates', 'safe-base_template.json'), + JSON.stringify({ agents: [{ id: 'parameterized-agent' }] }) + ); + + // These files prove traversal would reach valid JSON rather than merely + // failing because the escaped target happens not to exist. + fs.writeFileSync( + path.join(tempDir, 'outside-static.json'), + JSON.stringify({ agents: [{ id: 'outside-static' }] }) + ); + fs.writeFileSync( + path.join(templatesDir, 'outside-base.json'), + JSON.stringify({ agents: [{ id: 'outside-base' }] }) + ); + + resolver = new TemplateResolver(templatesDir); + }); + + afterEach(function () { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should load static and parameterized names containing hyphens and underscores', function () { + const staticReference = resolver.resolveConfigReference('safe-config_1'); + assert.strictEqual(staticReference.kind, 'static'); + assert.strictEqual(staticReference.loadedConfig.agents[0].id, 'static-agent'); + + const parameterizedReference = resolver.resolveConfigReference({ + base: 'safe-base_template', + params: {}, + }); + assert.strictEqual(parameterizedReference.kind, 'parameterized'); + assert.strictEqual(parameterizedReference.loadedConfig.agents[0].id, 'parameterized-agent'); + }); + + it('should preserve not-found and config-shape behavior for safe names', function () { + assert.throws( + () => resolver.resolveConfigReference('does-not-exist'), + /Config not found: does-not-exist/ + ); + assert.throws( + () => resolver.resolveConfigReference({ base: 'does-not-exist', params: {} }), + /Base template not found: does-not-exist/ + ); + + const noAgentsReference = resolver.resolveConfigReference('no-agents'); + assert.deepStrictEqual(noAgentsReference.loadedConfig, { name: 'no agents' }); + }); + + for (const invalidConfig of [null, {}, { params: {} }, [], 42]) { + it(`should preserve invalid config format for ${JSON.stringify(invalidConfig)}`, function () { + assert.throws( + () => resolver.resolveConfigReference(invalidConfig), + /Invalid config format: expected string or \{base, params\}/ + ); + }); + } + + for (const unsafeName of [ + '../outside-static', + String.raw`..\outside-static`, + path.resolve('/tmp', 'outside-static'), + 'nested/config', + String.raw`nested\config`, + String.raw`C:\outside-static`, + String.raw`\\server\share`, + '', + '.', + '..', + 'name.json', + 'name:config', + 'name config', + ]) { + it(`should reject unsafe static config name ${JSON.stringify(unsafeName)}`, function () { + assert.throws( + () => resolver.resolveConfigReference(unsafeName), + /Invalid config name.*letters, numbers, hyphens, and underscores/ + ); + }); + } + + for (const unsafeName of [ + '../outside-base', + String.raw`..\outside-base`, + path.resolve('/tmp', 'outside-base'), + 'nested/template', + String.raw`nested\template`, + String.raw`C:\outside-base`, + String.raw`\\server\share`, + '', + '.', + '..', + 'name.json', + 'name:template', + 'name template', + ]) { + it(`should reject unsafe parameterized base name ${JSON.stringify(unsafeName)}`, function () { + assert.throws( + () => resolver.resolveConfigReference({ base: unsafeName, params: {} }), + /Invalid base template name.*letters, numbers, hyphens, and underscores/ + ); + }); + } + + it('should reject unsafe names through direct base-template APIs', function () { + assert.throws(() => resolver.resolve('../outside-base', {}), /Invalid base template name/); + assert.throws( + () => resolver.getTemplateInfo(String.raw`..\outside-base`), + /Invalid base template name/ + ); + }); +}); + describe('2D Classification Routing', function () { let resolver; diff --git a/tests/unit/simulate-random-topology.test.js b/tests/unit/simulate-random-topology.test.js index 088ff1b3..3a3507a1 100644 --- a/tests/unit/simulate-random-topology.test.js +++ b/tests/unit/simulate-random-topology.test.js @@ -5,6 +5,38 @@ const { simulateRandomTopology, } = require('../../src/template-validation/simulate-random-topology'); +function createLoadConfigTopology(configReference) { + const message = { + topic: 'CLUSTER_OPERATIONS', + content: { + data: { + operations: [{ action: 'load_config', config: configReference }], + }, + }, + }; + + return { + agents: [ + { + id: 'loader', + role: 'orchestrator', + outputFormat: 'json', + jsonSchema: { type: 'object', properties: {} }, + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + hooks: { + onComplete: { + action: 'publish_message', + transform: { + engine: 'javascript', + script: `return ${JSON.stringify(message)};`, + }, + }, + }, + }, + ], + }; +} + describe('simulateRandomTopology', function () { it('passes for a topology with terminal completion path', async function () { const config = { @@ -115,3 +147,34 @@ describe('simulateRandomTopology', function () { ); }); }); + +describe('simulateRandomTopology load_config boundaries', function () { + const templatesDir = path.join(__dirname, '..', '..', 'cluster-templates'); + + for (const [description, configReference, expectedError] of [ + ['static traversal', '../package', 'Invalid config name'], + [ + 'parameterized traversal', + { base: '../../package', params: {} }, + 'Invalid base template name', + ], + ]) { + it(`reports the shared resolver error for ${description}`, async function () { + const errors = await simulateRandomTopology({ + config: createLoadConfigTopology(configReference), + templateId: `unit-${description.replaceAll(' ', '-')}`, + templatesDir, + samples: 1, + maxSteps: 10, + maxScenarioMs: 1000, + }); + + assert.ok( + errors.some( + (error) => error.includes('invalid CLUSTER_OPERATIONS') && error.includes(expectedError) + ), + `Expected ${expectedError}, got: ${errors.join(' | ')}` + ); + }); + } +});