snipara-companion 4.0.0 → 4.0.2

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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,17 @@
2
2
 
3
3
  Release notes for `snipara-companion`, newest first.
4
4
 
5
- ## 4.0.0 — prepared candidate (unpublished)
5
+ ## 4.0.2
6
+
7
+ - Match the public Sandbox command contract: pass evidence stores only to run, verify, status and cancel; keep gate, choose and diagnose compatible.
8
+
9
+ ## 4.0.1
10
+
11
+ - Delegate scoped local validation runs to Orchestrator for shared execution exclusion and freshly verified Sandbox receipt reuse. Preserve original run identity and report reuse separately.
12
+ - Keep legacy unscoped execution available; require compatible Orchestrator for coordinated runs and never silently fall back after uncertain execution.
13
+ - Match Sandbox's capability-bound scope v3 while preserving empty-capability v2 identities.
14
+
15
+ ## 4.0.0
6
16
 
7
17
  - Breaking CLI JSON contract: `workflow resume --json` returns a versioned compact overview by default. Existing full-payload consumers must add `--detail`; HTTP What Changed stays full by default. This migration requires a major Companion release.
8
18
 
package/dist/index.js CHANGED
@@ -348,7 +348,7 @@ function expectedSandboxScope(requestFile, scope) {
348
348
  const raw = JSON.parse(text3);
349
349
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid request");
350
350
  const r = raw;
351
- const known = /* @__PURE__ */ new Set(["schema_version", "workspace", "argv", "image", "profile", "cwd", "timeout_seconds", "cpus", "memory_mb", "pids", "disk_mb", "artifacts"]);
351
+ const known = /* @__PURE__ */ new Set(["schema_version", "workspace", "argv", "image", "profile", "cwd", "timeout_seconds", "cpus", "memory_mb", "pids", "disk_mb", "artifacts", "requirements"]);
352
352
  if (Object.keys(r).some((key) => !known.has(key))) throw new Error("Unknown request fields");
353
353
  const schema = r.schema_version === void 0 ? "snipara.sandbox.validation.v1" : r.schema_version;
354
354
  if (schema !== "snipara.sandbox.validation.v1" || typeof r.workspace !== "string" || !(0, import_node_path.isAbsolute)(r.workspace) || r.workspace.split("/").includes("..") || typeof r.image !== "string" || !r.image || r.image.startsWith("-") || /\s/.test(r.image)) throw new Error("Unsupported request identity");
@@ -368,6 +368,24 @@ function expectedSandboxScope(requestFile, scope) {
368
368
  const cpus = r.cpus === void 0 ? 2 : r.cpus;
369
369
  if (typeof cpus !== "number" || !Number.isFinite(cpus) || cpus < 0.01 || cpus > 8) throw new Error("Invalid CPU limit");
370
370
  const parts = ["snipara.validation.scope.v2", scope, schema, workspace, profile, r.image, import_node_path.posix.normalize(cwd).replace(/\/+$/, "") || ".", String(integer2("timeout_seconds", 120, 1, 900)), String(Math.trunc(cpus * 1e9)), String(integer2("memory_mb", 2048, 64, 8192)), String(integer2("pids", 128, 16, 512)), String(integer2("disk_mb", 2048, 16, 4096))];
371
+ const rawRequirements = r.requirements === void 0 ? {} : r.requirements;
372
+ if (!rawRequirements || typeof rawRequirements !== "object" || Array.isArray(rawRequirements)) throw new Error("Invalid requirements");
373
+ const requirements = rawRequirements;
374
+ const patterns = {
375
+ python_modules: /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/,
376
+ node_modules: /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/,
377
+ commands: /^[A-Za-z0-9][A-Za-z0-9._+-]*$/
378
+ };
379
+ if (Object.keys(requirements).some((name) => !Object.hasOwn(patterns, name))) throw new Error("Unknown requirements fields");
380
+ const names = ["python_modules", "node_modules", "commands"].map((name) => {
381
+ const values = requirements[name] === void 0 ? [] : requirements[name];
382
+ if (!Array.isArray(values) || values.length > 32 || values.some((value) => typeof value !== "string" || value.length > 128 || patterns[name].exec(value)?.[0] !== value)) throw new Error("Invalid requirement names");
383
+ return [...new Set(values)];
384
+ });
385
+ if (names.some((values) => values.length > 0)) {
386
+ parts[0] = "snipara.validation.scope.v3";
387
+ parts.push(...names.map((values) => JSON.stringify(values)));
388
+ }
371
389
  if (parts.some((value) => Buffer.from(value, "utf8").toString("utf8") !== value)) throw new Error("Invalid Unicode identity");
372
390
  return (0, import_node_crypto.createHash)("sha256").update(JSON.stringify(parts), "utf8").digest("hex");
373
391
  }
@@ -413,6 +431,9 @@ async function invokeSandboxValidation(action2, options) {
413
431
  if (options.probeTimeoutSeconds !== void 0 && (!diagnostic2 || !Number.isInteger(options.probeTimeoutSeconds) || options.probeTimeoutSeconds < 1 || options.probeTimeoutSeconds > MAX_PROBE_TIMEOUT_SECONDS)) {
414
432
  return { exitCode: 2, result: { ...sandboxUnavailable, stage: "adapter_input", reason_code: "invalid_probe_timeout", reason: "probeTimeoutSeconds is an integer from 1 to 60, supported only for diagnose and gate." } };
415
433
  }
434
+ if ((options.noReuse || options.orchestratorCommand !== void 0) && (action2 !== "run" || !options.scope?.trim()) || action2 === "run" && options.scope !== void 0 && !options.scope.trim()) {
435
+ return { exitCode: 2, result: { ...sandboxUnavailable, stage: "adapter_input", reason_code: "explicit_scope_required", reason: "Coordinated run controls require validation run with an explicit workflow or lot scope." } };
436
+ }
416
437
  if (process.platform === "win32") return { exitCode: 2, result: { ...sandboxUnavailable, reason: "Repository validation currently qualifies macOS/Linux Docker only." } };
417
438
  const requiresRequest = ["gate", "choose", "diagnose", "run", "verify"].includes(action2);
418
439
  const requiresRun = ["verify", "status", "cancel"].includes(action2);
@@ -420,37 +441,69 @@ async function invokeSandboxValidation(action2, options) {
420
441
  return { exitCode: 2, result: { ...sandboxUnavailable, reason: "Explicit request, workflow scope and valid run identity are required for the selected action." } };
421
442
  }
422
443
  if (action2 === "choose" && (!options.acknowledgeUserChoice || !["alternative", "install", "local", "skip"].includes(options.choice || "") || options.choice === "alternative" && !options.environment)) return { exitCode: 2, result: { ...sandboxUnavailable, reason: "An explicit acknowledged user choice is required." } };
444
+ const delegated = action2 === "run" && !!options.scope;
423
445
  const args = ["validation", action2];
424
- for (const [flag, value] of Object.entries({ request: options.request, scope: options.scope, "run-id": options.runId, store: options.store, choice: options.choice, environment: options.environment })) {
446
+ const parameters = delegated ? { request: options.request, scope: options.scope, store: options.store, "sandbox-command": options.sandboxCommand } : { request: options.request, scope: action2 === "run" ? void 0 : options.scope, "run-id": options.runId, store: ["run", "verify", "status", "cancel"].includes(action2) ? options.store : void 0, choice: options.choice, environment: options.environment };
447
+ for (const [flag, value] of Object.entries(parameters)) {
425
448
  if (value !== void 0) args.push(`--${flag}`, value);
426
449
  }
427
- if (options.acknowledgeUserChoice) args.push("--acknowledge-user-choice");
450
+ if (delegated && options.noReuse) args.push("--no-reuse");
451
+ if (!delegated && options.acknowledgeUserChoice) args.push("--acknowledge-user-choice");
428
452
  if (options.probeTimeoutSeconds !== void 0) args.push("--probe-timeout-seconds", String(options.probeTimeoutSeconds));
429
453
  const env2 = {};
430
454
  for (const name of ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG"]) {
431
455
  if (process.env[name]) env2[name] = process.env[name];
432
456
  }
457
+ const command2 = delegated ? options.orchestratorCommand || "snipara-orchestrator" : options.sandboxCommand || "snipara-sandbox";
458
+ if (delegated) {
459
+ try {
460
+ const capability = await executeBounded(command2, ["validation", "run", "--help"], env2, 1e4);
461
+ if (capability.status !== 0 || !/(?:^|\s)--no-reuse(?:\s|,|$)/m.test(capability.stdout)) throw new Error("The Orchestrator validation run command does not expose --no-reuse.");
462
+ } catch (error) {
463
+ return { exitCode: 2, result: { status: "orchestrator_update_required", stage: "adapter_capability", reason_code: "orchestrator_update_required", reason: `Scoped validation requires Orchestrator 1.14 or newer with validation reuse support. ${redactDiagnostic(error.message)}`, execution_authorized_by_gate: false } };
464
+ }
465
+ }
433
466
  let child;
434
467
  const timeoutMs = diagnostic2 ? ((options.probeTimeoutSeconds ?? DEFAULT_PROBE_TIMEOUT_SECONDS) + DIAGNOSTIC_CONTROL_GRACE_SECONDS + ADAPTER_STARTUP_ALLOWANCE_SECONDS) * 1e3 : action2 === "run" ? 96e4 : 3e4;
435
468
  try {
436
- child = await executeBounded(options.sandboxCommand || "snipara-sandbox", args, env2, timeoutMs);
469
+ child = await executeBounded(command2, args, env2, timeoutMs);
437
470
  } catch (error) {
438
471
  const failure = error;
472
+ if (delegated) return { exitCode: 2, result: { status: "validation_reuse_needs_review", work_reuse: { state: "needs_review" }, stage: "adapter_process", reason_code: failure.code === "ETIMEDOUT" ? "orchestrator_process_timeout" : failure.code === "EOUTPUT" ? "orchestrator_output_limit" : "orchestrator_process_failed", reason: redactDiagnostic(failure.message || "Orchestrator process failed."), adapter_timeout_seconds: timeoutMs / 1e3, execution_authorized_by_gate: false } };
439
473
  return { exitCode: 2, result: { ...sandboxUnavailable, stage: "adapter_process", reason_code: failure.code === "ETIMEDOUT" ? "sandbox_process_timeout" : failure.code === "EOUTPUT" ? "sandbox_output_limit" : "sandbox_process_failed", reason: redactDiagnostic(failure.message || "Sandbox process failed to start."), adapter_timeout_seconds: timeoutMs / 1e3 } };
440
474
  }
441
475
  try {
442
476
  const value = JSON.parse(child.stdout);
443
477
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Sandbox response");
444
478
  const result = value;
479
+ const workReuse = result.work_reuse && typeof result.work_reuse === "object" && !Array.isArray(result.work_reuse) ? result.work_reuse : void 0;
480
+ if (delegated && workReuse?.state === "needs_review" && (result.status === "needs_user_choice" || result.status === "user_choice_recorded")) {
481
+ if (result.run_id !== void 0 || result.execution_authorized_by_gate !== false || result.sandbox_used !== void 0 && result.sandbox_used !== false || Array.from(options.scope).length > 256) throw new Error("Invalid nonexecution gate response");
482
+ const scope = result.scope_sha256 === void 0 ? void 0 : expectedSandboxScope(options.request, options.scope);
483
+ if (result.scope_sha256 !== scope) throw new Error("Invalid gate scope binding");
484
+ if (result.status === "needs_user_choice") {
485
+ const allowed = ["alternative", "install", "continue_without_sandbox"];
486
+ if (typeof result.prompt !== "string" || !result.prompt.trim() || result.prompt.length > 2e3 || !Array.isArray(result.choices) || result.choices.length < 1 || result.choices.length > allowed.length || new Set(result.choices).size !== result.choices.length || !result.choices.every((value2) => typeof value2 === "string" && allowed.includes(value2))) throw new Error("Invalid unavailable gate choices");
487
+ return { exitCode: 2, result: sanitizeDiagnostics({ ...result, prompt: redactDiagnostic(result.prompt) }) };
488
+ }
489
+ const choice2 = result.choice;
490
+ if (!scope || result.sandbox_used !== false || result.proof_requirement_waived !== false || !validChoice(choice2, scope) || choice2?.choice === "install") throw new Error("Invalid recorded gate choice");
491
+ return { exitCode: 2, result: sanitizeDiagnostics(result) };
492
+ }
493
+ if (delegated && result.run_id === void 0 && (result.status === "validation_in_progress" && workReuse?.state === "in_progress" || result.status === "validation_reuse_needs_review" && workReuse?.state === "needs_review")) {
494
+ return { exitCode: 2, result: sanitizeDiagnostics(result) };
495
+ }
496
+ if (delegated && (!workReuse || typeof workReuse.state !== "string" || !["executed", "reused", "needs_review"].includes(workReuse.state))) throw new Error("Invalid validation reuse metadata");
445
497
  const schema = result.schema_version === "snipara.sandbox.validation.v1";
446
498
  const identity = ["gate", "choose"].includes(action2) && typeof result.scope_sha256 === "string" && result.scope_sha256 === expectedSandboxScope(options.request, options.scope);
447
499
  const ready = result.readiness;
448
500
  const choice = result.choice;
449
501
  const valid = action2 === "gate" ? identity && result.sandbox_used === false && result.execution_authorized_by_gate === false && (result.status === "sandbox_ready" && ready?.schema_version === "snipara.sandbox.validation.v1" && ready.ready === true && ready.backend === "docker" && typeof ready.image_id === "string" && !!ready.image_id || result.status === "needs_user_choice" && result.execution_authorized_by_gate === false && Array.isArray(result.choices) && result.choices.length > 0 || result.status === "user_choice_recorded" && result.proof_requirement_waived === false && validChoice(choice, result.scope_sha256) && choice?.choice !== "install") : action2 === "choose" ? identity && validChoice(result, result.scope_sha256) && result.choice === options.choice && result.environment === (options.environment || "") : action2 === "diagnose" ? typeof result.ready === "boolean" && schema && result.backend === "docker" : action2 === "cancel" ? result.cancel_requested === true && result.run_id === options.runId : action2 === "verify" ? schema && typeof result.valid === "boolean" && result.run_id === options.runId && Array.isArray(result.reasons) && (result.valid === false || result.reasons.length === 0 && validVerifiedReceipt(result.receipt, options.runId)) : schema && typeof result.run_id === "string" && /^[a-f0-9]{32}$/.test(result.run_id) && (!options.runId || result.run_id === options.runId) && typeof result.status === "string" && ["preparing", "running", "passed", "failed", "timeout", "cancelled", "environment_incompatible", "artifact_error", "collection_error", "cleanup_failed", "error", "interrupted"].includes(String(result.status)) && typeof result.cleanup_complete === "boolean";
450
502
  if (!valid) throw new Error("Unsupported Sandbox response schema");
451
- const failed = result.status === "needs_user_choice" || action2 === "verify" && result.valid !== true || action2 === "diagnose" && result.ready !== true || action2 === "run" && (result.status !== "passed" || result.cleanup_complete !== true);
503
+ const failed = result.status === "needs_user_choice" || action2 === "verify" && result.valid !== true || action2 === "diagnose" && result.ready !== true || action2 === "run" && (result.status !== "passed" || result.cleanup_complete !== true) || delegated && workReuse?.state === "needs_review";
452
504
  return { exitCode: failed ? 2 : child.status ?? 2, result: sanitizeDiagnostics(result) };
453
505
  } catch {
506
+ if (delegated) return { exitCode: 2, result: { status: "validation_reuse_needs_review", work_reuse: { state: "needs_review" }, stage: "adapter_response", reason_code: "orchestrator_response_invalid", reason: child.stderr ? redactDiagnostic(child.stderr) : "Orchestrator returned an unsupported or malformed response after execution was attempted; inspect its evidence before retrying.", execution_authorized_by_gate: false } };
454
507
  return { exitCode: 2, result: { ...sandboxUnavailable, stage: "adapter_response", reason_code: "sandbox_response_invalid", reason: child.stderr ? redactDiagnostic(child.stderr) : "Sandbox returned an unsupported or malformed response; update/configure it or select an alternative." } };
455
508
  }
456
509
  }
@@ -51417,6 +51470,9 @@ validation.command("preview").description("Read a bounded local request preview;
51417
51470
  });
51418
51471
  for (const action2 of ["gate", "choose", "diagnose", "run", "verify", "status", "cancel"]) {
51419
51472
  const validationCommand = validation.command(action2).option("--request <file>", "Standalone Sandbox validation request JSON").option("--scope <id>", "Authorized workflow or lot identity").option("--run-id <id>", "Sandbox run identity").option("--store <path>", "Durable Sandbox evidence directory").option("--choice <choice>", "Explicit user choice: alternative, install, local or skip").option("--environment <name>", "Name the chosen alternative environment").option("--acknowledge-user-choice", "Record an actual explicit user choice; never inferred").option("--sandbox-command <path>", "Explicit Sandbox executable; no shell interpolation");
51473
+ if (action2 === "run") {
51474
+ validationCommand.option("--no-reuse", "Request a fresh scoped run while retaining the shared validation lock").option("--orchestrator-command <path>", "Compatible Orchestrator executable for scoped validation");
51475
+ }
51420
51476
  if (["gate", "diagnose"].includes(action2)) {
51421
51477
  validationCommand.option(
51422
51478
  "--probe-timeout-seconds <seconds>",
@@ -51425,7 +51481,7 @@ for (const action2 of ["gate", "choose", "diagnose", "run", "verify", "status",
51425
51481
  );
51426
51482
  }
51427
51483
  validationCommand.action(async (options) => {
51428
- const outcome = await invokeSandboxValidation(action2, options);
51484
+ const outcome = await invokeSandboxValidation(action2, action2 === "run" ? { ...options, noReuse: options.reuse === false } : options);
51429
51485
  console.log(JSON.stringify(outcome.result));
51430
51486
  process.exitCode = outcome.exitCode;
51431
51487
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snipara-companion",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "Local-first CLI that asks your repo what breaks before an AI coding agent edits it.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {