Conversation
Pass one of the SDK-to-CLI gap: the things an agent cannot do any other way. box browser open|tabs|content|screenshot|act|close|cdp-url. The browser is the only part of a box with no shell fallback. Everything else degrades to box exec (no files read, use cat), but Chromium is driven through the coordinator rather than from inside the container, so there is no command in the box that reaches it. --tab is optional while one tab is open and required once there are several: acting on the wrong page is worse than asking which one. screenshot writes to --out instead of stdout, because stdout carries text a caller may pipe and PNG bytes would corrupt it. box status runs and box status logs. A run id was previously unobtainable from the CLI, so a failed run could be seen but not investigated, and nothing could be cancelled by id. box cancel <run-id>, with Box.cancelRun() added to the SDK. Run.cancel() only works while holding the object the call returned, which a separate process never is, so an agent that started a long run had no way to stop it. box snapshot list and box snapshot delete, and box from-snapshot --no-repl. The flag is the load-bearing part: restore always opened a REPL, so a script could create a snapshot and never restore one, which left listing and deleting them write-only. Same headless rule as box create, an explicit flag, --json, or no terminal on either stream. Left for later: schedule, skills, configure-model, network policy, and the code/session APIs. Provisioning a browser already exists as box create --browser; only driving it was missing.
Everything a program can do to a box, a terminal can now do too, so the skill can document one surface instead of telling agents to fall back to the SDK. box schedule exec|agent|list|get|update|pause|resume|delete. Cron on a box was reachable only from the SDK and the console, and nothing inside the container can register one, so there was no workaround. update sends only the fields named: a partial update that also sent the command would clear it. box skills add|remove|list, and box config model|harness|network|init-command. The network policy modes are exclusive, so a list passed with allow-all or deny-all is refused rather than accepted and ignored, which would read as a policy that narrowed access while doing nothing. box code <source> --lang js|ts|python, with - reading stdin so a program does not have to survive the shell's quoting. The rest of the browser: goto, observe, extract, live-url, and recordings start|stop|list|get|download. extract takes a flat JSON Schema file, because a Zod schema cannot travel through a command line, and refuses nested schemas instead of dropping fields, since a dropped field returns as a missing key rather than as an error. box resume is included for completeness even though every other command resumes a paused box on its own. Two deliberate omissions. exec.session is a live WebSocket with on/send/close, and a one-shot command has nowhere to hold it. getPreviewUrl and listPreviews are deprecated aliases that call the public-URL methods, so box public-url is already the same feature.
…rity Two new flags said seconds and passed the number to an SDK option measured in milliseconds, so `box code --timeout 30` killed the process after 30ms and `box schedule agent --timeout 60` was no better. `box run` already converted and clamped; that logic is now a shared timeoutMs helper the three of them use, and both call sites assert the SDK receives 30000 and 60000. box code reported exit_code: 0 whatever happened. exec.code returns a Run with the real exitCode and stderr, so a failing snippet looked successful to `&&` and to --json. It now mirrors box exec: stdout to stdout, stderr to stderr, the remote status passed through, and 125 kept for failures of the CLI itself. cancelRun took listRuns' JSDoc with it when it was inserted above the method. Moved back. The Python SDK had no cancel_run, so check_parity.py would have failed on the next run that saw this client.ts. Added to the async client, regenerated the sync client, with the PARITY row and a unit test. It versions separately from the npm packages, so it is not in the changeset. status logs --limit nope parsed to NaN and was forwarded into the query string, where it is ignored, so the caller silently got a default page. Both paging flags now have to be whole numbers.
… Python changelog box run kept its own copy of the seconds-to-milliseconds conversion and the timer clamp, which is how the two new flags came to miss it. It now calls timeoutMs, so the next flag that takes a timeout inherits both. Its existing tests already assert 30000 and the out-of-range rejection, and they fail when the helper is broken, so the refactor is covered by tests written before it. AGENTS.md asks for a CHANGELOG entry alongside the PARITY row and the unit test, and cancel_run had none. Added, with the version bump RELEASE.md asks for in the same change. That bump also closes a drift: pyproject.toml went to 0.3.1 in the git-namespace release while __version__ stayed at 0.3.0, so the package has been reporting a version it was not. Both are 0.3.2 now.
There was a problem hiding this comment.
🟡 Changes recommended
Moderate issues remain in schema validation, browser output, snapshot restoration, agent webhooks, and log-limit handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Expands the non-interactive CLI toward SDK parity across browser automation, schedules, configuration, runs, snapshots, and code execution, while adding run cancellation to both SDKs.
Changes:
- Adds browser, recording, schedule, configuration, run, snapshot, and code commands.
- Adds run cancellation to the JavaScript and Python SDKs.
- Adds tests, parity documentation, version updates, and release notes.
File summaries
| File | Description |
|---|---|
packages/sdk/src/client.ts |
Adds run cancellation by ID. |
packages/python-sdk/upstash_box/_version.py |
Updates the package version. |
packages/python-sdk/upstash_box/_sync/client.py |
Adds synchronous cancellation. |
packages/python-sdk/upstash_box/_async/client.py |
Adds asynchronous cancellation. |
packages/python-sdk/tests/_async/test_box_misc.py |
Tests asynchronous cancellation. |
packages/python-sdk/pyproject.toml |
Updates the published version. |
packages/python-sdk/PARITY.md |
Records cancellation parity. |
packages/python-sdk/CHANGELOG.md |
Documents the Python release. |
packages/cli/src/core/io.ts |
Centralizes timeout conversion. |
packages/cli/src/commands/status.ts |
Adds runs, logs, and cancellation commands. |
packages/cli/src/commands/snapshot.ts |
Adds snapshot listing and deletion. |
packages/cli/src/commands/schedule.ts |
Implements schedule management. |
packages/cli/src/commands/run.ts |
Uses shared timeout conversion. |
packages/cli/src/commands/from-snapshot.ts |
Supports headless restoration. |
packages/cli/src/commands/exec.ts |
Adds inline code execution. |
packages/cli/src/commands/config.ts |
Adds configuration, skills, and resume commands. |
packages/cli/src/commands/browser.ts |
Adds browser and recording commands. |
packages/cli/src/cli.ts |
Registers the expanded command surface. |
packages/cli/src/__tests__/commands/schedule-config.test.ts |
Tests schedule and configuration behavior. |
packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts |
Tests run and snapshot commands. |
packages/cli/src/__tests__/commands/from-snapshot.test.ts |
Tests headless restoration. |
packages/cli/src/__tests__/commands/code-and-extract.test.ts |
Tests code execution and schema extraction. |
packages/cli/src/__tests__/commands/browser.test.ts |
Tests core browser commands. |
.changeset/cli-browser-runs-snapshots.md |
Describes published package changes. |
Review details
Suppressed comments (4)
packages/cli/src/commands/browser.ts:200
- JSON Schema
propertiesare optional unless their names appear in the top-levelrequiredarray, but every entry here becomes a required Zod field. A valid flat schema with an optional field is therefore sent to the API as requiring that field and can make extraction fail when it is absent. Parserequiredand mark all other property schemas with.optional().
const shape: Record<string, z.ZodTypeAny> = {};
for (const [key, prop] of Object.entries(parsed.properties)) {
packages/cli/src/commands/browser.ts:208
- The
integerJSON Schema type is converted to an unrestrictedz.number(), so fractional extraction results pass validation despite violating the supplied schema. Preserve integer semantics with Zod's integer constraint.
case "number":
case "integer":
shape[key] = z.number();
break;
packages/cli/src/commands/browser.ts:263
- The new recordings command surface has no CLI tests:
browser.test.tscovers tabs, screenshots, actions, and CDP, but never calls anyrecording*Command. Add coverage for start option conversion/validation, stop/list/get output, and download path forwarding so this multi-command feature is exercised at the CLI boundary.
export async function recordingStartCommand(flags: BrowserFlags): Promise<void> {
const box = await open(flags);
const seconds = flags.maxSeconds === undefined ? undefined : Number(flags.maxSeconds);
if (seconds !== undefined && (!Number.isFinite(seconds) || seconds <= 0)) {
throw new CliError("--max-seconds must be a positive number");
packages/cli/src/commands/schedule.ts:127
ScheduleFlagsexposes bothtimeoutandwebhookUrl, and the SDK's update contract supports both, but this payload silently drops them. In addition, the CLI does not register corresponding update options, sobox schedule updatecannot reach those SDK capabilities. Register the flags and forward only those explicitly supplied, taking care that timeout0is the SDK's clear operation.
const changes = {
...(flags.cron === undefined ? {} : { cron: flags.cron }),
...(command.length === 0 ? {} : { command }),
...(flags.prompt === undefined ? {} : { prompt: flags.prompt }),
...(flags.folder === undefined ? {} : { folder: flags.folder }),
...(flags.model === undefined ? {} : { model: flags.model }),
};
- Files reviewed: 24/24 changed files
- Comments generated: 6
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Six findings, all real. box browser content advertised links and printed none: only --json carried a destination URL, so the default output did not match what the command says it reads. A schema file holding JSON null parsed successfully and then threw a raw TypeError on the property read, past the validation meant to explain the problem. box from-snapshot lost the box id when the working directory could not be written. The box exists and is billing by that point, so the id is the one thing that must survive; headless create already catches this and reports it. Agent schedules accept webhookUrl in the SDK and the flag was neither registered nor forwarded, so only exec schedules could call back. Same for update. --limit 0 was accepted here and then dropped by the SDK, which only sends the limit when it is truthy, so the caller silently got a full default page. Limit now has to be at least 1; offset still allows 0. cancelRun had no SDK-level test. The CLI tests mock the SDK, so nothing pinned the method or the path and a typo would only have surfaced against a live box.
…t method
Reverts Box.cancelRun() and its Python port, so packages/sdk and
packages/python-sdk are byte-identical to main and this is a CLI-only change.
box cancel now calls box._request("POST", "/v2/box/:id/runs/:runId/cancel").
That is the same method Tab already uses across class boundaries, so the base
URL, auth headers, timeout and error handling stay in the SDK instead of being
rebuilt in the CLI, which is what a direct fetch would have required.
The cost is that nothing typechecks the path any more: with no cancel-by-id on
Box, a typo would only surface against a live box. The command test now asserts
the method and the path for that reason.
The changeset drops @upstash/box, leaving only @upstash/box-cli.
There was a problem hiding this comment.
🟡 Changes recommended
Multiple moderate SDK-parity, validation, schema-handling, and release-metadata issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (5)
Previously missed (3) — in code that hasn't changed since the last review.
.changeset/cli-browser-runs-snapshots.md:3
- The PR description says the Python version drift is fixed, but the reviewed tree still has
pyproject.tomlat 0.3.1 andupstash_box/_version.pyat 0.3.0, while this changeset only releases the CLI. Include the promised Python version correction and release metadata.
packages/cli/src/commands/browser.ts:284 - The SDK contract caps recordings at 600 seconds, but this validation accepts larger values and forwards an invalid request to the API. Reject values above 600 locally so callers get a deterministic CLI error.
packages/cli/src/commands/status.ts:141 - The PR description promises public
Box.cancelRun()/cancel_run()APIs, but this code still bypasses the SDK through_request, and neither SDK currently defines those methods. Add the public methods (including the generated Python sync client, parity row, tests, and changelog) and callbox.cancelRun(runId)here so the advertised SDK capability is actually delivered.
packages/cli/src/cli.ts:175
- The new command tree is only tested by calling handler functions directly; no added integration test invokes the built CLI to verify Commander parses nested verbs, repeatable options,
--forwarding, and global/local flag merging. Per the repository testing requirement, add process-boundary integration coverage for representative new command families (especiallystatus/snapshotsubcommands andschedule exec -- ...).
// `box status` keeps its own action; runs and logs hang off it as subcommands.
const statusGroup = program.commands.find((command) => command.name() === "status")!;
statusGroup
.command("runs")
packages/cli/src/commands/browser.ts:228
- An
integerschema is silently widened tonumber, so the API receives a number schema and client-side validation accepts fractional values. Keep the integer constraint in the generated Zod schema.
case "number":
case "integer":
shape[key] = z.number();
break;
- Files reviewed: 16/16 changed files
- Comments generated: 3
- Review effort level: Balanced
…change the timeout Two more from Copilot, both real. schemaFromFile marked every declared property required, so extract threw on a page that was simply missing an optional field. tab.extract() ends in schema.parse(), which means that failure is client-side and looks like the extraction failing rather than the schema being wrong. JSON Schema treats a property as optional unless it is named in `required`, so the converter follows that and wraps the rest in .optional(). schedule update could not touch the timeout, although UpdateScheduleOptions takes one and creation already exposed --timeout. Added, with the same seconds-to-milliseconds conversion, plus an allowZero option on the helper so 0 survives as the SDK's way of clearing the field instead of being rejected with every other non-positive value.
There was a problem hiding this comment.
🔵 Needs a closer look
Five moderate issues remain in schema validation, recording limits, timeout handling, headless output, and the missing public cancellation SDK API.
Review details
Suppressed comments (5)
Previously missed (5) — in code that hasn't changed since the last review.
packages/cli/src/commands/browser.ts:226
- A malformed property definition such as
{"properties":{"title":null}}passes the top-level checks and then throws a raw TypeError whenprop.typeis read. Since this file is untrusted CLI input, validate each property before dereferencing it so callers receive aCliErrorlike the other unsupported schemas.
packages/cli/src/commands/browser.ts:290 - The SDK contract caps recordings at 600 seconds, but this validation accepts larger values and forwards them to the endpoint. Reject values above 600 locally so
--max-seconds 601produces an actionable CLI error rather than an invalid recording request.
packages/cli/src/commands/exec.ts:89 - For timeouts above 600 seconds, the backend receives the requested value but the SDK aborts this synchronous request at its default 600,000 ms (
packages/sdk/src/client.ts:1747does not override_request's timeout). Thusbox code ... --timeout 900fails roughly five minutes earlier than requested. Propagate the execution timeout plus a small request grace period through the SDK request or configure this Box request accordingly.
packages/cli/src/commands/from-snapshot.ts:126 - Headless restore no longer leaves stdout as a reusable box ID when pinning succeeds: the default path prints both the ID and
Pinned to ..., soID=$(box from-snapshot --no-repl ...)captures a multi-line invalid ID. Keep the pin notice on stderr vianote, matchingcommands/create.ts:224-225.
packages/cli/src/commands/status.ts:141 - The PR description promises a public
Box.cancelRun(runId)/cancel_runSDK API, but the repository still exposes onlyRun.cancel()and this command calls_requestdirectly. This leaves the advertised SDK parity work, Python parity row/generated sync client/test/changelog, and version drift fix absent, while coupling the CLI to an internal method. Add the public APIs in both SDKs and callbox.cancelRun(runId)here.
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Gives the CLI parity with the SDK, so an agent driving a terminal can reach everything a program can. This is what the Box skill will document, and today it has to tell agents to fall back to the SDK for a third of what a box does.
71 of 72 SDK capabilities now have a non-interactive command, checked by script rather than by eye.
What's new
The browser is the one part of a box with no shell fallback. Everything else degrades to
box exec(nofiles read? usecat), but Chromium is driven through the coordinator rather than from inside the container, so nothing running in the box can reach it. Same for schedules: they are registered on the coordinator, so there was no workaround at all.from-snapshot --no-replis small but load-bearing. Restore always opened a REPL, so a script could take a snapshot and never restore one, which leftsnapshot list/deletewrite-only.Decisions worth a look
--tabis optional with one tab open and required with several. Acting on the wrong page is worse than asking which one.screenshotandrecordings downloadrequire-o. stdout carries text a caller may pipe; image and video bytes would corrupt it.extracttakes a flat JSON Schema file and refuses nested schemas rather than dropping fields. A Zod schema cannot cross a command line, and a dropped field returns as a missing key, which reads as "the page did not have it".config networkrejects--allow-domainalongsideallow-all, which would otherwise read as narrowing access while doing nothing.schedule updatesends only the fields named, since a partial update that also sent the command would clear it.SDK change
Box.cancelRun(runId)in both SDKs (cancel_runin Python, with the PARITY row, generated sync client, unit test and changelog entry).Run.cancel()only works while holding the object the original call returned, which a separate process never is, so an agent that started a long run had no way to stop it.The Python bump also closes a pre-existing drift:
pyproject.tomlmoved to 0.3.1 in the git-namespace release while__version__stayed at 0.3.0.Deliberately not included
exec.sessionis a live WebSocket withon/send/close; a one-shot command has nowhere to hold it. This is the one gap, and it belongs to the REPL.preview—getPreviewUrlandlistPreviewsare deprecated aliases that call the public-URL methods, sobox public-urlis already the same feature.git create-issue— handled separately.Testing
470 CLI tests (up from 416 on main) and 431 SDK tests. Mutation-tested the guards that matter: the multi-tab refusal, the tab projection, the headless restore, both timeout conversions, and
box code's exit status all fail their tests when reverted.Two bugs found in review and fixed here: both new
--timeoutflags passed seconds into millisecond options, so--timeout 30meant 30ms; andbox codealways reportedexit_code: 0, making a failing snippet look successful to&&and to--json.box runhad the only correct copy of that conversion, so it is now a shared helper all three use.Follow-up: the skills repo needs a pass to document this surface, alongside the existing
box list/box use/--keep-aliveomissions.