From 19cd23fc0db64ab996fa8c080b7e750aad2151a5 Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:36:12 +0100 Subject: [PATCH 1/3] fix(sandbox): don't install session keep-alive in non-interactive create `sandbox create` defaults to `--timeout session`, which keeps the sandbox alive only while this process (the primary client) stays connected and blocks on a SIGINT keep-alive. In non-interactive / CI mode that loop never terminated: `create --json --non-interactive` hung forever, never emitting JSON nor exiting. Reject the default session timeout up front in non-interactive mode (before creating an orphan) with a USAGE / NON_INTERACTIVE_REQUIRED envelope that points to an explicit `--timeout`, since a session-scoped sandbox would be destroyed the moment the process exits. Gate the SIGINT keep-alive (and the ssh fallback) on interactivity, route status chrome to stderr, and enrich the `--json` create payload with org and timeout. --- sandbox/mod.ts | 72 ++++++++++++++++++++++++++++++++------------- tests/agent.test.ts | 27 +++++++++++++++++ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/sandbox/mod.ts b/sandbox/mod.ts index f76c3da..211497f 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -12,7 +12,10 @@ import { join } from "@std/path"; import { Spinner } from "@std/cli/unstable-spinner"; import { + error, + ExitCode, formatDuration, + isNonInteractive, parseSize, renderTemporalTimestamp, tablePrinter, @@ -91,6 +94,26 @@ export const sandboxCreateCommand = new Command() config.noCreate(); const org = await getOrg(options, config, options.org); + // A "session" timeout (the default) keeps the sandbox alive only for as long + // as this process — the *primary* client — stays connected, blocking on + // SIGINT. That is an inherently interactive construct: in non-interactive / + // CI mode the process must return promptly, and doing so would immediately + // destroy a session-scoped sandbox. Reject it up front (before creating an + // orphan) and steer the caller to an explicit, self-sufficient timeout. + const nonInteractive = isNonInteractive(options); + if (nonInteractive && options.timeout === "session") { + error( + options, + "Cannot create a sandbox with the default 'session' timeout in non-interactive mode: a session-scoped sandbox is destroyed as soon as this command exits.", + { + code: ExitCode.USAGE, + errorCode: "NON_INTERACTIVE_REQUIRED", + hint: + "Pass an explicit --timeout (e.g. --timeout 15m) so the sandbox outlives this command, then manage it with `sandbox kill`.", + }, + ); + } + const quiet = options.timeout === "session"; const token = await getAuth(options, quiet); @@ -167,36 +190,45 @@ export const sandboxCreateCommand = new Command() await config.save(); const stopMessage = "Stopping the sandbox..."; + + // Status chrome belongs on stderr so `--json` stdout stays a single payload. + const installKeepAlive = () => { + console.error("\nCtrl+C to stop the sandbox."); + Deno.addSignalListener("SIGINT", async () => { + console.error("\n" + stopMessage); + await sandbox.close(); + Deno.exit(); + }); + }; + + const emitResult = () => { + if (options.json) { + writeJsonResult({ id: sandbox.id, org, timeout: options.timeout }); + } else { + console.log(sandbox.id); + } + }; + if (options.ssh) { const success = await sshIntoSandbox(sandbox); if (success) { // Closes the sandbox only when ssh session was established and finished successfully - console.log("Disconnecting from the sandbox..."); + console.error("Disconnecting from the sandbox..."); await sandbox.close(); + } else if (nonInteractive) { + // No TTY to attach an ssh session to: return the sandbox info and let + // its (explicit) timeout govern its lifetime instead of blocking. + emitResult(); + Deno.exit(); } else { // Otherwise, keep the sandbox running and wait for Ctrl+C - console.log("\nCtrl+C to stop the sandbox."); - Deno.addSignalListener("SIGINT", async () => { - console.log("\n" + stopMessage); - await sandbox.close(); - Deno.exit(); - }); + installKeepAlive(); } } else if (options.timeout === "session") { - // Otherwise, keep the sandbox running and wait for Ctrl+C - console.log("\nCtrl+C to stop the sandbox."); - Deno.addSignalListener("SIGINT", async () => { - console.log("\n" + stopMessage); - await sandbox.close(); - Deno.exit(); - }); + // Interactive only — non-interactive session timeouts are rejected above. + installKeepAlive(); } else { - if (options.json) { - writeJsonResult({ id: sandbox.id }); - } else { - console.log(sandbox.id); - } - + emitResult(); Deno.exit(); } })); diff --git a/tests/agent.test.ts b/tests/agent.test.ts index 64b3db8..a44b4cc 100644 --- a/tests/agent.test.ts +++ b/tests/agent.test.ts @@ -197,6 +197,33 @@ Deno.test("sandbox --help advertises --json and --non-interactive", async () => assertStringIncludes(res.stdout, "--non-interactive"); }); +Deno.test("sandbox create --json --non-interactive with default session timeout fails fast (no hang)", async () => { + // Regression for the create hang: the default `timeout=session` installs an + // interactive SIGINT keep-alive. In non-interactive mode that blocked forever + // instead of emitting JSON or exiting. It must now refuse up front with a + // USAGE envelope (exit 2) and a clean stdout, before any backend round-trip. + const res = await sandboxRaw( + "--json", + "--non-interactive", + "--token", + "obviously-invalid-token", + "--endpoint", + "http://127.0.0.1:1", + "create", + "--org", + "test", + ); + assertEquals(res.code, 2, `unexpected exit; stderr: ${res.stderr}`); + assertEquals( + res.stdout.trim(), + "", + `stdout should stay clean: ${res.stdout}`, + ); + const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!); + assertEquals(envelope.error.code, "NON_INTERACTIVE_REQUIRED"); + assertStringIncludes(envelope.error.hint, "--timeout"); +}); + Deno.test("sandbox list --json emits a structured error envelope, never a browser/hang", async () => { // Bad token + unreachable endpoint: the command must fail fast with a // machine-parseable envelope on stderr (and a clean stdout) rather than From cb233076b070d447b08b2a9bd9e1506a3cb5528e Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:55:27 +0100 Subject: [PATCH 2/3] refactor(sandbox): route success/status output to stderr across subcommands Standardize the output contract so stdout carries ONLY data payloads (the --json result, or in human mode the list table / piped value) while every success / confirmation / status message goes to stderr, in both --json and human modes. sandbox/mod.ts: - create: "Created sandbox with id" and "Exposed port ... to ..." -> stderr (the exposeHttp branch no longer needs a --json special-case) - kill: "Sandbox ... killed successfully." -> stderr - deploy: "Successfully deployed ..." -> stderr - ssh helper: the "ssh " echo and the connect-info fallback -> stderr sandbox/volumes.ts, sandbox/snapshot.ts: - delete: "Successfully deleted volume/snapshot ..." -> stderr config.ts (shared spine, reached by every sandbox command): - "Created configuration file at ..." and the interactive "Selected organization/application" confirmations -> stderr. The config-file message in particular was leaking onto stdout and corrupting `sandbox create --json` whenever it ran in a directory without an existing deno.json(c). Data outputs (sandbox/volume/snapshot ids, list tables, extend result, exec output) stay on stdout unchanged. --- config.ts | 6 +++--- sandbox/mod.ts | 18 +++++++----------- sandbox/snapshot.ts | 4 +++- sandbox/volumes.ts | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/config.ts b/config.ts index b885639..0581f69 100644 --- a/config.ts +++ b/config.ts @@ -58,7 +58,7 @@ export async function getOrg( } org = selectedOrg.value.slug; - console.log(`Selected organization '${selectedOrg.value.name}'`); + console.error(`Selected organization '${selectedOrg.value.name}'`); } } @@ -144,7 +144,7 @@ export async function getApp( created = true; } else { app = selectedApp.value.slug; - console.log(`Selected application '${selectedApp.value.slug}'`); + console.error(`Selected application '${selectedApp.value.slug}'`); } } @@ -314,7 +314,7 @@ async function writeConfig( ); if (!configContent.config) { - console.log( + console.error( `Created configuration file at '${join(Deno.cwd(), "deno.jsonc")}'`, ); } diff --git a/sandbox/mod.ts b/sandbox/mod.ts index 211497f..e8e9f36 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -136,7 +136,7 @@ export const sandboxCreateCommand = new Command() if ( (options.timeout === "session" || options.ssh) && !options.json ) { - console.log(`${green("✔")} Created sandbox with id '${sandbox.id}'`); + console.error(`${green("✔")} Created sandbox with id '${sandbox.id}'`); } if (options.copy) { @@ -158,12 +158,8 @@ export const sandboxCreateCommand = new Command() if (options.exposeHttp) { const url = await sandbox.exposeHttp({ port: options.exposeHttp }); - // In JSON mode this is progress, not the final result; keep stdout clean. - if (options.json) { - console.error(`Exposed port ${options.exposeHttp} to ${url}`); - } else { - console.log(`Exposed port ${options.exposeHttp} to ${url}`); - } + // Progress/status, not the command's data payload — keep stdout clean. + console.error(`Exposed port ${options.exposeHttp} to ${url}`); } const args = this.getLiteralArgs().length > 0 @@ -316,7 +312,7 @@ export const sandboxKillCommand = new Command() if (options.json) { writeJsonResult({ id: sandboxId, killed: res.success }); } else if (res.success) { - console.log(`${green("✔")} Sandbox ${sandboxId} killed successfully.`); + console.error(`${green("✔")} Sandbox ${sandboxId} killed successfully.`); } })); @@ -576,7 +572,7 @@ export const sandboxDeployCommand = new Command() if (options.json) { writeJsonResult({ id: sandboxId, app, deployed: true }); } else { - console.log( + console.error( `${ green("✔") } Successfully deployed sandbox '${sandboxId}' to app '${app}'.`, @@ -634,7 +630,7 @@ async function sshIntoSandbox(sandbox: Sandbox): Promise { stderr: "null", }).output(); if (which.success) { - console.log(`ssh ${connectInfo}`); + console.error(`ssh ${connectInfo}`); const command = new Deno.Command("ssh", { args: [connectInfo], stdin: "inherit", @@ -646,7 +642,7 @@ async function sshIntoSandbox(sandbox: Sandbox): Promise { await sandbox.close(); return true; } else { - console.log( + console.error( `Started ssh session. You can now connect to ${magenta(connectInfo)} Example: diff --git a/sandbox/snapshot.ts b/sandbox/snapshot.ts index 06acfa6..2dd55ae 100644 --- a/sandbox/snapshot.ts +++ b/sandbox/snapshot.ts @@ -102,7 +102,9 @@ export const snapshotsDeleteCommand = new Command() if (options.json) { writeJsonResult({ id: idOrSlug, deleted: true }); } else { - console.log(`${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`); + console.error( + `${green("✔")} Successfully deleted snapshot '${idOrSlug}'.`, + ); } })); diff --git a/sandbox/volumes.ts b/sandbox/volumes.ts index 6abc6f5..3207004 100644 --- a/sandbox/volumes.ts +++ b/sandbox/volumes.ts @@ -118,7 +118,7 @@ export const volumesDeleteCommand = new Command() if (options.json) { writeJsonResult({ id: idOrSlug, deleted: true }); } else { - console.log(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`); + console.error(`${green("✔")} Successfully deleted volume '${idOrSlug}'.`); } })); From 412fdc8368dce16e57c3f7b3b5bc08c09271bfe8 Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Tue, 30 Jun 2026 12:38:44 +0100 Subject: [PATCH 3/3] fix(sandbox): skip interactive ssh under --non-interactive create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sandbox create --ssh` resolved interactivity only on the ssh *failure* branch (`sshIntoSandbox` returning false, which happens solely when the `ssh` binary is absent). When `ssh` is installed — the normal CI/dev case — `sshIntoSandbox` reached `command.spawn()` with `stdin: "inherit"` against a non-TTY stdin and blocked reading the inherited pipe until EOF, reintroducing the exact hang this PR removes, with no JSON ever emitted. Resolve the non-interactive condition BEFORE invoking `sshIntoSandbox`: in non-interactive mode, never open an interactive ssh session. Instead expose ssh, emit the JSON result enriched with the connection details (`ssh.hostname` / `ssh.username`) so the caller can connect themselves, and exit 0; the sandbox lives for its explicit (non-session) timeout. The interactive `--ssh` path is unchanged. --- sandbox/mod.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/sandbox/mod.ts b/sandbox/mod.ts index e8e9f36..d699269 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -197,25 +197,41 @@ export const sandboxCreateCommand = new Command() }); }; - const emitResult = () => { + const emitResult = (extra?: Record) => { if (options.json) { - writeJsonResult({ id: sandbox.id, org, timeout: options.timeout }); + writeJsonResult({ + id: sandbox.id, + org, + timeout: options.timeout, + ...extra, + }); } else { console.log(sandbox.id); } }; if (options.ssh) { + if (nonInteractive) { + // Never open an interactive ssh session in non-interactive mode: + // `ssh` with inherited (non-TTY) stdin blocks until EOF, the very hang + // this command must avoid. Expose ssh, hand the caller the connection + // details, and return — the sandbox lives for its (explicit) timeout. + const ssh = await sandbox.exposeSsh(); + if (!options.json) { + console.error( + `Connect with: ssh ${magenta(`${ssh.username}@${ssh.hostname}`)}`, + ); + } + emitResult({ + ssh: { hostname: ssh.hostname, username: ssh.username }, + }); + Deno.exit(); + } const success = await sshIntoSandbox(sandbox); if (success) { // Closes the sandbox only when ssh session was established and finished successfully console.error("Disconnecting from the sandbox..."); await sandbox.close(); - } else if (nonInteractive) { - // No TTY to attach an ssh session to: return the sandbox info and let - // its (explicit) timeout govern its lifetime instead of blocking. - emitResult(); - Deno.exit(); } else { // Otherwise, keep the sandbox running and wait for Ctrl+C installKeepAlive();