@tryarcanist/cli 0.1.105 → 0.1.106

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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1146 -18
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -128,7 +128,7 @@ arcanist sessions create your-org/your-repo "verify the deployed change" --cold
128
128
  arcanist sessions create your-org/your-repo "retry-safe create" --idempotency-key 1f0e6f1a-...
129
129
  ```
130
130
 
131
- `--backend` picks the agent runtime backend: `codex` (default) or `claude_code`. `--model` must be valid for the chosen backend — codex runs OpenAI models (`gpt-5.5` default, `gpt-5.4`), `claude_code` runs Anthropic models (`claude-opus-4-8` default, `claude-sonnet-4-6`) — and the CLI rejects a mismatch before any network call. `claude_code` sessions require an Anthropic API key configured in Settings.
131
+ `--backend` picks the agent runtime backend: `codex` (default) or `claude_code`. `--model` must be valid for the chosen backend — codex runs OpenAI models (`gpt-5.4` default, `gpt-5.5`), `claude_code` runs Anthropic models (`claude-opus-4-8` default, `claude-sonnet-4-6`) — and the CLI rejects a mismatch before any network call. `claude_code` sessions require an Anthropic API key configured in Settings.
132
132
 
133
133
  `--wait` blocks until the created prompt finishes and exits non-zero if the prompt finishes with `status: failed`, making it suitable for cron or other schedulers that alert on command failure. `--poll-interval <ms>` tunes the completion check frequency.
134
134
 
package/dist/index.js CHANGED
@@ -421,27 +421,29 @@ var SESSION_START_MODEL_IDS_BY_BACKEND = {
421
421
  [CLAUDE_CODE_AGENT_RUNTIME_BACKEND]: [AnthropicModel.Opus48, AnthropicModel.Sonnet46]
422
422
  };
423
423
  var DEFAULT_SESSION_START_MODEL_ID_BY_BACKEND = {
424
- [CODEX_AGENT_RUNTIME_BACKEND]: OpenAIModel.GPT55,
424
+ [CODEX_AGENT_RUNTIME_BACKEND]: OpenAIModel.GPT54,
425
425
  [CLAUDE_CODE_AGENT_RUNTIME_BACKEND]: AnthropicModel.Opus48
426
426
  };
427
427
  var SESSION_START_MODEL_IDS = SESSION_START_MODEL_IDS_BY_BACKEND[CODEX_AGENT_RUNTIME_BACKEND];
428
428
  var DEFAULT_SESSION_START_MODEL_ID = DEFAULT_SESSION_START_MODEL_ID_BY_BACKEND[CODEX_AGENT_RUNTIME_BACKEND];
429
429
  var MODEL_REGISTRY = [
430
430
  {
431
- id: OpenAIModel.GPT55,
432
- name: "GPT-5.5",
431
+ id: OpenAIModel.GPT54,
432
+ name: "GPT-5.4",
433
433
  provider: "openai",
434
434
  backends: [CODEX_AGENT_RUNTIME_BACKEND],
435
435
  contextWindow: 1e6,
436
+ // Default codex model after the gpt-5.5 downgrade; carries gpt-5.5's prior
437
+ // "medium" default so default sessions keep the same reasoning effort.
436
438
  reasoning: { efforts: ["none", "low", "medium", "high", "xhigh"], default: "medium" }
437
439
  },
438
440
  {
439
- id: OpenAIModel.GPT54,
440
- name: "GPT-5.4",
441
+ id: OpenAIModel.GPT55,
442
+ name: "GPT-5.5",
441
443
  provider: "openai",
442
444
  backends: [CODEX_AGENT_RUNTIME_BACKEND],
443
445
  contextWindow: 1e6,
444
- reasoning: { efforts: ["none", "low", "medium", "high", "xhigh"], default: void 0 }
446
+ reasoning: { efforts: ["none", "low", "medium", "high", "xhigh"], default: "medium" }
445
447
  },
446
448
  {
447
449
  id: OpenAIModel.GPT54Mini,
@@ -579,6 +581,19 @@ function getSessionStartModelIdsForBackend(backend) {
579
581
  function isSessionStartModelAllowedForBackend(modelId, backend) {
580
582
  return VALID_SESSION_START_MODEL_IDS_BY_BACKEND[backend].has(modelId);
581
583
  }
584
+ var MODEL_TIER_BY_ID = {
585
+ [OpenAIModel.GPT55]: "frontier",
586
+ [OpenAIModel.GPT54]: "mid",
587
+ [AnthropicModel.Opus48]: "frontier",
588
+ [AnthropicModel.Sonnet46]: "mid"
589
+ };
590
+ var CODEX_MODEL_ID_BY_TIER = {
591
+ // Single source of truth: the frontier codex model follows the codex default,
592
+ // so changing the default propagates here automatically. Only used for
593
+ // non-codex (e.g. claude) parents — codex parents verify on their own model.
594
+ frontier: DEFAULT_SESSION_START_MODEL_ID_BY_BACKEND[CODEX_AGENT_RUNTIME_BACKEND],
595
+ mid: OpenAIModel.GPT54
596
+ };
582
597
  function getProviderForModel(modelId) {
583
598
  return MODEL_PROVIDERS[modelId] ?? "openai";
584
599
  }
@@ -750,9 +765,9 @@ function validateUploadedFilePayload(files, existingNames = []) {
750
765
  label: "uploaded files",
751
766
  normalizeItems: trimNamedItems,
752
767
  validateItem: (file) => {
753
- const byteLength = new TextEncoder().encode(file.content).byteLength;
754
- if (byteLength > MAX_UPLOADED_FILE_SIZE_BYTES) {
755
- return `Uploaded file too large: ${file.name} (${byteLength} bytes, max ${MAX_UPLOADED_FILE_SIZE_BYTES})`;
768
+ const byteLength2 = new TextEncoder().encode(file.content).byteLength;
769
+ if (byteLength2 > MAX_UPLOADED_FILE_SIZE_BYTES) {
770
+ return `Uploaded file too large: ${file.name} (${byteLength2} bytes, max ${MAX_UPLOADED_FILE_SIZE_BYTES})`;
756
771
  }
757
772
  if (hasNullBytes(file.content)) {
758
773
  return `Binary files are not supported: ${file.name}`;
@@ -2175,11 +2190,11 @@ function formatStatusLine(status) {
2175
2190
  if (status.sandboxSubstate && status.sandboxSubstate !== "none") substate.push(status.sandboxSubstate);
2176
2191
  if (status.stopMode && status.stopMode !== "none") substate.push(status.stopMode);
2177
2192
  if (status.finalizingStep && status.finalizingStep !== "none") substate.push(status.finalizingStep);
2178
- const phaseLabel = substate.length > 0 ? `${label}/${substate.join("/")}` : label;
2193
+ const phaseLabel2 = substate.length > 0 ? `${label}/${substate.join("/")}` : label;
2179
2194
  const details = [];
2180
2195
  if (status.title) details.push(status.title);
2181
2196
  if (status.spawnDurationMs != null) details.push(`spawn ${(status.spawnDurationMs / 1e3).toFixed(1)}s`);
2182
- return details.length > 0 ? `[status] ${phaseLabel} | ${details.join(" | ")}` : `[status] ${phaseLabel}`;
2197
+ return details.length > 0 ? `[status] ${phaseLabel2} | ${details.join(" | ")}` : `[status] ${phaseLabel2}`;
2183
2198
  }
2184
2199
  function advanceStatusDisconnectMask(status, mask, lastView, now) {
2185
2200
  const view = status.phase ? { phase: status.phase, sandboxSubstate: status.sandboxSubstate } : null;
@@ -2541,6 +2556,1103 @@ async function messageCommand(sessionId, promptArg, options = {}, command) {
2541
2556
  console.log(`Message sent to session ${sessionId}.`);
2542
2557
  }
2543
2558
 
2559
+ // src/commands/sandbox.ts
2560
+ import { execFileSync } from "child_process";
2561
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2562
+ import { dirname } from "path";
2563
+
2564
+ // ../../shared/github/repo-url.ts
2565
+ function parseGithubRepoFullName(value) {
2566
+ const shorthand = value.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
2567
+ if (shorthand) return { owner: shorthand[1], repo: shorthand[2] };
2568
+ const ssh = value.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
2569
+ if (ssh) return { owner: ssh[1], repo: ssh[2] };
2570
+ const https = value.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
2571
+ if (https) return { owner: https[1], repo: https[2] };
2572
+ return null;
2573
+ }
2574
+
2575
+ // ../../shared/sandbox-layer/parser.ts
2576
+ import { isMap, isScalar, isSeq, parseDocument } from "yaml";
2577
+
2578
+ // ../../shared/utils/hex.ts
2579
+ function bytesToHex(buf) {
2580
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
2581
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
2582
+ }
2583
+
2584
+ // ../../shared/sandbox-layer/parser.ts
2585
+ var textEncoder = new TextEncoder();
2586
+ async function computeSha256Hex(message) {
2587
+ const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(message));
2588
+ return bytesToHex(new Uint8Array(digest));
2589
+ }
2590
+ var SANDBOX_LAYER_MANIFEST_PATH = ".arcanist/sandbox.yaml";
2591
+ var MAX_MANIFEST_BYTES = 64 * 1024;
2592
+ var MAX_LAYER_BYTES = 128 * 1024;
2593
+ var MAX_RUN_COMMAND_BYTES = 8 * 1024;
2594
+ var MAX_SMOKE_COMMANDS = 20;
2595
+ var MAX_SMOKE_ARGS = 32;
2596
+ var MAX_SMOKE_ARG_BYTES = 512;
2597
+ var MAX_DISPLAY_NAME_CHARS = 80;
2598
+ var MIN_DISPLAY_NAME_CHARS = 2;
2599
+ var MAX_ENV_KEY_CHARS = 80;
2600
+ var MAX_ENV_VALUE_BYTES = 2 * 1024;
2601
+ var MAX_ENV_VARS = 50;
2602
+ var SECRET_PATTERN = /\b(secret|token|password|passwd|private_key|private-key|access_key|access-key|client_secret|client-secret)\b|BEGIN PRIVATE KEY/i;
2603
+ var FORBIDDEN_PATHS = [
2604
+ "/app",
2605
+ "/app/start-bridge.sh",
2606
+ "/app/bridge",
2607
+ "/app/scripts",
2608
+ "/workspace",
2609
+ "/usr/local/sbin/arcanist-enforce-egress"
2610
+ ];
2611
+ var FORBIDDEN_TARGETING_OPS = /\b(rm|mv|cp|ln|chmod|chown|truncate|tee)\b|\bsed\s+-i\b|\bcat\s*>/;
2612
+ var ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,79}$/;
2613
+ var SandboxLayerValidationError = class extends Error {
2614
+ issues;
2615
+ path;
2616
+ detail;
2617
+ line;
2618
+ constructor(pathOrIssues, detail, line, field, code = "invalid") {
2619
+ const issues = Array.isArray(pathOrIssues) ? pathOrIssues : [{ path: pathOrIssues, line, field, code, message: detail ?? "invalid sandbox layer source" }];
2620
+ const first = issues[0] ?? { path: "sandbox-layer", message: "invalid sandbox layer source" };
2621
+ super(formatIssue(first));
2622
+ this.name = "SandboxLayerValidationError";
2623
+ this.issues = issues;
2624
+ this.path = first.path;
2625
+ this.detail = first.message;
2626
+ this.line = first.line;
2627
+ }
2628
+ };
2629
+ function formatIssue(issue2) {
2630
+ return `${issue2.path}${issue2.line ? `:${issue2.line}` : ""}: ${issue2.message}`;
2631
+ }
2632
+ function issue(path, message, options = {}) {
2633
+ return {
2634
+ path,
2635
+ line: options.line,
2636
+ field: options.field,
2637
+ code: options.code ?? "invalid",
2638
+ message
2639
+ };
2640
+ }
2641
+ function byteLength(value) {
2642
+ return new TextEncoder().encode(value).byteLength;
2643
+ }
2644
+ function rejectSecrets(path, text) {
2645
+ const match = text.match(SECRET_PATTERN);
2646
+ if (match) {
2647
+ throw new SandboxLayerValidationError(
2648
+ path,
2649
+ `sandbox layer source appears to contain a secret-like token: ${match[0]}`
2650
+ );
2651
+ }
2652
+ }
2653
+ function assertSize(path, text, maxBytes) {
2654
+ if (text.includes("\0")) {
2655
+ throw new SandboxLayerValidationError(path, "file must not contain NUL bytes");
2656
+ }
2657
+ if (byteLength(text) > maxBytes) {
2658
+ throw new SandboxLayerValidationError(path, `file exceeds ${maxBytes} byte limit`);
2659
+ }
2660
+ }
2661
+ function validateRepoRelativeArcanistPath(path, field = "path") {
2662
+ const normalized = path.trim();
2663
+ if (!normalized) throw new Error(`${field} is required`);
2664
+ if (/^[A-Za-z]:/.test(normalized)) throw new Error(`${field} must be a POSIX repo-relative path`);
2665
+ if (normalized.includes("\\")) throw new Error(`${field} must use POSIX '/' separators`);
2666
+ if (normalized.startsWith("/") || normalized.includes("\0")) throw new Error(`${field} must be repo-relative`);
2667
+ const parts = normalized.split("/");
2668
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
2669
+ throw new Error(`${field} cannot contain empty, '.', or '..' segments`);
2670
+ }
2671
+ if (!normalized.startsWith(".arcanist/")) throw new Error(`${field} must be under .arcanist/`);
2672
+ return normalized;
2673
+ }
2674
+ function yamlLineForPath(doc, keyPath) {
2675
+ let node = doc.contents;
2676
+ for (const segment of keyPath) {
2677
+ if (!isMap(node)) return void 0;
2678
+ const pair = node.items.find((item) => isScalar(item.key) && item.key.value === segment);
2679
+ if (!pair) return void 0;
2680
+ node = pair.value;
2681
+ }
2682
+ const range = node?.range;
2683
+ if (!range) return void 0;
2684
+ const before = doc.toString().slice(0, range[0]);
2685
+ return before.split(/\r?\n/).length;
2686
+ }
2687
+ function assertKnownKeys(path, doc, value) {
2688
+ const issues = [];
2689
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2690
+ return [issue(path, "manifest must be a YAML object", { code: "manifest_type" })];
2691
+ }
2692
+ const root = value;
2693
+ for (const key of Object.keys(root)) {
2694
+ if (!["version", "name", "layer", "smoke"].includes(key)) {
2695
+ issues.push(
2696
+ issue(
2697
+ path,
2698
+ key === "resources" ? "resources is not supported; resource sizing is Arcanist policy" : `unknown field '${key}'`,
2699
+ {
2700
+ line: yamlLineForPath(doc, [key]),
2701
+ field: key,
2702
+ code: key === "resources" ? "resources_not_allowed" : "unknown_field"
2703
+ }
2704
+ )
2705
+ );
2706
+ }
2707
+ }
2708
+ if (root.layer && typeof root.layer === "object" && !Array.isArray(root.layer)) {
2709
+ for (const key of Object.keys(root.layer)) {
2710
+ if (key !== "dockerfile") {
2711
+ issues.push(
2712
+ issue(path, `unknown field 'layer.${key}'`, {
2713
+ line: yamlLineForPath(doc, ["layer", key]),
2714
+ field: `layer.${key}`,
2715
+ code: "unknown_field"
2716
+ })
2717
+ );
2718
+ }
2719
+ }
2720
+ }
2721
+ if (root.smoke && typeof root.smoke === "object" && !Array.isArray(root.smoke)) {
2722
+ for (const key of Object.keys(root.smoke)) {
2723
+ if (key !== "commands") {
2724
+ issues.push(
2725
+ issue(path, `unknown field 'smoke.${key}'`, {
2726
+ line: yamlLineForPath(doc, ["smoke", key]),
2727
+ field: `smoke.${key}`,
2728
+ code: "unknown_field"
2729
+ })
2730
+ );
2731
+ }
2732
+ }
2733
+ }
2734
+ return issues;
2735
+ }
2736
+ function parseSandboxLayerManifest(path, text) {
2737
+ assertSize(path, text, MAX_MANIFEST_BYTES);
2738
+ rejectSecrets(path, text);
2739
+ let doc;
2740
+ try {
2741
+ doc = parseDocument(text, { strict: true });
2742
+ } catch (err) {
2743
+ throw new SandboxLayerValidationError(path, `invalid YAML: ${err instanceof Error ? err.message : String(err)}`);
2744
+ }
2745
+ if (doc.errors.length > 0) {
2746
+ const error = doc.errors[0];
2747
+ throw new SandboxLayerValidationError(path, `invalid YAML: ${error.message}`, error.linePos?.[0]?.line);
2748
+ }
2749
+ const parsed = doc.toJSON();
2750
+ const issues = assertKnownKeys(path, doc, parsed);
2751
+ const raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2752
+ if (!Object.prototype.hasOwnProperty.call(raw, "version")) {
2753
+ issues.push(issue(path, "version is required", { field: "version", code: "required" }));
2754
+ } else if (raw.version !== 1) {
2755
+ issues.push(
2756
+ issue(path, "version must be exactly 1", {
2757
+ line: yamlLineForPath(doc, ["version"]),
2758
+ field: "version",
2759
+ code: "invalid_version"
2760
+ })
2761
+ );
2762
+ }
2763
+ const name = raw.name == null ? null : typeof raw.name === "string" ? raw.name.trim() : "";
2764
+ if (raw.name != null && (typeof raw.name !== "string" || name === null || name.length < MIN_DISPLAY_NAME_CHARS || name.length > MAX_DISPLAY_NAME_CHARS)) {
2765
+ issues.push(
2766
+ issue(path, "name must be a 2-80 character display name", {
2767
+ line: yamlLineForPath(doc, ["name"]),
2768
+ field: "name",
2769
+ code: "invalid_name"
2770
+ })
2771
+ );
2772
+ }
2773
+ const layer = raw.layer;
2774
+ if (!layer || typeof layer !== "object" || Array.isArray(layer)) {
2775
+ issues.push(
2776
+ issue(path, "layer.dockerfile is required", {
2777
+ line: yamlLineForPath(doc, ["layer"]),
2778
+ field: "layer.dockerfile",
2779
+ code: "required"
2780
+ })
2781
+ );
2782
+ }
2783
+ let normalizedDockerfile;
2784
+ const dockerfile = layer && typeof layer === "object" && !Array.isArray(layer) ? layer.dockerfile : void 0;
2785
+ if (typeof dockerfile !== "string") {
2786
+ issues.push(
2787
+ issue(path, "layer.dockerfile must be a string", {
2788
+ line: yamlLineForPath(doc, ["layer", "dockerfile"]),
2789
+ field: "layer.dockerfile",
2790
+ code: "invalid_type"
2791
+ })
2792
+ );
2793
+ normalizedDockerfile = "";
2794
+ } else {
2795
+ try {
2796
+ normalizedDockerfile = validateRepoRelativeArcanistPath(dockerfile, "layer.dockerfile");
2797
+ } catch (err) {
2798
+ issues.push(
2799
+ issue(path, err instanceof Error ? err.message : String(err), {
2800
+ line: yamlLineForPath(doc, ["layer", "dockerfile"]),
2801
+ field: "layer.dockerfile",
2802
+ code: "invalid_path"
2803
+ })
2804
+ );
2805
+ normalizedDockerfile = "";
2806
+ }
2807
+ }
2808
+ const commandsNode = isMap(doc.contents) ? doc.getIn(["smoke", "commands"], true) : void 0;
2809
+ const rawCommands = raw.smoke?.commands ?? [];
2810
+ const smokeCommands = [];
2811
+ if (!Array.isArray(rawCommands)) {
2812
+ issues.push(
2813
+ issue(path, "smoke.commands must be an array", {
2814
+ line: yamlLineForPath(doc, ["smoke", "commands"]),
2815
+ field: "smoke.commands",
2816
+ code: "invalid_type"
2817
+ })
2818
+ );
2819
+ } else {
2820
+ if (rawCommands.length > MAX_SMOKE_COMMANDS) {
2821
+ issues.push(
2822
+ issue(path, `smoke.commands may contain at most ${MAX_SMOKE_COMMANDS} commands`, {
2823
+ line: yamlLineForPath(doc, ["smoke", "commands"]),
2824
+ field: "smoke.commands",
2825
+ code: "too_many_items"
2826
+ })
2827
+ );
2828
+ }
2829
+ rawCommands.forEach((command, index) => {
2830
+ const commandNode = isSeq(commandsNode) ? commandsNode.items[index] : void 0;
2831
+ const line = commandNode?.range ? text.slice(0, commandNode.range[0]).split(/\r?\n/).length : yamlLineForPath(doc, ["smoke", "commands"]);
2832
+ const field = `smoke.commands.${index}`;
2833
+ if (!Array.isArray(command)) {
2834
+ issues.push(
2835
+ issue(path, "smoke commands must be argument arrays, not shell strings", {
2836
+ line,
2837
+ field,
2838
+ code: "invalid_type"
2839
+ })
2840
+ );
2841
+ return;
2842
+ }
2843
+ if (command.length === 0 || command.length > MAX_SMOKE_ARGS) {
2844
+ issues.push(
2845
+ issue(path, `smoke commands must contain 1-${MAX_SMOKE_ARGS} args`, { line, field, code: "invalid_length" })
2846
+ );
2847
+ return;
2848
+ }
2849
+ const parsedCommand = [];
2850
+ command.forEach((arg, argIndex) => {
2851
+ const argField = `${field}.${argIndex}`;
2852
+ if (typeof arg !== "string" || arg.length === 0 || arg.includes("\0") || /[\r\n]/.test(arg) || byteLength(arg) > MAX_SMOKE_ARG_BYTES) {
2853
+ issues.push(
2854
+ issue(
2855
+ path,
2856
+ `smoke args must be non-empty strings up to ${MAX_SMOKE_ARG_BYTES} bytes with no NUL or newline`,
2857
+ { line, field: argField, code: "invalid_smoke_arg" }
2858
+ )
2859
+ );
2860
+ return;
2861
+ }
2862
+ if (SECRET_PATTERN.test(arg)) {
2863
+ issues.push(
2864
+ issue(path, "smoke args must not contain obvious secret material", {
2865
+ line,
2866
+ field: argField,
2867
+ code: "secret_like"
2868
+ })
2869
+ );
2870
+ return;
2871
+ }
2872
+ parsedCommand.push(arg);
2873
+ });
2874
+ if (parsedCommand.length === command.length) smokeCommands.push(parsedCommand);
2875
+ });
2876
+ }
2877
+ if (issues.length > 0) throw new SandboxLayerValidationError(issues);
2878
+ return { version: 1, name, layer: { dockerfile: normalizedDockerfile }, smoke: { commands: smokeCommands } };
2879
+ }
2880
+ function splitLogicalInstructions(path, text) {
2881
+ assertSize(path, text, MAX_LAYER_BYTES);
2882
+ rejectSecrets(path, text);
2883
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
2884
+ if (lines[lines.length - 1] === "") lines.pop();
2885
+ const result = [];
2886
+ let current = "";
2887
+ let startLine = 0;
2888
+ for (let index = 0; index < lines.length; index += 1) {
2889
+ const lineNumber = index + 1;
2890
+ const rawLine = lines[index];
2891
+ const trimmed = rawLine.trim();
2892
+ if (!current && (!trimmed || trimmed.startsWith("#"))) {
2893
+ if (/^#\s*syntax\s*=/i.test(trimmed)) {
2894
+ throw new SandboxLayerValidationError(path, "BuildKit syntax directives are not supported", lineNumber);
2895
+ }
2896
+ continue;
2897
+ }
2898
+ if (/<<\s*[-\w'"]+/.test(rawLine)) {
2899
+ throw new SandboxLayerValidationError(path, "heredocs are not supported in sandbox layer files", lineNumber);
2900
+ }
2901
+ if (!current) startLine = lineNumber;
2902
+ const continued = /\\\s*$/.test(rawLine);
2903
+ current += (current ? " " : "") + rawLine.replace(/\\\s*$/, "").trim();
2904
+ if (byteLength(current) > MAX_LAYER_BYTES) {
2905
+ throw new SandboxLayerValidationError(path, "continued instruction exceeds layer size limit", startLine);
2906
+ }
2907
+ if (!continued) {
2908
+ result.push({ text: current.trim(), startLine, endLine: lineNumber });
2909
+ current = "";
2910
+ startLine = 0;
2911
+ }
2912
+ }
2913
+ if (current) {
2914
+ throw new SandboxLayerValidationError(path, "unterminated line continuation", startLine);
2915
+ }
2916
+ return result;
2917
+ }
2918
+ function splitEnvTokens(path, body, startLine) {
2919
+ const tokens2 = [];
2920
+ let current = "";
2921
+ let quote = null;
2922
+ for (const char of body) {
2923
+ if (quote) {
2924
+ current += char;
2925
+ if (char === quote) quote = null;
2926
+ continue;
2927
+ }
2928
+ if (char === "'" || char === '"') {
2929
+ quote = char;
2930
+ current += char;
2931
+ continue;
2932
+ }
2933
+ if (/\s/.test(char)) {
2934
+ if (current) {
2935
+ tokens2.push(current);
2936
+ current = "";
2937
+ }
2938
+ continue;
2939
+ }
2940
+ current += char;
2941
+ }
2942
+ if (quote) throw new SandboxLayerValidationError(path, "ENV contains an unterminated quote", startLine);
2943
+ if (current) tokens2.push(current);
2944
+ return tokens2;
2945
+ }
2946
+ function parseEnvValues(path, body, startLine) {
2947
+ const values = {};
2948
+ const tokens2 = splitEnvTokens(path, body, startLine);
2949
+ if (tokens2.length > MAX_ENV_VARS) {
2950
+ throw new SandboxLayerValidationError(path, `ENV may set at most ${MAX_ENV_VARS} variables`, startLine);
2951
+ }
2952
+ for (const token of tokens2) {
2953
+ const separator = token.indexOf("=");
2954
+ if (separator <= 0) {
2955
+ throw new SandboxLayerValidationError(path, "ENV entries must use KEY=value form", startLine);
2956
+ }
2957
+ const key = token.slice(0, separator);
2958
+ let value = token.slice(separator + 1);
2959
+ if (!ENV_KEY_RE.test(key)) {
2960
+ throw new SandboxLayerValidationError(path, `invalid ENV key '${key}'`, startLine);
2961
+ }
2962
+ if (key.length > MAX_ENV_KEY_CHARS) {
2963
+ throw new SandboxLayerValidationError(path, `ENV key '${key}' is too long`, startLine);
2964
+ }
2965
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2966
+ value = value.slice(1, -1);
2967
+ }
2968
+ if (!value || value.includes("\0") || /[\r\n]/.test(value) || byteLength(value) > MAX_ENV_VALUE_BYTES) {
2969
+ throw new SandboxLayerValidationError(
2970
+ path,
2971
+ `ENV value for '${key}' must be 1-${MAX_ENV_VALUE_BYTES} bytes with no NUL or newline`,
2972
+ startLine
2973
+ );
2974
+ }
2975
+ if (SECRET_PATTERN.test(value)) {
2976
+ throw new SandboxLayerValidationError(
2977
+ path,
2978
+ `ENV value for '${key}' appears to contain obvious secret material`,
2979
+ startLine
2980
+ );
2981
+ }
2982
+ values[key] = value;
2983
+ }
2984
+ if (Object.keys(values).length === 0) {
2985
+ throw new SandboxLayerValidationError(path, "ENV must set at least one value", startLine);
2986
+ }
2987
+ return values;
2988
+ }
2989
+ function assertRunSafety(path, command, startLine) {
2990
+ if (byteLength(command) > MAX_RUN_COMMAND_BYTES) {
2991
+ throw new SandboxLayerValidationError(path, `RUN command exceeds ${MAX_RUN_COMMAND_BYTES} byte limit`, startLine);
2992
+ }
2993
+ if (/(^|\s)--mount=/.test(command)) {
2994
+ throw new SandboxLayerValidationError(path, "BuildKit RUN --mount is not supported", startLine);
2995
+ }
2996
+ if (SECRET_PATTERN.test(command)) {
2997
+ throw new SandboxLayerValidationError(path, "RUN command appears to contain obvious secret material", startLine);
2998
+ }
2999
+ const lower = command.toLowerCase();
3000
+ const targetsForbiddenPath = FORBIDDEN_PATHS.some((forbidden) => lower.includes(forbidden.toLowerCase()));
3001
+ if (targetsForbiddenPath && FORBIDDEN_TARGETING_OPS.test(lower)) {
3002
+ throw new SandboxLayerValidationError(path, "RUN modifies an Arcanist-owned runtime path", startLine);
3003
+ }
3004
+ }
3005
+ function parseSandboxLayerDockerfile(path, text) {
3006
+ const instructions = splitLogicalInstructions(path, text);
3007
+ return instructions.map((entry) => {
3008
+ const match = entry.text.match(/^([A-Za-z]+)\s*(.*)$/s);
3009
+ if (!match) {
3010
+ throw new SandboxLayerValidationError(path, "invalid Dockerfile instruction", entry.startLine);
3011
+ }
3012
+ const instruction = match[1].toUpperCase();
3013
+ const body = match[2].trim();
3014
+ if (instruction === "RUN") {
3015
+ if (!body) throw new SandboxLayerValidationError(path, "RUN must not be empty", entry.startLine);
3016
+ assertRunSafety(path, body, entry.startLine);
3017
+ return { kind: "run", command: body, startLine: entry.startLine, endLine: entry.endLine };
3018
+ }
3019
+ if (instruction === "ENV") {
3020
+ return {
3021
+ kind: "env",
3022
+ values: parseEnvValues(path, body, entry.startLine),
3023
+ startLine: entry.startLine,
3024
+ endLine: entry.endLine
3025
+ };
3026
+ }
3027
+ throw new SandboxLayerValidationError(
3028
+ path,
3029
+ `${instruction} is not supported in Arcanist sandbox layer files. V1 layers support only RUN and ENV.`,
3030
+ entry.startLine
3031
+ );
3032
+ });
3033
+ }
3034
+ function normalizeSandboxLayerInstructions(instructions) {
3035
+ return JSON.stringify(
3036
+ instructions.map((instruction) => {
3037
+ if (instruction.kind === "run") return { kind: "run", command: instruction.command };
3038
+ return {
3039
+ kind: "env",
3040
+ values: Object.fromEntries(
3041
+ Object.entries(instruction.values).sort(([left], [right]) => left.localeCompare(right))
3042
+ )
3043
+ };
3044
+ })
3045
+ );
3046
+ }
3047
+ function sortJsonValue(value) {
3048
+ if (Array.isArray(value)) return value.map(sortJsonValue);
3049
+ if (!value || typeof value !== "object") return value;
3050
+ return Object.fromEntries(
3051
+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, sortJsonValue(entry)])
3052
+ );
3053
+ }
3054
+ function canonicalSandboxLayerSourceJson(input) {
3055
+ return JSON.stringify(
3056
+ sortJsonValue({
3057
+ schemaVersion: 1,
3058
+ manifest: {
3059
+ version: input.manifest.version,
3060
+ name: input.manifest.name,
3061
+ layerDockerfile: input.manifest.layer.dockerfile,
3062
+ smokeCommands: input.manifest.smoke.commands
3063
+ },
3064
+ layer: JSON.parse(normalizeSandboxLayerInstructions(input.instructions)),
3065
+ buildIdentity: input.buildIdentity ?? null
3066
+ })
3067
+ );
3068
+ }
3069
+ async function parseSandboxLayerSource(input) {
3070
+ const manifest = parseSandboxLayerManifest(input.manifestPath, input.manifestText);
3071
+ if (manifest.layer.dockerfile !== input.layerPath) {
3072
+ throw new SandboxLayerValidationError(
3073
+ input.manifestPath,
3074
+ `layer.dockerfile must match fetched layer path '${input.layerPath}'`,
3075
+ void 0,
3076
+ "layer.dockerfile",
3077
+ "layer_path_mismatch"
3078
+ );
3079
+ }
3080
+ const instructions = parseSandboxLayerDockerfile(input.layerPath, input.layerText);
3081
+ const canonicalSource = canonicalSandboxLayerSourceJson({
3082
+ manifest,
3083
+ instructions,
3084
+ buildIdentity: input.buildIdentity
3085
+ });
3086
+ return {
3087
+ manifest: {
3088
+ version: manifest.version,
3089
+ name: manifest.name,
3090
+ layerDockerfile: manifest.layer.dockerfile,
3091
+ smokeCommands: manifest.smoke.commands
3092
+ },
3093
+ layer: { instructions },
3094
+ hashes: {
3095
+ manifestHash: await computeSha256Hex(input.manifestText),
3096
+ layerHash: await computeSha256Hex(input.layerText),
3097
+ normalizedSourceHash: await computeSha256Hex(canonicalSource)
3098
+ }
3099
+ };
3100
+ }
3101
+
3102
+ // src/commands/sandbox.ts
3103
+ var DEFAULT_MANIFEST_PATH = SANDBOX_LAYER_MANIFEST_PATH;
3104
+ var DEFAULT_POLL_INTERVAL_MS = 2500;
3105
+ var TERMINAL_BUILD_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
3106
+ function git(args) {
3107
+ try {
3108
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3109
+ } catch (err) {
3110
+ throw new CliError("user", `git ${args.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`);
3111
+ }
3112
+ }
3113
+ function currentRepo() {
3114
+ const remote = git(["remote", "get-url", "origin"]);
3115
+ const parsed = parseGithubRepoFullName(remote);
3116
+ if (!parsed) throw new CliError("user", "origin remote must point at a GitHub repository");
3117
+ return { owner: parsed.owner, repo: parsed.repo };
3118
+ }
3119
+ function currentRef() {
3120
+ return git(["rev-parse", "--abbrev-ref", "HEAD"]);
3121
+ }
3122
+ function assertCleanAndPushed() {
3123
+ const dirty = git(["status", "--porcelain"]);
3124
+ if (dirty) throw new CliError("user", "Sandbox layer source must be committed before build.");
3125
+ const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
3126
+ const ahead = git(["rev-list", "--count", `${upstream}..HEAD`]);
3127
+ if (ahead !== "0")
3128
+ throw new CliError("user", "Current commit is not pushed; push before building the sandbox layer.");
3129
+ }
3130
+ function assertManifestExists(path) {
3131
+ if (!existsSync(path)) throw new CliError("user", `Missing sandbox manifest: ${path}`);
3132
+ }
3133
+ function parseRepoArg(value, fallback) {
3134
+ if (!value) return fallback();
3135
+ const parsed = parseGithubRepoFullName(value);
3136
+ if (!parsed) throw new CliError("user", "repo must be owner/name or a GitHub URL");
3137
+ return { owner: parsed.owner, repo: parsed.repo };
3138
+ }
3139
+ function repoPath(repo) {
3140
+ return `${repo.owner}/${repo.repo}`;
3141
+ }
3142
+ function encodePathSegment(value) {
3143
+ return encodeURIComponent(value);
3144
+ }
3145
+ async function resolveBusinessId(config, options) {
3146
+ if (options.business && options.business.trim()) return options.business.trim();
3147
+ const whoami = await apiFetch(config, "/api/auth/whoami");
3148
+ if (whoami.businessId && whoami.businessId.trim()) return whoami.businessId;
3149
+ throw new CliError("user", "--business is required when the authenticated token has no business context.");
3150
+ }
3151
+ function parsePollInterval2(value) {
3152
+ if (!value) return DEFAULT_POLL_INTERVAL_MS;
3153
+ const parsed = Number(value);
3154
+ if (!Number.isInteger(parsed) || parsed < 250) {
3155
+ throw new CliError("user", "--poll-interval must be an integer >= 250.");
3156
+ }
3157
+ return parsed;
3158
+ }
3159
+ function sleep2(ms) {
3160
+ return new Promise((resolve) => setTimeout(resolve, ms));
3161
+ }
3162
+ function printBuildRequest(buildRequest) {
3163
+ console.log(
3164
+ [
3165
+ `Build ${buildRequest.id} is ${buildRequest.status}.`,
3166
+ buildRequest.sourceRepo ? `source=${buildRequest.sourceRepo}` : null,
3167
+ buildRequest.targetRepo ? `target=${buildRequest.targetRepo}` : null,
3168
+ buildRequest.commitSha ? `commit=${buildRequest.commitSha}` : null,
3169
+ buildRequest.resourceProfileKey ? `profile=${buildRequest.resourceProfileKey}` : null,
3170
+ buildRequest.createdBy ? `builtBy=${buildActorLabel(buildRequest.createdBy)}` : null
3171
+ ].filter(Boolean).join(" ")
3172
+ );
3173
+ }
3174
+ function buildActorLabel(actor) {
3175
+ if (!actor) return "unknown";
3176
+ if (actor.login) return `@${actor.login}`;
3177
+ if (actor.name) return actor.name;
3178
+ return `User ${actor.userId}`;
3179
+ }
3180
+ function phaseLabel(phase) {
3181
+ return phase === "provider_build" ? "provider build" : phase;
3182
+ }
3183
+ function printActiveTemplateStatus(buildRequest) {
3184
+ if (buildRequest.status === "completed") {
3185
+ const template = buildRequest.activeTemplateRef ?? buildRequest.providerArtifactRef;
3186
+ console.log(template ? `Active template updated to ${template}.` : "Active: yes");
3187
+ return;
3188
+ }
3189
+ if (buildRequest.activeTemplateRef) {
3190
+ console.log("Active template unchanged; previous successful sandbox remains active.");
3191
+ } else {
3192
+ console.log("No previous active sandbox; sessions will use Arcanist default.");
3193
+ }
3194
+ }
3195
+ function printTerminalBuildSummary(buildRequest) {
3196
+ if (buildRequest.status === "completed") {
3197
+ console.log("Sandbox build completed.");
3198
+ if (buildRequest.sourceRepo) console.log(`Source: ${buildRequest.sourceRepo}`);
3199
+ if (buildRequest.commitSha) console.log(`Commit: ${buildRequest.commitSha}`);
3200
+ if (buildRequest.baseTemplateRef && buildRequest.baseVersion) {
3201
+ console.log(`Built from: ${buildRequest.baseTemplateRef} @ ${buildRequest.baseVersion}`);
3202
+ }
3203
+ if (buildRequest.createdBy) console.log(`Built by: ${buildActorLabel(buildRequest.createdBy)}`);
3204
+ const template = buildRequest.providerArtifactRef ?? buildRequest.activeTemplateRef;
3205
+ if (template) console.log(`Template: ${template}`);
3206
+ console.log("Smoke: passed");
3207
+ console.log("Active: yes");
3208
+ printActiveTemplateStatus(buildRequest);
3209
+ return;
3210
+ }
3211
+ const failure = buildRequest.failureSummary;
3212
+ const phase = failure ? phaseLabel(failure.phase) : buildRequest.status;
3213
+ console.log(`Sandbox build failed during ${phase}.`);
3214
+ if (buildRequest.createdBy) console.log(`Built by: ${buildActorLabel(buildRequest.createdBy)}`);
3215
+ if (failure?.command) console.log(`Command: ${failure.command}`);
3216
+ if (failure?.exitCode !== void 0) console.log(`Exit code: ${failure.exitCode}`);
3217
+ console.log(`Reason: ${failure?.reason ?? `Build finished with status ${buildRequest.status}`}`);
3218
+ if (failure?.stdoutPreview) console.log(`Stdout: ${failure.stdoutPreview}`);
3219
+ if (failure?.stderrPreview) console.log(`Stderr: ${failure.stderrPreview}`);
3220
+ printActiveTemplateStatus(buildRequest);
3221
+ }
3222
+ function printBuildHistory(builds) {
3223
+ if (builds.length === 0) {
3224
+ console.log("No sandbox builds found.");
3225
+ return;
3226
+ }
3227
+ for (const build of builds) {
3228
+ console.log(
3229
+ [
3230
+ `Build ${build.id} is ${build.status}.`,
3231
+ `source=${build.sourceRepo}`,
3232
+ `commit=${build.commitSha}`,
3233
+ `profile=${build.resourceProfileKey}`,
3234
+ build.templateId ? `template=${build.templateId}` : null,
3235
+ `base=${build.baseTemplateRef}@${build.baseVersion}`,
3236
+ `builtBy=${buildActorLabel(build.createdBy)}`
3237
+ ].filter(Boolean).join(" ")
3238
+ );
3239
+ }
3240
+ }
3241
+ function selectionTierLabel(tier) {
3242
+ if (tier === "repo_local") return "repo-local";
3243
+ if (tier === "repo_assignment") return "assigned";
3244
+ return "workspace default";
3245
+ }
3246
+ function printSandboxStatus(payload) {
3247
+ if (payload.selection) {
3248
+ console.log(`Using ${selectionTierLabel(payload.selection.tier)} sandbox.`);
3249
+ console.log(`Template: ${payload.selection.templateId}`);
3250
+ console.log(`Source: ${payload.selection.sourceRepo}`);
3251
+ console.log(`Commit: ${payload.selection.commitSha}`);
3252
+ console.log(`Profile: ${payload.selection.resourceProfileKey}`);
3253
+ console.log(`Built from: ${payload.selection.baseTemplateRef} @ ${payload.selection.baseVersion}`);
3254
+ console.log(`Base status: ${payload.selection.baseStatus}`);
3255
+ console.log(`Built by: ${buildActorLabel(payload.selection.createdBy)}`);
3256
+ return;
3257
+ }
3258
+ console.log("Using Arcanist default sandbox.");
3259
+ if (payload.fallback?.templateId) console.log(`Template: ${payload.fallback.templateId}`);
3260
+ if (payload.latestRepoBuild) {
3261
+ console.log(`Latest custom build: ${payload.latestRepoBuild.status}`);
3262
+ console.log(`Commit: ${payload.latestRepoBuild.commitSha}`);
3263
+ console.log(`Profile: ${payload.latestRepoBuild.resourceProfileKey}`);
3264
+ console.log(`Built from: ${payload.latestRepoBuild.baseTemplateRef} @ ${payload.latestRepoBuild.baseVersion}`);
3265
+ console.log(`Built by: ${buildActorLabel(payload.latestRepoBuild.createdBy)}`);
3266
+ }
3267
+ }
3268
+ function printLogs(logs) {
3269
+ for (const log of logs) {
3270
+ console.log(log.message);
3271
+ }
3272
+ }
3273
+ function buildLogsPath(businessId, buildId, options = {}) {
3274
+ const params = new URLSearchParams();
3275
+ if (options.afterSequence != null) params.set("afterSequence", String(options.afterSequence));
3276
+ if (options.limit != null) params.set("limit", String(options.limit));
3277
+ const suffix = params.toString() ? `?${params.toString()}` : "";
3278
+ return `/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/build-requests/${encodePathSegment(
3279
+ buildId
3280
+ )}/logs${suffix}`;
3281
+ }
3282
+ async function waitForBuild(config, businessId, buildId, options) {
3283
+ let afterSequence;
3284
+ for (; ; ) {
3285
+ if (options.follow) {
3286
+ const logs = await apiFetch(config, buildLogsPath(businessId, buildId, { afterSequence }));
3287
+ if (logs.logs.length > 0) {
3288
+ if (options.json) writeJson({ ok: true, logs: logs.logs });
3289
+ else printLogs(logs.logs);
3290
+ afterSequence = logs.logs[logs.logs.length - 1].sequence;
3291
+ }
3292
+ }
3293
+ const status = await apiFetch(
3294
+ config,
3295
+ `/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/build-requests/${encodePathSegment(buildId)}`
3296
+ );
3297
+ if (TERMINAL_BUILD_STATUSES.has(status.buildRequest.status)) {
3298
+ return status.buildRequest;
3299
+ }
3300
+ await sleep2(options.pollIntervalMs);
3301
+ }
3302
+ }
3303
+ function formatValidationPayload(input) {
3304
+ return {
3305
+ ok: true,
3306
+ manifestPath: input.manifestPath,
3307
+ layerPath: input.layerPath,
3308
+ normalizedSourceHash: input.parsed.hashes.normalizedSourceHash,
3309
+ instructionCount: input.parsed.layer.instructions.length,
3310
+ smokeCommandCount: input.parsed.manifest.smokeCommands.length,
3311
+ diagnostics: []
3312
+ };
3313
+ }
3314
+ function nextBuildCommand(manifestPath) {
3315
+ try {
3316
+ const repo = currentRepo();
3317
+ const manifestArg = manifestPath === DEFAULT_MANIFEST_PATH ? "" : ` --manifest ${manifestPath}`;
3318
+ return `arcanist sandbox build ${repoPath(repo)} --wait --follow${manifestArg}`;
3319
+ } catch {
3320
+ return "configure a GitHub origin, then run arcanist sandbox build <owner/repo> --wait --follow";
3321
+ }
3322
+ }
3323
+ function handleValidationError(err, options, command) {
3324
+ if (err instanceof SandboxLayerValidationError) {
3325
+ const payload = { ok: false, diagnostics: err.issues };
3326
+ if (isJson(command, options)) {
3327
+ writeJson(payload);
3328
+ process.exitCode = 1;
3329
+ return;
3330
+ }
3331
+ throw new CliError(
3332
+ "user",
3333
+ err.issues.map((issue2) => `${issue2.path}${issue2.line ? `:${issue2.line}` : ""}: ${issue2.message}`).join("\n")
3334
+ );
3335
+ }
3336
+ throw err;
3337
+ }
3338
+ function sourceBody(sourceRepo, manifestPath) {
3339
+ return {
3340
+ sourceRepoOwner: sourceRepo.owner,
3341
+ sourceRepoName: sourceRepo.repo,
3342
+ manifestPath
3343
+ };
3344
+ }
3345
+ async function sandboxInitCommand(options = {}, command) {
3346
+ const runtime = getRuntimeOptions(command, options);
3347
+ if (existsSync(DEFAULT_MANIFEST_PATH)) throw new CliError("conflict", `${DEFAULT_MANIFEST_PATH} already exists`);
3348
+ const layerPath = ".arcanist/sandbox.layer.Dockerfile";
3349
+ if (existsSync(layerPath)) throw new CliError("conflict", `${layerPath} already exists`);
3350
+ mkdirSync2(dirname(DEFAULT_MANIFEST_PATH), { recursive: true });
3351
+ writeFileSync2(
3352
+ DEFAULT_MANIFEST_PATH,
3353
+ [
3354
+ "version: 1",
3355
+ "layer:",
3356
+ " dockerfile: .arcanist/sandbox.layer.Dockerfile",
3357
+ "smoke:",
3358
+ " commands:",
3359
+ ' - ["bash", "-lc", "command -v git && command -v python3"]',
3360
+ ""
3361
+ ].join("\n"),
3362
+ "utf8"
3363
+ );
3364
+ writeFileSync2(
3365
+ layerPath,
3366
+ [
3367
+ "# Only RUN and ENV instructions are supported.",
3368
+ "# Example:",
3369
+ "# RUN apt-get update && apt-get install -y --no-install-recommends ripgrep && rm -rf /var/lib/apt/lists/*",
3370
+ ""
3371
+ ].join("\n"),
3372
+ "utf8"
3373
+ );
3374
+ const payload = { ok: true, manifestPath: DEFAULT_MANIFEST_PATH, layerPath };
3375
+ if (isJson(command, runtime)) {
3376
+ writeJson(payload);
3377
+ return;
3378
+ }
3379
+ console.log(`Created ${DEFAULT_MANIFEST_PATH} and ${layerPath}`);
3380
+ }
3381
+ async function sandboxValidateCommand(options = {}, command) {
3382
+ const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
3383
+ assertManifestExists(manifestPath);
3384
+ const manifestText = readFileSync2(manifestPath, "utf8");
3385
+ let layerPath;
3386
+ try {
3387
+ layerPath = parseSandboxLayerManifest(manifestPath, manifestText).layer.dockerfile;
3388
+ assertManifestExists(layerPath);
3389
+ const layerText = readFileSync2(layerPath, "utf8");
3390
+ const parsed = await parseSandboxLayerSource({ manifestPath, manifestText, layerPath, layerText });
3391
+ const payload = formatValidationPayload({ manifestPath, layerPath, parsed });
3392
+ if (isJson(command, options)) {
3393
+ writeJson(payload);
3394
+ return;
3395
+ }
3396
+ console.log("Valid sandbox template.");
3397
+ console.log(`Manifest: ${payload.manifestPath}`);
3398
+ console.log(`Layer: ${payload.layerPath}`);
3399
+ console.log(`Normalized source hash: ${payload.normalizedSourceHash}`);
3400
+ console.log(`Instructions: ${payload.instructionCount}`);
3401
+ console.log(`Smoke commands: ${payload.smokeCommandCount}`);
3402
+ console.log(`Next: ${nextBuildCommand(manifestPath)}`);
3403
+ } catch (err) {
3404
+ handleValidationError(err, options, command);
3405
+ }
3406
+ }
3407
+ async function sandboxBuildCommand(sourceRepoArg, options = {}, command) {
3408
+ const runtime = getRuntimeOptions(command, options);
3409
+ const config = requireConfig(runtime);
3410
+ const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
3411
+ assertManifestExists(manifestPath);
3412
+ if (!options.ref) assertCleanAndPushed();
3413
+ const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
3414
+ const targetRepo = options.targetRepo ? parseRepoArg(options.targetRepo, currentRepo) : null;
3415
+ const businessId = await resolveBusinessId(config, options);
3416
+ const ref = options.ref ?? currentRef();
3417
+ const payload = await apiFetch(
3418
+ config,
3419
+ `/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(sourceRepo.owner)}/${encodePathSegment(
3420
+ sourceRepo.repo
3421
+ )}/sandbox-layer/build-requests`,
3422
+ {
3423
+ method: "POST",
3424
+ body: JSON.stringify({
3425
+ ref,
3426
+ manifestPath,
3427
+ ...targetRepo ? { targetRepo: { owner: targetRepo.owner, name: targetRepo.repo } } : {}
3428
+ })
3429
+ }
3430
+ );
3431
+ if (isJson(command, options)) writeJson(payload);
3432
+ else printBuildRequest(payload.buildRequest);
3433
+ if (!options.wait && !options.follow) return;
3434
+ const json = isJson(command, options);
3435
+ const finalBuild = await waitForBuild(config, businessId, payload.buildRequest.id, {
3436
+ follow: options.follow,
3437
+ pollIntervalMs: parsePollInterval2(options.pollInterval),
3438
+ json
3439
+ });
3440
+ if (json) writeJson({ ok: true, buildRequest: finalBuild });
3441
+ else printTerminalBuildSummary(finalBuild);
3442
+ if (finalBuild.status !== "completed") {
3443
+ process.exitCode = 1;
3444
+ }
3445
+ }
3446
+ async function sandboxStatusCommand(repoArg, options = {}, command) {
3447
+ const runtime = getRuntimeOptions(command, options);
3448
+ const config = requireConfig(runtime);
3449
+ const businessId = await resolveBusinessId(config, options);
3450
+ const repo = parseRepoArg(repoArg, currentRepo);
3451
+ const payload = await apiFetch(
3452
+ config,
3453
+ `/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(repo.owner)}/${encodePathSegment(
3454
+ repo.repo
3455
+ )}/sandbox-layer/resolution`
3456
+ );
3457
+ if (isJson(command, options)) {
3458
+ writeJson(payload);
3459
+ return;
3460
+ }
3461
+ printSandboxStatus(payload);
3462
+ }
3463
+ async function sandboxHistoryCommand(sourceRepoArg, options = {}, command) {
3464
+ const runtime = getRuntimeOptions(command, options);
3465
+ const config = requireConfig(runtime);
3466
+ const businessId = await resolveBusinessId(config, options);
3467
+ const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
3468
+ const params = new URLSearchParams({ sourceRepo: repoPath(sourceRepo) });
3469
+ if (options.targetRepo) params.set("targetRepo", repoPath(parseRepoArg(options.targetRepo, currentRepo)));
3470
+ if (options.status) params.set("status", options.status);
3471
+ if (options.limit) {
3472
+ const limit = Number(options.limit);
3473
+ if (!Number.isInteger(limit) || limit < 1) {
3474
+ throw new CliError("user", "--limit must be a positive integer.");
3475
+ }
3476
+ params.set("limit", String(limit));
3477
+ }
3478
+ const payload = await apiFetch(
3479
+ config,
3480
+ `/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/build-requests?${params.toString()}`
3481
+ );
3482
+ if (isJson(command, options)) {
3483
+ writeJson(payload);
3484
+ return;
3485
+ }
3486
+ printBuildHistory(payload.builds);
3487
+ }
3488
+ async function sandboxRebuildStaleCommand(options = {}, command) {
3489
+ const runtime = getRuntimeOptions(command, options);
3490
+ const config = requireConfig(runtime);
3491
+ if (!options.dryRun && !options.yes) {
3492
+ throw new CliError("user", "Use --dry-run to preview or --yes to start a rebuild campaign.");
3493
+ }
3494
+ if (options.all && options.business) {
3495
+ throw new CliError("user", "Use either --all or --business, not both.");
3496
+ }
3497
+ const businessId = options.all ? null : await resolveBusinessId(config, options);
3498
+ let payload = await apiFetch(config, "/api/admin/sandbox-layer/rebuild-campaigns", {
3499
+ method: "POST",
3500
+ body: JSON.stringify({
3501
+ scope: options.all ? "all" : "business",
3502
+ ...businessId ? { businessId } : {},
3503
+ reason: "base_update",
3504
+ dryRun: Boolean(options.dryRun)
3505
+ })
3506
+ }).catch((error) => {
3507
+ if (error instanceof CliError && /403|Forbidden/.test(error.message)) {
3508
+ throw new CliError("auth", "Only Arcanist internal admins can rebuild stale sandbox layers.");
3509
+ }
3510
+ throw error;
3511
+ });
3512
+ if (options.follow && !options.dryRun) {
3513
+ payload = await followRebuildCampaign(config, payload.campaign.id);
3514
+ }
3515
+ if (isJson(command, options)) {
3516
+ writeJson(payload);
3517
+ return;
3518
+ }
3519
+ printRebuildCampaign(payload);
3520
+ }
3521
+ async function followRebuildCampaign(config, campaignId) {
3522
+ for (; ; ) {
3523
+ const payload = await apiFetch(
3524
+ config,
3525
+ `/api/admin/sandbox-layer/rebuild-campaigns/${encodePathSegment(campaignId)}`
3526
+ );
3527
+ if (isCampaignTerminal(payload.campaign.status)) return payload;
3528
+ await sleep2(DEFAULT_POLL_INTERVAL_MS);
3529
+ }
3530
+ }
3531
+ function isCampaignTerminal(status) {
3532
+ return status === "completed" || status === "completed_with_failures" || status === "failed";
3533
+ }
3534
+ function printRebuildCampaign(payload) {
3535
+ console.log(`Campaign: ${payload.campaign.id}`);
3536
+ console.log(`Status: ${payload.campaign.status}`);
3537
+ console.log(`Active artifacts scanned: ${payload.campaign.summary.activeArtifactsScanned}`);
3538
+ console.log(`Stale artifacts found: ${payload.campaign.summary.staleArtifactsFound}`);
3539
+ console.log(`Current artifacts skipped: ${payload.campaign.summary.currentArtifactsSkipped}`);
3540
+ console.log(`Missing installation skips: ${payload.campaign.summary.missingInstallationSkips}`);
3541
+ console.log(`Source unavailable skips: ${payload.campaign.summary.sourceUnavailableSkips}`);
3542
+ console.log(`Unversioned base skips: ${payload.campaign.summary.unversionedBaseSkips}`);
3543
+ console.log(`Active changed skips: ${payload.campaign.summary.activeChangedSkips}`);
3544
+ console.log(`Failed items: ${payload.campaign.summary.failedItems}`);
3545
+ console.log(`Promoted items: ${payload.campaign.summary.promotedItems}`);
3546
+ console.log(`Builds queued: ${payload.campaign.summary.buildsQueued}`);
3547
+ if (payload.campaign.status === "completed_with_failures" || payload.campaign.status === "failed") {
3548
+ console.log("Previous active templates remain active for failed or skipped rebuild items.");
3549
+ }
3550
+ }
3551
+ async function sandboxLogsCommand(buildId, options = {}, command) {
3552
+ const runtime = getRuntimeOptions(command, options);
3553
+ const config = requireConfig(runtime);
3554
+ const businessId = await resolveBusinessId(config, options);
3555
+ let afterSequence = options.afterSequence == null ? void 0 : Number(options.afterSequence);
3556
+ if (afterSequence != null && (!Number.isInteger(afterSequence) || afterSequence < 0)) {
3557
+ throw new CliError("user", "--after-sequence must be a non-negative integer.");
3558
+ }
3559
+ const limit = options.limit == null ? void 0 : Number(options.limit);
3560
+ if (limit != null && (!Number.isInteger(limit) || limit < 1)) {
3561
+ throw new CliError("user", "--limit must be a positive integer.");
3562
+ }
3563
+ for (; ; ) {
3564
+ const payload = await apiFetch(
3565
+ config,
3566
+ buildLogsPath(businessId, buildId, { afterSequence, limit })
3567
+ );
3568
+ if (isJson(command, options)) writeJson(payload);
3569
+ else printLogs(payload.logs);
3570
+ if (!options.follow) return;
3571
+ if (payload.logs.length > 0) afterSequence = payload.logs[payload.logs.length - 1].sequence;
3572
+ await sleep2(parsePollInterval2(options.pollInterval));
3573
+ }
3574
+ }
3575
+ async function sandboxAssignDefaultCommand(sourceRepoArg, options = {}, command) {
3576
+ const runtime = getRuntimeOptions(command, options);
3577
+ const config = requireConfig(runtime);
3578
+ const businessId = await resolveBusinessId(config, options);
3579
+ const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
3580
+ const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
3581
+ const payload = await apiFetch(
3582
+ config,
3583
+ `/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/default-source`,
3584
+ { method: "PUT", body: JSON.stringify(sourceBody(sourceRepo, manifestPath)) }
3585
+ );
3586
+ if (isJson(command, options)) {
3587
+ writeJson(payload);
3588
+ return;
3589
+ }
3590
+ console.log(`Set default sandbox layer source to ${repoPath(sourceRepo)}.`);
3591
+ console.log(
3592
+ `Build missing target coverage with: arcanist sandbox build ${repoPath(sourceRepo)} --business ${businessId}`
3593
+ );
3594
+ }
3595
+ async function sandboxAssignRepoCommand(targetRepoArg, sourceRepoArg, options = {}, command) {
3596
+ const runtime = getRuntimeOptions(command, options);
3597
+ const config = requireConfig(runtime);
3598
+ const businessId = await resolveBusinessId(config, options);
3599
+ const targetRepo = parseRepoArg(targetRepoArg, currentRepo);
3600
+ const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
3601
+ const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
3602
+ const payload = await apiFetch(
3603
+ config,
3604
+ `/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(targetRepo.owner)}/${encodePathSegment(
3605
+ targetRepo.repo
3606
+ )}/sandbox-layer/assignment`,
3607
+ { method: "PUT", body: JSON.stringify(sourceBody(sourceRepo, manifestPath)) }
3608
+ );
3609
+ if (isJson(command, options)) {
3610
+ writeJson(payload);
3611
+ return;
3612
+ }
3613
+ console.log(`Assigned ${repoPath(sourceRepo)} as sandbox layer source for ${repoPath(targetRepo)}.`);
3614
+ console.log(
3615
+ `Build target coverage with: arcanist sandbox build ${repoPath(sourceRepo)} --target-repo ${repoPath(
3616
+ targetRepo
3617
+ )} --business ${businessId}`
3618
+ );
3619
+ }
3620
+ async function sandboxUnassignDefaultCommand(options = {}, command) {
3621
+ const runtime = getRuntimeOptions(command, options);
3622
+ const config = requireConfig(runtime);
3623
+ const businessId = await resolveBusinessId(config, options);
3624
+ if (!options.yes) await confirmOrThrow("Clear the business default sandbox layer source?");
3625
+ const payload = await apiFetch(
3626
+ config,
3627
+ `/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/default-source`,
3628
+ { method: "DELETE" }
3629
+ );
3630
+ if (isJson(command, options)) {
3631
+ writeJson(payload);
3632
+ return;
3633
+ }
3634
+ console.log("Cleared default sandbox layer source.");
3635
+ }
3636
+ async function sandboxUnassignRepoCommand(targetRepoArg, options = {}, command) {
3637
+ const runtime = getRuntimeOptions(command, options);
3638
+ const config = requireConfig(runtime);
3639
+ const businessId = await resolveBusinessId(config, options);
3640
+ const targetRepo = parseRepoArg(targetRepoArg, currentRepo);
3641
+ if (!options.yes) await confirmOrThrow(`Clear sandbox layer assignment for ${repoPath(targetRepo)}?`);
3642
+ const payload = await apiFetch(
3643
+ config,
3644
+ `/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(targetRepo.owner)}/${encodePathSegment(
3645
+ targetRepo.repo
3646
+ )}/sandbox-layer/assignment`,
3647
+ { method: "DELETE" }
3648
+ );
3649
+ if (isJson(command, options)) {
3650
+ writeJson(payload);
3651
+ return;
3652
+ }
3653
+ console.log(`Cleared sandbox layer assignment for ${repoPath(targetRepo)}.`);
3654
+ }
3655
+
2544
3656
  // src/commands/sessions.ts
2545
3657
  async function listSessionsCommand(options, command) {
2546
3658
  const runtime = getRuntimeOptions(command, options);
@@ -2706,8 +3818,8 @@ function parseStopBlocked(err) {
2706
3818
  }
2707
3819
 
2708
3820
  // src/commands/test-creds.ts
2709
- import { readFileSync as readFileSync2 } from "fs";
2710
- function parseRepoArg(repo) {
3821
+ import { readFileSync as readFileSync3 } from "fs";
3822
+ function parseRepoArg2(repo) {
2711
3823
  const trimmed = repo.trim();
2712
3824
  const slashIndex = trimmed.indexOf("/");
2713
3825
  if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
@@ -2724,13 +3836,13 @@ function readValue(options) {
2724
3836
  throw new CliError("user", "Provide exactly one of --value, --value-file, or --value-stdin.");
2725
3837
  }
2726
3838
  if (options.value !== void 0) return options.value;
2727
- const raw = options.valueFile ? readFileSync2(options.valueFile, "utf8") : readFileSync2(0, "utf8");
3839
+ const raw = options.valueFile ? readFileSync3(options.valueFile, "utf8") : readFileSync3(0, "utf8");
2728
3840
  return raw.replace(/\r?\n$/, "");
2729
3841
  }
2730
3842
  async function listTestCredentialsCommand(repo, options, command) {
2731
3843
  const runtime = getRuntimeOptions(command, options);
2732
3844
  const config = requireConfig(runtime);
2733
- const repoArg = parseRepoArg(repo);
3845
+ const repoArg = parseRepoArg2(repo);
2734
3846
  const payload = await apiFetch(config, basePath(options.business, repoArg));
2735
3847
  if (isJson(command, options)) {
2736
3848
  writeJson(payload);
@@ -2748,7 +3860,7 @@ async function listTestCredentialsCommand(repo, options, command) {
2748
3860
  async function setTestCredentialCommand(repo, name, options, command) {
2749
3861
  const runtime = getRuntimeOptions(command, options);
2750
3862
  const config = requireConfig(runtime);
2751
- const repoArg = parseRepoArg(repo);
3863
+ const repoArg = parseRepoArg2(repo);
2752
3864
  const value = readValue(options);
2753
3865
  const payload = await apiFetch(
2754
3866
  config,
@@ -2766,12 +3878,12 @@ async function deleteTestCredentialCommand(repo, name, options, command) {
2766
3878
  throw new CliError("user", "`test-creds delete --json` requires --yes.");
2767
3879
  }
2768
3880
  if (options.yes !== true) {
2769
- const repoArg2 = parseRepoArg(repo);
3881
+ const repoArg2 = parseRepoArg2(repo);
2770
3882
  await confirmOrThrow(`Delete test credential '${name}' for ${repoArg2.owner}/${repoArg2.name}?`);
2771
3883
  }
2772
3884
  const runtime = getRuntimeOptions(command, options);
2773
3885
  const config = requireConfig(runtime);
2774
- const repoArg = parseRepoArg(repo);
3886
+ const repoArg = parseRepoArg2(repo);
2775
3887
  const payload = await apiFetch(
2776
3888
  config,
2777
3889
  `${basePath(options.business, repoArg)}/${encodeURIComponent(name)}`,
@@ -3016,6 +4128,22 @@ Examples:
3016
4128
  arcanist sessions usage <session-id> --json
3017
4129
  `
3018
4130
  ).action((sessionId, options, command) => usageCommand(sessionId, options, command));
4131
+ var sandbox = program.command("sandbox").description("Sandbox layer commands");
4132
+ sandbox.command("init").description("Create sandbox layer source files").action((options, command) => sandboxInitCommand(options, command));
4133
+ sandbox.command("validate").description("Validate local sandbox layer source files").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").action((options, command) => sandboxValidateCommand(options, command));
4134
+ sandbox.command("build").description("Build a repo-sourced sandbox layer").argument("[source-repo]", "Source repository owner/name; defaults to current git remote").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").option("--ref <ref>", "Git ref to build").option("--target-repo <repo>", "Target repository owner/name for resource-profile coverage").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--wait", "Wait for the build to finish").option("--follow", "Follow logs while waiting").option("--poll-interval <ms>", "Polling interval in milliseconds").action((sourceRepo, options, command) => sandboxBuildCommand(sourceRepo, options, command));
4135
+ sandbox.command("status").description("Show sandbox layer status").argument("[repo]", "Repository owner/name; defaults to current git remote").option("--business <id>", "Business ID; defaults to authenticated user's business").action((repo, options, command) => sandboxStatusCommand(repo, options, command));
4136
+ sandbox.command("history").description("Show sandbox layer build history").argument("[source-repo]", "Source repository owner/name; defaults to current git remote").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--target-repo <repo>", "Target repository owner/name for resource-profile coverage").option("--status <status>", "Filter by build status").option("--limit <n>", "Maximum builds to return").action((sourceRepo, options, command) => sandboxHistoryCommand(sourceRepo, options, command));
4137
+ sandbox.command("logs").description("Print sandbox layer build logs").argument("<build-id>", "Build ID").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--follow", "Follow logs").option("--after-sequence <n>", "Return logs after this sequence").option("--limit <n>", "Maximum log chunks to return").option("--poll-interval <ms>", "Polling interval in milliseconds").action((buildId, options, command) => sandboxLogsCommand(buildId, options, command));
4138
+ sandbox.command("rebuild-stale").description("Create stale sandbox template rebuild campaigns").option("--business <id>", "Business ID to scan; defaults to authenticated user's business").option("--all", "Scan all businesses").option("--dry-run", "Preview stale rebuilds without queueing provider builds").option("--yes", "Start a rebuild campaign").option("--follow", "Follow campaign progress").action((options, command) => sandboxRebuildStaleCommand(options, command));
4139
+ var sandboxAssign = sandbox.command("assign").description("Assign sandbox layer sources");
4140
+ sandboxAssign.command("default").description("Set the business default sandbox layer source").argument("<source-repo>", "Source repository owner/name").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").option("--business <id>", "Business ID; defaults to authenticated user's business").action((sourceRepo, options, command) => sandboxAssignDefaultCommand(sourceRepo, options, command));
4141
+ sandboxAssign.command("repo").description("Assign a sandbox layer source to one target repo").argument("<target-repo>", "Target repository owner/name").argument("<source-repo>", "Source repository owner/name").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").option("--business <id>", "Business ID; defaults to authenticated user's business").action(
4142
+ (targetRepo, sourceRepo, options, command) => sandboxAssignRepoCommand(targetRepo, sourceRepo, options, command)
4143
+ );
4144
+ var sandboxUnassign = sandbox.command("unassign").description("Remove sandbox layer source assignments");
4145
+ sandboxUnassign.command("default").description("Clear the business default sandbox layer source").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--yes", "Skip confirmation").action((options, command) => sandboxUnassignDefaultCommand(options, command));
4146
+ sandboxUnassign.command("repo").description("Clear a target repo sandbox layer assignment").argument("<target-repo>", "Target repository owner/name").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--yes", "Skip confirmation").action((targetRepo, options, command) => sandboxUnassignRepoCommand(targetRepo, options, command));
3019
4147
  var tokens = program.command("tokens").description("CLI token commands");
3020
4148
  tokens.command("list").description("List CLI tokens").option("--limit <n>", "Maximum tokens to return").option("--cursor <cursor>", "Pagination cursor").action((options, command) => listTokensCommand(options, command));
3021
4149
  tokens.command("create").description("Create a CLI token and print it once").option("--scope <scope>", "Token scope: read or write", "read").option("--expires-in-days <days>", "Token expiry in days").addHelpText(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryarcanist/cli",
3
- "version": "0.1.105",
3
+ "version": "0.1.106",
4
4
  "description": "CLI for Arcanist — create and manage coding agent sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,8 @@
28
28
  "homepage": "https://www.tryarcanist.com",
29
29
  "license": "MIT",
30
30
  "dependencies": {
31
- "commander": "^12.1.0"
31
+ "commander": "^12.1.0",
32
+ "yaml": "^2.8.3"
32
33
  },
33
34
  "devDependencies": {
34
35
  "tsup": "^8.0.0",