From 0761e2fabfb9379b19b4f2345c4a273cf28d6213 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 24 Jul 2026 00:29:31 -0400 Subject: [PATCH] fix: gate ECC node qualification before spawn --- README.md | 7 +- docs/design/ecc-ito-compute-integration.md | 3 +- scripts/ecc.js | 17 +-- scripts/ito.js | 74 ++++++++++-- scripts/lib/ito-environment.js | 15 +++ tests/scripts/ito-cli-bridge.test.js | 124 ++++++++++++++++++++- 6 files changed, 211 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 282e680d2..839c2bcf0 100644 --- a/README.md +++ b/README.md @@ -1228,9 +1228,10 @@ skill](skills/ito-compute/SKILL.md) for the full RFQ authority and MCP setup contract. `find` submits a live authenticated RFQ. It does not reserve capacity. -`evals` requires the canonical CLI's double opt-in, a separately installed -`sixtytwo-cli==0.3.33`, an explicit node list, and an existing absolute -configuration directory. It cannot rent, launch, recover, repair, or purchase. +`evals` requires both `ITO_ENABLE_SIXTYTWO_LIVE=1` and `--live-sixtytwo`, a +separately installed `sixtytwo-cli==0.3.33`, an explicit node list, and an +existing absolute configuration directory. It cannot rent, launch, recover, +repair, or purchase. ECC exposes no quote lock, purchase, workload, or inference path, and it never replaces a missing client or failed live call with a local result. diff --git a/docs/design/ecc-ito-compute-integration.md b/docs/design/ecc-ito-compute-integration.md index 81655bd12..a659afb46 100644 --- a/docs/design/ecc-ito-compute-integration.md +++ b/docs/design/ecc-ito-compute-integration.md @@ -70,7 +70,8 @@ environment. It does not inspect or log the key. or agent must gather every hard topology/economic constraint and obtain explicit buyer authority before invoking it. - `status` reads current RFQ and procurement status. -- `evals` runs only the canonical CLI's double-opt-in +- `evals` requires both `ITO_ENABLE_SIXTYTWO_LIVE=1` and + `--live-sixtytwo`, then runs only the canonical CLI's pinned `sixtytwo-cli==0.3.33` qualification adapter against an explicit node list and existing absolute configuration directory. It receives no `ITO_API_KEY` or unrelated cloud/model credentials and cannot rent, launch, recover, diff --git a/scripts/ecc.js b/scripts/ecc.js index e1acae3d0..7408952a4 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -4,7 +4,7 @@ const { spawnSync } = require('child_process'); const path = require('path'); const { listAvailableLanguages } = require('./lib/install-executor'); const { getComputeSponsorCopy } = require('./lib/compute-sponsor'); -const { createSafeItoEnvironment } = require('./lib/ito-environment'); +const { createSafeItoInvocationEnvironment } = require('./lib/ito-environment'); const COMMANDS = { install: { @@ -107,7 +107,7 @@ const PRIMARY_COMMANDS = [ ]; function showHelp(exitCode = 0) { - console.log(` + process.stdout.write(` ECC selective-install CLI Usage: @@ -227,22 +227,15 @@ function runCommand(commandName, args) { if (!command) { throw new Error(`Unknown command: ${commandName}`); } - const itoSubcommand = args[0] === '--json' ? args[1] : args[0]; - const isItoNodeQualification = commandName === 'ito' && itoSubcommand === 'evals'; - const result = spawnSync( process.execPath, [path.join(__dirname, command.script), ...args], { cwd: process.cwd(), env: commandName === 'ito' - ? { - ...createSafeItoEnvironment(process.env, { - includeControls: true, - includeItoRuntime: !isItoNodeQualification, - includeItoEvals: isItoNodeQualification, - }), - } + ? createSafeItoInvocationEnvironment(process.env, args, { + includeControls: true, + }) : process.env, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, diff --git a/scripts/ito.js b/scripts/ito.js index 53e49ee91..f03821d23 100755 --- a/scripts/ito.js +++ b/scripts/ito.js @@ -5,7 +5,9 @@ const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); -const { createSafeItoEnvironment } = require("./lib/ito-environment"); +const { + createSafeItoInvocationEnvironment, +} = require("./lib/ito-environment"); const SUPPORTED_COMMANDS = Object.freeze(["auth", "find", "status", "evals"]); const CANONICAL_REPOSITORY = "https://github.com/Ito-Markets/ito-cloud-runtime.git"; @@ -18,9 +20,10 @@ const CANONICAL_ENTRY_SEGMENTS = Object.freeze([ ]); const EXECUTABLE_OVERRIDE = "ECC_ITO_CLI_EXECUTABLE"; const MAX_OUTPUT_BYTES = 10 * 1024 * 1024; +const NODE_QUALIFICATION_TIMEOUT_MS = 31 * 60 * 1000; function showHelp() { - console.log(` + process.stdout.write(` ECC × Itô local CLI bridge Usage: @@ -63,15 +66,64 @@ The same package's MCP server exposes only: Configure the MCP command as "node" with this absolute argument: /absolute/path/to/ito-cloud-runtime/${CANONICAL_PACKAGE_PATH}/dist/bin/ito-mcp.js -Inject ITO_API_KEY into the child process from 1Password or the launching -environment. Never put the key in arguments, tracked files, or chat. +For auth, find, and status, inject ITO_API_KEY into the child process from +1Password or the launching environment. Never put the key in arguments, +tracked files, or chat. Live node qualification requires ITO_ENABLE_SIXTYTWO_LIVE=1, --live-sixtytwo, an explicit node list, and an existing absolute config -directory. The canonical CLI requires sixtytwo-cli==0.3.33 and fails closed. +directory. It forwards only named SIXTYTWO_API_TOKEN/SIXTYTWO_TOKEN and SSH +agent state; ITO_API_KEY is intentionally excluded. The canonical CLI requires +sixtytwo-cli==0.3.33 and fails closed. `); } +function requiredOptionValue(args, option) { + const indexes = args + .map((value, index) => (value === option ? index : -1)) + .filter((index) => index >= 0); + if (indexes.length !== 1) { + throw new Error(`${option} is required exactly once for live node qualification.`); + } + const value = args[indexes[0] + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${option} requires a non-empty value for live node qualification.`); + } + return value; +} + +function validateNodeQualificationArgs(args, environment) { + if (environment.ITO_ENABLE_SIXTYTWO_LIVE !== "1") { + throw new Error( + "Live node qualification requires ITO_ENABLE_SIXTYTWO_LIVE=1 before any process is started." + ); + } + if (args.filter((value) => value === "--live-sixtytwo").length !== 1) { + throw new Error( + "Live node qualification requires --live-sixtytwo exactly once before any process is started." + ); + } + requiredOptionValue(args, "--cluster"); + requiredOptionValue(args, "--nodes"); + const configDirectory = requiredOptionValue(args, "--config-dir"); + if (!path.isAbsolute(configDirectory)) { + throw new Error("--config-dir must be an existing absolute directory."); + } + try { + const resolved = fs.realpathSync.native(configDirectory); + if ( + !fs.statSync(resolved).isDirectory() + || !fs.statSync(path.join(resolved, "sixtytwo.yaml")).isFile() + ) { + throw new Error("invalid qualification configuration"); + } + } catch { + throw new Error( + "--config-dir must exist and contain a regular sixtytwo.yaml before any process is started." + ); + } +} + function parseArgs(argv, environment = process.env) { const args = [...argv]; if ( @@ -101,6 +153,9 @@ function parseArgs(argv, environment = process.env) { `Unsupported Itô command "${command || "(missing)"}"; ECC permits only auth, find, status, and evals.` ); } + if (command === "evals") { + validateNodeQualificationArgs(withoutJson, environment); + } return Object.freeze({ help: false, @@ -199,13 +254,9 @@ function invokeIto(executable, args, environment = process.env) { const result = spawnSync(invocation.executable, invocation.args, { cwd: process.cwd(), encoding: "utf8", - env: { - ...createSafeItoEnvironment(environment, { - includeItoRuntime: !isNodeQualification, - includeItoEvals: isNodeQualification, - }), - }, + env: createSafeItoInvocationEnvironment(environment, args), maxBuffer: MAX_OUTPUT_BYTES, + timeout: isNodeQualification ? NODE_QUALIFICATION_TIMEOUT_MS : undefined, shell: false, windowsHide: true, }); @@ -245,6 +296,7 @@ module.exports = Object.freeze({ CANONICAL_PACKAGE_PATH, CANONICAL_REPOSITORY, EXECUTABLE_OVERRIDE, + NODE_QUALIFICATION_TIMEOUT_MS, SUPPORTED_COMMANDS, buildInvocation, invokeIto, diff --git a/scripts/lib/ito-environment.js b/scripts/lib/ito-environment.js index 6b899dd18..93f6490f1 100644 --- a/scripts/lib/ito-environment.js +++ b/scripts/lib/ito-environment.js @@ -42,6 +42,7 @@ const ECC_ITO_CONTROL_KEYS = Object.freeze([ "ECC_ITO_CLI_EXECUTABLE", "NODE_ENV", ]); +const ITO_RUNTIME_COMMANDS = new Set(["auth", "find", "status"]); function copyDefined(source, target, key) { if (typeof source[key] === "string") { @@ -79,10 +80,24 @@ function createSafeItoEnvironment(source = process.env, options = {}) { return Object.freeze(safe); } +function createSafeItoInvocationEnvironment( + source = process.env, + args = [], + options = {}, +) { + const command = args.filter((value) => value !== "--json")[0]; + return createSafeItoEnvironment(source, { + includeControls: options.includeControls === true, + includeItoRuntime: ITO_RUNTIME_COMMANDS.has(command), + includeItoEvals: command === "evals", + }); +} + module.exports = Object.freeze({ ECC_ITO_CONTROL_KEYS, ITO_EVAL_ENVIRONMENT_KEYS, ITO_RUNTIME_ENVIRONMENT_KEYS, SYSTEM_ENVIRONMENT_KEYS, createSafeItoEnvironment, + createSafeItoInvocationEnvironment, }); diff --git a/tests/scripts/ito-cli-bridge.test.js b/tests/scripts/ito-cli-bridge.test.js index 381b05348..c9a458819 100644 --- a/tests/scripts/ito-cli-bridge.test.js +++ b/tests/scripts/ito-cli-bridge.test.js @@ -13,7 +13,14 @@ const { spawnSync } = require("child_process"); const REPO_ROOT = path.join(__dirname, "..", ".."); const ECC_SCRIPT = path.join(REPO_ROOT, "scripts", "ecc.js"); +const ITO_SCRIPT = path.join(REPO_ROOT, "scripts", "ito.js"); const CANONICAL_PACKAGE = "Ito-Markets/ito-cloud-runtime/cli/ito-compute-cli"; +const { + NODE_QUALIFICATION_TIMEOUT_MS, +} = require("../../scripts/ito"); +const { + createSafeItoInvocationEnvironment, +} = require("../../scripts/lib/ito-environment"); function runCli(args, environment = {}) { return spawnSync(process.execPath, [ECC_SCRIPT, ...args], { @@ -80,8 +87,8 @@ function main() { console.log("\n=== Testing ECC × Itô real CLI bridge ===\n"); const tests = [ - ["forwards only the reviewed CLI surface to an explicit local executable", () => { - for (const command of ["auth", "find", "status", "evals"]) { + ["forwards only the reviewed RFQ CLI surface to an explicit local executable", () => { + for (const command of ["auth", "find", "status"]) { const probe = makeItoProbe(); try { const result = runCli(["ito", command], { @@ -156,6 +163,8 @@ function main() { const probe = makeItoProbe(); try { const configDirectory = path.join(probe.directory, "qualification"); + fs.mkdirSync(configDirectory); + fs.writeFileSync(path.join(configDirectory, "sixtytwo.yaml"), "suite: full\n"); const result = runCli([ "ito", "evals", @@ -170,6 +179,7 @@ function main() { ITO_INVENTORY_URL: "https://edge.example.test", ITO_ENABLE_SIXTYTWO_LIVE: "1", SIXTYTWO_API_TOKEN: "sixtytwo-test-token", + SIXTYTWO_TOKEN: "sixtytwo-legacy-test-token", SSH_AUTH_SOCK: "/tmp/ecc-test-agent.sock", ITO_CLI_DEMO: "1", ITO_CLI_STATE_DIR: "/tmp/forbidden-paper-state", @@ -187,6 +197,7 @@ function main() { ]); assert.strictEqual(invocation.env.ITO_ENABLE_SIXTYTWO_LIVE, "1"); assert.strictEqual(invocation.env.SIXTYTWO_API_TOKEN, "sixtytwo-test-token"); + assert.strictEqual(invocation.env.SIXTYTWO_TOKEN, "sixtytwo-legacy-test-token"); assert.strictEqual(invocation.env.SSH_AUTH_SOCK, "/tmp/ecc-test-agent.sock"); assert.strictEqual(invocation.env.ITO_API_KEY, undefined); assert.strictEqual(invocation.env.ITO_API_URL, undefined); @@ -199,6 +210,115 @@ function main() { fs.rmSync(probe.directory, { recursive: true, force: true }); } }], + ["rejects every incomplete live qualification before spawning", () => { + const validArgs = [ + "ito", + "evals", + "--cluster", "clu_prod_example", + "--live-sixtytwo", + "--nodes", "gpu-01,gpu-02", + ]; + const cases = [ + { + label: "missing environment opt-in", + args: [...validArgs, "--config-dir", "__CONFIG__"], + env: {}, + error: /ITO_ENABLE_SIXTYTWO_LIVE=1/, + }, + { + label: "missing live flag", + args: validArgs.filter((value) => value !== "--live-sixtytwo") + .concat("--config-dir", "__CONFIG__"), + env: { ITO_ENABLE_SIXTYTWO_LIVE: "1" }, + error: /--live-sixtytwo/, + }, + { + label: "missing nodes", + args: [ + "ito", "evals", + "--cluster", "clu_prod_example", + "--live-sixtytwo", + "--config-dir", "__CONFIG__", + ], + env: { ITO_ENABLE_SIXTYTWO_LIVE: "1" }, + error: /--nodes/, + }, + { + label: "missing cluster", + args: [ + "ito", "evals", + "--live-sixtytwo", + "--nodes", "gpu-01", + "--config-dir", "__CONFIG__", + ], + env: { ITO_ENABLE_SIXTYTWO_LIVE: "1" }, + error: /--cluster/, + }, + { + label: "relative config directory", + args: [...validArgs, "--config-dir", "relative/config"], + env: { ITO_ENABLE_SIXTYTWO_LIVE: "1" }, + error: /absolute/, + }, + { + label: "missing config directory", + args: [...validArgs, "--config-dir", "__MISSING_CONFIG__"], + env: { ITO_ENABLE_SIXTYTWO_LIVE: "1" }, + error: /sixtytwo\.yaml/, + }, + ]; + + for (const testCase of cases) { + const probe = makeItoProbe(); + try { + const configDirectory = path.join(probe.directory, "qualification"); + fs.mkdirSync(configDirectory); + fs.writeFileSync(path.join(configDirectory, "sixtytwo.yaml"), "suite: full\n"); + const args = testCase.args.map((value) => ( + value === "__CONFIG__" + ? configDirectory + : value === "__MISSING_CONFIG__" + ? path.join(probe.directory, "missing") + : value + )); + const result = runCli(args, { + ECC_ITO_CLI_EXECUTABLE: probe.executable, + ...testCase.env, + }); + assert.notStrictEqual(result.status, 0, testCase.label); + assert.match(result.stderr, testCase.error, testCase.label); + assert.ok( + !fs.existsSync(probe.log), + `${testCase.label} must not spawn the canonical Itô CLI`, + ); + } finally { + fs.rmSync(probe.directory, { recursive: true, force: true }); + } + } + }], + ["classifies Itō child environments once and fails closed on unknown prefixes", () => { + const safe = createSafeItoInvocationEnvironment( + { + PATH: process.env.PATH, + ECC_ITO_CLI_EXECUTABLE: "/operator/canonical/ito.js", + ITO_API_KEY: "must-not-cross", + SIXTYTWO_TOKEN: "must-not-cross", + }, + ["--future-ecc-flag", "evals"], + { includeControls: true }, + ); + assert.strictEqual(safe.ECC_ITO_CLI_EXECUTABLE, "/operator/canonical/ito.js"); + assert.strictEqual(safe.ITO_API_KEY, undefined); + assert.strictEqual(safe.SIXTYTWO_TOKEN, undefined); + }], + ["bounds the outer node-qualification process beyond the canonical timeout", () => { + assert.strictEqual(NODE_QUALIFICATION_TIMEOUT_MS, 31 * 60 * 1000); + const source = fs.readFileSync(ITO_SCRIPT, "utf8"); + assert.match( + source, + /timeout: isNodeQualification \? NODE_QUALIFICATION_TIMEOUT_MS : undefined/, + ); + }], ["rejects unsupported browser, paper, and execution operations before spawning", () => { for (const command of ["rent", "lock", "run", "inference", "mcp"]) { const probe = makeItoProbe();