Skip to content

feat(sandbox): runCommand timeout/AbortSignal and file ops#122

Merged
jamie-at-bunny merged 12 commits into
mainfrom
feat/sandbox-timeouts-file-ops
Jul 13, 2026
Merged

feat(sandbox): runCommand timeout/AbortSignal and file ops#122
jamie-at-bunny merged 12 commits into
mainfrom
feat/sandbox-timeouts-file-ops

Conversation

@jamie-at-bunny

Copy link
Copy Markdown
Member

@amir-at-bunny I'm experimenting with this for project "rabbit's foot".

@bogdan-at-bunny

Copy link
Copy Markdown

@codex review

@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0ec3434

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@bunny.net/cli Minor
@bunny.net/sandbox Patch
@bunny.net/cli-linux-x64 Minor
@bunny.net/cli-linux-arm64 Minor
@bunny.net/cli-darwin-x64 Minor
@bunny.net/cli-darwin-arm64 Minor
@bunny.net/cli-windows-x64 Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds several capabilities to the sandbox SDK and CLI: runCommand gains timeout (milliseconds), AbortSignal, and onStdout/onStderr streaming callbacks; listFiles, deleteFile, rename, and exists file operations are added to both the SDK and transport; the CLI gains bunny sandbox cp (SFTP copy) and bunny sandbox files list/ls; and exec gains a --timeout flag plus the -- separator fix. All three issues flagged in the previous review round (post-rejection callbacks, silent readDir throw on missing path, and rename overwrite on OpenSSH) are addressed with a settled guard, isMissingFileError in readDir, and an lstat-before-rename guard respectively.

  • SDK transport: collectExec is extracted as a tested, narrow helper that enforces timeout/abort and guards onData callbacks with a settled flag; new SFTP methods (readDir, unlink, rename, stat, lexists) follow the same null/bool "missing = expected" pattern established by readFile.
  • CLI: resolve.ts consolidates sandboxFromName/connectSandbox from the old env/resolve.ts; cp.ts handles upload/download with spinner and proper disconnect() in finally; exec.ts correctly merges positionals with args[\"--\"] and kills the spawned SSH process on timeout.

Confidence Score: 5/5

Safe to merge; all three issues from the previous review round are addressed, and the new functionality is well-tested with no blocking defects.

The collectExec extraction is thoroughly unit-tested including the settled guard. The new SFTP methods follow the established null/bool convention for missing-file cases. The exec timeout implementation correctly sets a timedOut flag before calling proc.kill() and clears the timer on normal exit. The only asymmetry is that the upload path in cp.ts doesn't auto-detect a remote directory the way the download path detects local directories.

packages/cli/src/commands/sandbox/cp.ts — the upload path could be made more consistent with the download path's directory detection.

Important Files Changed

Filename Overview
packages/sandbox/src/transport.ts Extracts collectExec with settled-flag guard (fixing the previous post-rejection callback race), adds SFTP ops (readDir returning [] on missing path, unlink, rename with lstat-before-rename guard, stat/lexists); all paths handle isMissingFileError consistently.
packages/sandbox/src/sandbox.ts Adds listFiles/deleteFile/rename/exists delegates, timeout/signal/callback plumbing for blocking runCommand, Symbol.dispose/asyncDispose; asyncDispose is declared async unnecessarily but is harmless.
packages/cli/src/commands/sandbox/cp.ts New SFTP copy command; download correctly auto-detects local directories but upload only checks trailing slash on the remote path, causing a confusing SFTP error when the remote destination is an existing directory without a trailing slash.
packages/cli/src/commands/sandbox/exec.ts Adds --timeout (kills spawned SSH process, returns exit code 124), fixes -- separator with populate--/nargs:1, merges positionals and separator args correctly; timeout/exitCode logic is race-free.
packages/cli/src/commands/sandbox/resolve.ts Consolidates sandboxFromName from env/resolve.ts and adds parseRemoteRef + connectSandbox; parseRemoteRef correctly excludes dot/slash/tilde prefixes to distinguish sandbox refs from local paths.
packages/cli/src/commands/sandbox/files/list.ts Lists sandbox directory via SFTP; uses exists() check to distinguish empty directory from missing path; error handling correctly re-wraps SandboxError as UserError and propagates UserError unchanged through the catch block.
packages/sandbox/src/types.ts Splits RunCommandOptions into BlockingCommandOptions / DetachedCommandOptions union; adds SandboxFileEntry; type-level constraints prevent mixing detached with timeout/signal/callbacks.
packages/sandbox/src/sandbox.test.ts Adds unit tests for collectExec (timeout, abort, streaming, settled guard), fileEntryFromAttrs, runCommand validation, and Symbol.dispose/asyncDispose; fakeStream correctly simulates the ssh2 channel surface.
packages/cli/src/commands/sandbox/env-args.ts Adds nargs:1 to prevent --env from greedily consuming the command that follows; minimal and correct fix.
packages/sandbox/src/errors.ts Adds CommandTimeoutError extending SandboxError; carries timeoutMs, stdout, stderr for partial-output inspection; constructor sets name correctly.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CLI
    participant Sandbox
    participant SshTransport
    participant collectExec

    CLI->>Sandbox: "runCommand({ cmd, timeout, signal, onStdout })"
    Sandbox->>Sandbox: validate timeout / detached guards
    Sandbox->>SshTransport: "exec(remote, { timeoutMs, signal, onData })"
    SshTransport->>SshTransport: signal.throwIfAborted()
    SshTransport->>SshTransport: execStream(command)
    SshTransport->>collectExec: collectExec(stream, limits)
    collectExec->>collectExec: register data/stderr handlers (settled guard)
    collectExec->>collectExec: set setTimeout(timeoutMs)
    alt normal completion
        collectExec-->>Sandbox: "{ stdout, stderr, exitCode }"
        Sandbox-->>CLI: CommandFinished
    else timeout fires
        collectExec->>collectExec: "cleanup() settled=true, clearTimeout"
        collectExec->>SshTransport: stream.signal(KILL), stream.close()
        collectExec-->>Sandbox: reject(CommandTimeoutError)
    else AbortSignal fires
        collectExec->>collectExec: "cleanup() settled=true"
        collectExec->>SshTransport: stream.signal(KILL), stream.close()
        collectExec-->>Sandbox: reject(signal.reason)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CLI
    participant Sandbox
    participant SshTransport
    participant collectExec

    CLI->>Sandbox: "runCommand({ cmd, timeout, signal, onStdout })"
    Sandbox->>Sandbox: validate timeout / detached guards
    Sandbox->>SshTransport: "exec(remote, { timeoutMs, signal, onData })"
    SshTransport->>SshTransport: signal.throwIfAborted()
    SshTransport->>SshTransport: execStream(command)
    SshTransport->>collectExec: collectExec(stream, limits)
    collectExec->>collectExec: register data/stderr handlers (settled guard)
    collectExec->>collectExec: set setTimeout(timeoutMs)
    alt normal completion
        collectExec-->>Sandbox: "{ stdout, stderr, exitCode }"
        Sandbox-->>CLI: CommandFinished
    else timeout fires
        collectExec->>collectExec: "cleanup() settled=true, clearTimeout"
        collectExec->>SshTransport: stream.signal(KILL), stream.close()
        collectExec-->>Sandbox: reject(CommandTimeoutError)
    else AbortSignal fires
        collectExec->>collectExec: "cleanup() settled=true"
        collectExec->>SshTransport: stream.signal(KILL), stream.close()
        collectExec-->>Sandbox: reject(signal.reason)
    end
Loading

Reviews (9): Last reviewed commit: "docs(sandbox): document env methods, dis..." | Re-trigger Greptile

Comment thread packages/sandbox/src/sandbox.ts
Comment thread packages/sandbox/src/transport.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fe65490f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/sandbox/src/transport.ts
Comment thread packages/sandbox/src/sandbox.ts Outdated
Comment thread packages/sandbox/src/types.ts
@jamie-at-bunny

Copy link
Copy Markdown
Member Author

@codex re-review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

Additive quick wins folded onto the timeouts/file-ops work:

- SDK: `runCommand` accepts `onStdout`/`onStderr` callbacks to observe
  output live in the blocking path. Wired through `ExecLimits.onData` in
  `collectExec`, so streaming composes with `timeout` and `signal`.
- SDK: implement `Symbol.dispose`/`Symbol.asyncDispose` so `using` /
  `await using` release the SSH connection on scope exit. Disposal
  disconnects but never deletes the sandbox.
- CLI: add `bunny sandbox cp` to copy files to/from a sandbox over SFTP
  using `<sandbox>:<path>` refs, preserving local file mode on upload.

Docs (README, package READMEs, AGENTS.md) and changesets updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xq3KNv1JcjWZS77ZjcRmYA
@jamie-at-bunny

Copy link
Copy Markdown
Member Author

@amir-at-bunny I needed a cp command at the weekend, and just tasked Claude to implement something I could test. I'll review this myself today, and tidy up any gaps before asking for another review.

…p errors (#123)

- runCommand now rejects onStdout/onStderr combined with detached (they were
  silently ignored); the guard message points to command.kill()/logs()
- sandbox cp reports a clear error for directory sources instead of a
  misleading "file not found", and stats the source once instead of twice
- drop the repeated `as RemoteRef` casts in cp by narrowing ref once
- reuse LogChunk for exec streaming chunks instead of a duplicated inline type
- remove a dead `?? 0` fallback in collectExec's timeout path
- trim redundant comments


Claude-Session: https://claude.ai/code/session_01GrhrNWGpQR9qo7ovxQ13qY

Co-authored-by: Claude <noreply@anthropic.com>
Comment thread .changeset/sandbox-cp-command.md Outdated
Comment thread .changeset/sandbox-timeouts-file-ops.md Outdated
Comment thread .changeset/sandbox-timeouts-file-ops.md Outdated
Comment thread .changeset/sandbox-streaming-and-disposal.md Outdated

@amir-at-bunny amir-at-bunny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

- new `bunny sandbox ls <sandbox>[:<path>]` lists files in a sandbox
  directory over SFTP via the SDK's listFiles (table/json/csv/markdown);
  the `ls` alias moves off `sandbox list` to make room
- `bunny sandbox exec --timeout <seconds>` closes the SSH connection and
  exits 124 when the command outlives the limit
- fix `sandbox exec` `--` handling: the documented `-- <command>` form was
  dropped by yargs, and a greedy `--env` array swallowed the command after
  it (now nargs:1, still repeatable)
- consolidate sandbox record -> SDK handle boilerplate: parseRemoteRef and
  env/resolve.ts's sandboxFromName move to sandbox/resolve.ts, with
  connectSandbox layered on top for SSH-requiring commands (cp, ls)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrhrNWGpQR9qo7ovxQ13qY
Comment thread packages/sandbox/src/transport.ts
claude added 3 commits July 13, 2026 13:38
…s alias

`bunny sandbox files list` (alias: ls) now houses the file listing,
mirroring `bunny storage files`, and gives future file ops (rm, mv) a
home. `bunny sandbox ls` is once again an alias of `sandbox list`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrhrNWGpQR9qo7ovxQ13qY
- collectExec stops delivering onData chunks once the promise has settled,
  so a timeout/abort rejection is the last thing a streaming caller sees
- transport.exec rejects an already-aborted signal before opening the SSH
  channel, so the remote command never starts
- RunCommandOptions is now a blocking/detached union: timeout, signal, and
  output callbacks no longer type-check with detached: true, and dynamic
  options resolve to Command | CommandFinished instead of the wrong overload

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrhrNWGpQR9qo7ovxQ13qY
…antics

The SDK README was missing the env-var surface (create env, per-command
env, getEnv/setEnv/unsetEnv) and disconnect() from the API table. Also
note that streaming options are blocking-only (type-enforced) and that a
pre-aborted signal never starts the remote command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrhrNWGpQR9qo7ovxQ13qY
@jamie-at-bunny jamie-at-bunny merged commit 27a1929 into main Jul 13, 2026
2 checks passed
@jamie-at-bunny jamie-at-bunny deleted the feat/sandbox-timeouts-file-ops branch July 13, 2026 14:21
@github-actions github-actions Bot mentioned this pull request Jul 13, 2026
jedisct1 added a commit to dip-proto/bunny-cli that referenced this pull request Jul 13, 2026
* main:
  feat(sandbox): runCommand timeout/AbortSignal and file ops (BunnyWay#122)

# Conflicts:
#	packages/sandbox/src/errors.ts
#	packages/sandbox/src/index.ts
#	packages/sandbox/src/transport.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants