From cd989f9925e37e367f6fc222a48bd2a7f0ffccad Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 11:45:39 +0300 Subject: [PATCH 1/7] DX-2998: browser, run history, cancel and snapshot restore in the CLI 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 , 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. --- .changeset/cli-browser-runs-snapshots.md | 12 ++ .../src/__tests__/commands/browser.test.ts | 191 ++++++++++++++++++ .../__tests__/commands/from-snapshot.test.ts | 70 ++++++- .../commands/runs-and-snapshots.test.ts | 137 +++++++++++++ packages/cli/src/cli.ts | 129 +++++++++++- packages/cli/src/commands/browser.ts | 141 +++++++++++++ packages/cli/src/commands/from-snapshot.ts | 39 +++- packages/cli/src/commands/snapshot.ts | 41 +++- packages/cli/src/commands/status.ts | 74 +++++++ packages/sdk/src/client.ts | 12 ++ 10 files changed, 828 insertions(+), 18 deletions(-) create mode 100644 .changeset/cli-browser-runs-snapshots.md create mode 100644 packages/cli/src/__tests__/commands/browser.test.ts create mode 100644 packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts create mode 100644 packages/cli/src/commands/browser.ts diff --git a/.changeset/cli-browser-runs-snapshots.md b/.changeset/cli-browser-runs-snapshots.md new file mode 100644 index 00000000..ec2987d7 --- /dev/null +++ b/.changeset/cli-browser-runs-snapshots.md @@ -0,0 +1,12 @@ +--- +"@upstash/box-cli": patch +"@upstash/box": patch +--- + +Reach the parts of a box the CLI could not: the browser, run history, and snapshot restore. + +- `box browser open|tabs|content|screenshot|act|close|cdp-url`. The browser is the one thing in a box with no shell fallback, because it is driven through the coordinator rather than from inside the container, so `box exec` could never stand in for it. `--tab` is optional while a single tab is open and required once there are several: acting on the wrong page is worse than asking which one. `screenshot` writes to `--out` rather than stdout, which carries text a caller may pipe. +- `box status runs` and `box status logs`. A run id was previously unobtainable, so a failed run could be observed but not investigated. +- `box cancel `, 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`. +- `box from-snapshot --no-repl`, matching `box create`: explicit flag, `--json`, or no terminal on either stream. Without it a script could take a snapshot and never restore one, which made listing and deleting them write-only. diff --git a/packages/cli/src/__tests__/commands/browser.test.ts b/packages/cli/src/__tests__/commands/browser.test.ts new file mode 100644 index 00000000..60772060 --- /dev/null +++ b/packages/cli/src/__tests__/commands/browser.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + browserOpenCommand, + browserTabsCommand, + browserContentCommand, + browserScreenshotCommand, + browserActCommand, + browserCloseCommand, + browserCdpUrlCommand, +} from "../../commands/browser.js"; +import { CliError } from "../../core/errors.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), +})); + +describe("box browser", () => { + let stdout: ReturnType; + let stderr: ReturnType; + const flags = { box: "b1", token: "box_test" }; + + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + /** A box whose browser namespace is backed by the given fakes. */ + const boxWith = (browser: Record) => { + getBox.mockResolvedValue({ browser }); + return browser; + }; + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + }); + + afterEach(() => { + stdout.mockRestore(); + stderr.mockRestore(); + }); + + it("prints the tab id on open, so later commands can address it", async () => { + const create = vi.fn().mockResolvedValue({ id: "tab-7" }); + boxWith({ tab: { create } }); + + await browserOpenCommand("https://example.com", { ...flags }); + + expect(create).toHaveBeenCalledWith("https://example.com"); + expect(written()).toContain("tab-7"); + }); + + it("lists tabs as data, not as SDK objects", async () => { + // A real Tab holds a back-reference to the Box, so emitting one raw is a + // circular structure that JSON.stringify throws on. Model that here, or + // the projection looks unnecessary. + const fakeTab = (id: string, url: string, title: string) => { + const tab: Record = { id, url, title, content: vi.fn() }; + tab.box = { id: "b1", tabs: [tab] }; + return tab; + }; + boxWith({ + listTabs: vi + .fn() + .mockResolvedValue([ + fakeTab("tab-1", "https://a.test", "A"), + fakeTab("tab-2", "https://b.test", "B"), + ]), + }); + + await browserTabsCommand({ ...flags, json: true }); + + // A Tab instance carries methods and a back-reference to the box; emitting + // it raw would put that in --json output. + expect(JSON.parse(written())).toEqual([ + { id: "tab-1", url: "https://a.test", title: "A" }, + { id: "tab-2", url: "https://b.test", title: "B" }, + ]); + }); + + it("uses the only open tab without being told", async () => { + const content = vi.fn().mockResolvedValue({ title: "T", url: "u", text: "hello" }); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1", content }]) }); + + await browserContentCommand({ ...flags }); + + expect(content).toHaveBeenCalled(); + expect(written()).toContain("hello"); + }); + + it("refuses to guess when several tabs are open", async () => { + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1" }, { id: "tab-2" }]) }); + + // Acting on the wrong page is worse than asking which one. + await expect(browserContentCommand({ ...flags })).rejects.toThrow(/--tab/); + }); + + it("says what to do when nothing is open", async () => { + boxWith({ listTabs: vi.fn().mockResolvedValue([]) }); + + await expect(browserContentCommand({ ...flags })).rejects.toThrow(/browser open/); + }); + + it("addresses the named tab directly, without listing", async () => { + const listTabs = vi.fn(); + const content = vi.fn().mockResolvedValue({ title: "T", url: "u", text: "x" }); + boxWith({ listTabs, getTab: vi.fn(() => ({ id: "tab-9", content })) }); + + await browserContentCommand({ ...flags, tab: "tab-9" }); + + expect(listTabs).not.toHaveBeenCalled(); + expect(content).toHaveBeenCalled(); + }); + + describe("screenshot", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "box-shot-")); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("writes the PNG to the file it was given", async () => { + const png = new Uint8Array([137, 80, 78, 71]); + boxWith({ + listTabs: vi + .fn() + .mockResolvedValue([{ id: "t", screenshot: vi.fn().mockResolvedValue(png) }]), + }); + const out = join(dir, "page.png"); + + await browserScreenshotCommand({ ...flags, out }); + + expect([...readFileSync(out)]).toEqual([137, 80, 78, 71]); + }); + + it("requires --out rather than putting bytes on stdout", async () => { + // stdout is the data channel for text; PNG bytes would corrupt a pipe. + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "t" }]) }); + + await expect(browserScreenshotCommand({ ...flags })).rejects.toThrow(CliError); + }); + + it("decodes a base64 screenshot rather than writing the string", async () => { + const b64 = Buffer.from([1, 2, 3]).toString("base64"); + boxWith({ + listTabs: vi + .fn() + .mockResolvedValue([{ id: "t", screenshot: vi.fn().mockResolvedValue(b64) }]), + }); + const out = join(dir, "page.png"); + + await browserScreenshotCommand({ ...flags, out }); + + expect([...readFileSync(out)]).toEqual([1, 2, 3]); + }); + }); + + it("passes the instruction through to act", async () => { + const act = vi.fn().mockResolvedValue({ success: true }); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "t", act }]) }); + + await browserActCommand("click the login button", { ...flags }); + + expect(act).toHaveBeenCalledWith("click the login button"); + }); + + it("closes the tab", async () => { + const close = vi.fn().mockResolvedValue(undefined); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-3", close }]) }); + + await browserCloseCommand({ ...flags }); + + expect(close).toHaveBeenCalled(); + expect(written()).toContain("tab-3"); + }); + + it("prints the CDP url on stdout and the hint on stderr", async () => { + boxWith({ cdpUrl: vi.fn().mockResolvedValue("ws://cdp.test/abc") }); + + await browserCdpUrlCommand({ ...flags }); + + // The URL is the data; the how-to is a diagnostic, so a pipe stays clean. + expect(written()).toContain("ws://cdp.test/abc"); + expect(stderr.mock.calls.map((c) => String(c[0])).join("")).toContain("connectOverCDP"); + }); +}); diff --git a/packages/cli/src/__tests__/commands/from-snapshot.test.ts b/packages/cli/src/__tests__/commands/from-snapshot.test.ts index 4eb63d3d..9de918c8 100644 --- a/packages/cli/src/__tests__/commands/from-snapshot.test.ts +++ b/packages/cli/src/__tests__/commands/from-snapshot.test.ts @@ -20,8 +20,27 @@ vi.mock("../../auth.js", () => ({ resolveToken: vi.fn((token?: string) => token ?? "resolved-token"), })); +vi.mock("../../core/box-ref.js", () => ({ + writeBoxFile: vi.fn(() => "/tmp/.box"), +})); + +/** Run a body as though a terminal were attached, then restore. */ +async function withTty(body: () => Promise): Promise { + const inTty = process.stdin.isTTY; + const outTty = process.stdout.isTTY; + process.stdin.isTTY = true; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + try { + await body(); + } finally { + process.stdin.isTTY = inTty; + Object.defineProperty(process.stdout, "isTTY", { value: outTty, configurable: true }); + } +} + import { Box } from "@upstash/box"; import { startRepl } from "../../repl/terminal.js"; +import { writeBoxFile } from "../../core/box-ref.js"; describe("fromSnapshotCommand", () => { let exitSpy: ReturnType; @@ -41,11 +60,13 @@ describe("fromSnapshotCommand", () => { const mockBox = { id: "box-1" }; vi.mocked(Box.fromSnapshot).mockResolvedValueOnce(mockBox as any); - await fromSnapshotCommand("snap-1", { - token: "key", - agentModel: "model", - agentHarness: "claude-code", - agentApiKey: "agent-key", + await withTty(async () => { + await fromSnapshotCommand("snap-1", { + token: "key", + agentModel: "model", + agentHarness: "claude-code", + agentApiKey: "agent-key", + }); }); expect(Box.fromSnapshot).toHaveBeenCalledWith( @@ -73,10 +94,12 @@ describe("fromSnapshotCommand", () => { const mockBox = { id: "box-2" }; vi.mocked(Box.fromSnapshot).mockResolvedValueOnce(mockBox as any); - await fromSnapshotCommand("snap-1", { - token: "key", - agentModel: "model", - agentHarness: "claude-code", + await withTty(async () => { + await fromSnapshotCommand("snap-1", { + token: "key", + agentModel: "model", + agentHarness: "claude-code", + }); }); expect(Box.fromSnapshot).toHaveBeenCalledWith( @@ -88,6 +111,35 @@ describe("fromSnapshotCommand", () => { expect(startRepl).toHaveBeenCalledWith(mockBox); }); + it("restores without a REPL when --no-repl is given", async () => { + // The reason snapshot list/delete were write-only: an agent could make a + // snapshot and never restore one, because restore always opened a REPL. + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + + await withTty(async () => { + await fromSnapshotCommand("snap-1", { token: "key", repl: false }); + }); + + expect(startRepl).not.toHaveBeenCalled(); + expect(writeBoxFile).toHaveBeenCalledWith("box-9"); + }); + + it("goes headless with no terminal, so a script does not hang", async () => { + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + + await fromSnapshotCommand("snap-1", { token: "key" }); + + expect(startRepl).not.toHaveBeenCalled(); + }); + + it("does not pin when --no-use is given", async () => { + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + + await fromSnapshotCommand("snap-1", { token: "key", repl: false, use: false }); + + expect(writeBoxFile).not.toHaveBeenCalled(); + }); + it("errors when agentModel is set without a harness flag", async () => { await expect( fromSnapshotCommand("snap-1", { token: "key", agentModel: "model" }), diff --git a/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts new file mode 100644 index 00000000..cc958cf2 --- /dev/null +++ b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { statusRunsCommand, statusLogsCommand, cancelCommand } from "../../commands/status.js"; +import { snapshotListCommand, snapshotDeleteCommand } from "../../commands/snapshot.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), + findBoxFile: vi.fn(() => undefined), +})); + +describe("runs, logs, cancel and snapshots", () => { + let stdout: ReturnType; + const flags = { box: "b1", token: "box_test" }; + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + describe("status runs", () => { + it("lists runs with the id first, since that is what cancel needs", async () => { + getBox.mockResolvedValue({ + listRuns: vi.fn().mockResolvedValue([ + { + id: "run-1", + type: "agent", + status: "completed", + duration_ms: 4200, + cost_usd: 0.0123, + }, + ]), + }); + + await statusRunsCommand({ ...flags }); + + expect(written()).toContain("run-1"); + expect(written()).toContain("agent"); + }); + + it("says so plainly when there are none", async () => { + getBox.mockResolvedValue({ listRuns: vi.fn().mockResolvedValue([]) }); + + await statusRunsCommand({ ...flags }); + + expect(written()).toMatch(/no runs/i); + }); + + it("emits the raw run objects under --json", async () => { + const runs = [{ id: "run-1", type: "shell", duration_ms: 10, cost_usd: 0 }]; + getBox.mockResolvedValue({ listRuns: vi.fn().mockResolvedValue(runs) }); + + await statusRunsCommand({ ...flags, json: true }); + + expect(JSON.parse(written())).toEqual(runs); + }); + }); + + describe("status logs", () => { + it("passes paging through only when asked", async () => { + const logs = vi.fn().mockResolvedValue([]); + getBox.mockResolvedValue({ logs }); + + await statusLogsCommand({ ...flags }); + expect(logs).toHaveBeenCalledWith({}); + + await statusLogsCommand({ ...flags, limit: "50", offset: "10" }); + expect(logs).toHaveBeenCalledWith({ limit: 50, offset: 10 }); + }); + + it("renders the timestamp as a date rather than a number", async () => { + getBox.mockResolvedValue({ + logs: vi + .fn() + .mockResolvedValue([ + { timestamp: 1767225600, level: "error", source: "agent", message: "boom" }, + ]), + }); + + await statusLogsCommand({ ...flags }); + + expect(written()).toContain("boom"); + expect(written()).toMatch(/\d{4}-\d{2}-\d{2}T/); + }); + }); + + describe("cancel", () => { + it("cancels the run it was given", async () => { + const cancelRun = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ cancelRun }); + + await cancelCommand("run-9", { ...flags }); + + expect(cancelRun).toHaveBeenCalledWith("run-9"); + expect(written()).toContain("run-9"); + }); + }); + + describe("snapshots", () => { + it("lists snapshots id-first, so delete has something to take", async () => { + getBox.mockResolvedValue({ + listSnapshots: vi + .fn() + .mockResolvedValue([ + { id: "snap-1", name: "before-migration", status: "ready", size_bytes: 52_428_800 }, + ]), + }); + + await snapshotListCommand({ ...flags }); + + expect(written()).toContain("snap-1"); + expect(written()).toContain("50MB"); + }); + + it("reports an empty list rather than printing nothing", async () => { + getBox.mockResolvedValue({ listSnapshots: vi.fn().mockResolvedValue([]) }); + + await snapshotListCommand({ ...flags }); + + expect(written()).toMatch(/no snapshots/i); + }); + + it("deletes by id", async () => { + const deleteSnapshot = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ deleteSnapshot }); + + await snapshotDeleteCommand("snap-2", { ...flags }); + + expect(deleteSnapshot).toHaveBeenCalledWith("snap-2"); + }); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4a7e1fb3..a2cbccee 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,7 +9,11 @@ import { fromSnapshotCommand } from "./commands/from-snapshot.js"; import { listCommand } from "./commands/list.js"; import { getCommand } from "./commands/get.js"; import { initDemoCommand } from "./commands/init-demo.js"; -import { snapshotCommand } from "./commands/snapshot.js"; +import { + snapshotCommand, + snapshotListCommand, + snapshotDeleteCommand, +} from "./commands/snapshot.js"; import { completionCommand } from "./commands/completion.js"; import { envSetCommand, @@ -18,7 +22,21 @@ import { envSetAllCommand, } from "./commands/env.js"; import { labelAddCommand, labelRemoveCommand, labelListCommand } from "./commands/labels.js"; -import { statusCommand } from "./commands/status.js"; +import { + statusCommand, + statusRunsCommand, + statusLogsCommand, + cancelCommand, +} from "./commands/status.js"; +import { + browserOpenCommand, + browserTabsCommand, + browserContentCommand, + browserScreenshotCommand, + browserActCommand, + browserCloseCommand, + browserCdpUrlCommand, +} from "./commands/browser.js"; import { useCommand } from "./commands/use.js"; import { execCommand } from "./commands/exec.js"; import { @@ -120,6 +138,36 @@ program await runCommand(async () => statusCommand(globals(flags))); }); +// `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") + .description("List the box's runs, most recent first") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (opts) => runCommand(async () => statusRunsCommand(merged(opts)))); + +statusGroup + .command("logs") + .description("Print the box's log lines") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .option("--limit ", "Maximum entries to return") + .option("--offset ", "Skip this many entries") + .action(async (opts) => runCommand(async () => statusLogsCommand(merged(opts)))); + +program + .command("cancel ") + .description("Cancel a running execution (get ids from box status runs)") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (runId: string, opts) => + runCommand(async () => cancelCommand(runId, merged(opts))), + ); + program .command("exec") // Variadic so the remote command survives as separate words, and Commander @@ -457,13 +505,61 @@ program (val: string, prev: string[]) => [...prev, val], [] as string[], ) + .option("--no-repl", "Restore and print the id instead of opening the REPL") + .option("--no-use", "Do not write a .box file") + .option("--json", "Emit machine-readable output (implies --no-repl)") .action(async (snapshotId, opts) => - runCommand(async () => { - refuseJson(merged(opts), "box from-snapshot", "box create --no-repl --json"); - return fromSnapshotCommand(snapshotId, merged(opts)); - }), + runCommand(async () => fromSnapshotCommand(snapshotId, merged(opts))), ); +const browser = program + .command("browser") + .description("Drive the box's headless Chromium (needs box create --browser)"); + +/** Flags every browser verb accepts, declared once. */ +const withBrowserCommon = (cmd: import("commander").Command) => + cmd + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .option("--tab ", "Tab to act on; only needed when more than one is open"); + +withBrowserCommon( + browser.command("open").argument("").description("Open a URL and print the tab id"), +).action(async (url: string, opts) => + runCommand(async () => browserOpenCommand(url, merged(opts))), +); + +withBrowserCommon(browser.command("tabs").description("List open tabs")).action(async (opts) => + runCommand(async () => browserTabsCommand(merged(opts))), +); + +withBrowserCommon( + browser.command("content").description("Read the page title, text and links"), +).action(async (opts) => runCommand(async () => browserContentCommand(merged(opts)))); + +withBrowserCommon(browser.command("screenshot").description("Capture the page as a PNG")) + .requiredOption("-o, --out ", "Write the PNG here") + .option("--full-page", "Capture the whole scrollable page") + .action(async (opts) => runCommand(async () => browserScreenshotCommand(merged(opts)))); + +withBrowserCommon( + browser + .command("act") + .argument("") + .description('Act on the page in words, e.g. "click the login button"'), +).action(async (instruction: string, opts) => + runCommand(async () => browserActCommand(instruction, merged(opts))), +); + +withBrowserCommon(browser.command("close").description("Close a tab")).action(async (opts) => + runCommand(async () => browserCloseCommand(merged(opts))), +); + +withBrowserCommon( + browser.command("cdp-url").description("Print the CDP URL for Playwright or Puppeteer"), +).action(async (opts) => runCommand(async () => browserCdpUrlCommand(merged(opts)))); + program .command("list") .description("List all boxes") @@ -489,6 +585,27 @@ program .option("--name ", "Snapshot name") .action(async (boxId, opts) => runCommand(async () => snapshotCommand(boxId, merged(opts)))); +const snapshotGroup = program.commands.find((command) => command.name() === "snapshot")!; + +snapshotGroup + .command("list") + .description("List the box's snapshots") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (opts) => runCommand(async () => snapshotListCommand(merged(opts)))); + +snapshotGroup + .command("delete") + .argument("") + .description("Delete a snapshot") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (snapshotId: string, opts) => + runCommand(async () => snapshotDeleteCommand(snapshotId, merged(opts))), + ); + program .command("init-demo") .description("Scaffold a standalone demo project for @upstash/box") diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts new file mode 100644 index 00000000..94287671 --- /dev/null +++ b/packages/cli/src/commands/browser.ts @@ -0,0 +1,141 @@ +import { writeFileSync } from "node:fs"; +import { Box, type Tab } from "@upstash/box"; +import { resolveBoxId, announceBox } from "../core/box-ref.js"; +import { CliError } from "../core/errors.js"; +import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; + +export type BrowserFlags = GlobalFlags & { + tab?: string; + out?: string; + fullPage?: boolean; +}; + +async function open(flags: BrowserFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + return Box.get(resolved.id, { apiKey: requireToken(flags.token) }); +} + +/** + * Resolve which tab to act on. + * + * A box can hold several tabs, and every page operation is addressed by one. + * With a single tab open, requiring --tab would be ceremony; with several, + * guessing would act on the wrong page. + * @param box - the box. + * @param flags - the merged flags, for --tab. + * @returns the tab to use. + * @throws CliError when there is nothing open, or the choice is ambiguous. + */ +async function resolveTab(box: Box, flags: BrowserFlags): Promise { + if (flags.tab) return box.browser.getTab(flags.tab); + + const tabs = await box.browser.listTabs(); + if (tabs.length === 0) { + throw new CliError("No open tabs. Open one with: box browser open "); + } + if (tabs.length > 1) { + throw new CliError( + `${tabs.length} tabs are open; name one with --tab . List them with: box browser tabs`, + ); + } + return tabs[0]!; +} + +/** + * Open a URL in the box's browser. + * @param url - the page to load. + * @param flags - the merged flags. + */ +export async function browserOpenCommand(url: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await box.browser.tab.create(url); + emit({ tab_id: tab.id, url }, [tab.id], flags); +} + +/** + * List the open tabs. + * @param flags - the merged flags. + */ +export async function browserTabsCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tabs = await box.browser.listTabs(); + const data = tabs.map((tab) => ({ id: tab.id, url: tab.url, title: tab.title })); + emit( + data, + tabs.length === 0 + ? ["No open tabs."] + : tabs.map((tab) => `${tab.id}\t${tab.url ?? ""}\t${tab.title ?? ""}`), + flags, + ); +} + +/** + * Read the active page: title, visible text and links. + * @param flags - the merged flags. + */ +export async function browserContentCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const content = await tab.content(); + emit(content, [content.title, content.url, "", content.text], flags); +} + +/** + * Capture the page as a PNG. + * + * The image goes to a file rather than stdout: stdout carries text that a + * caller may pipe, and PNG bytes down the same channel would corrupt it. + * @param flags - the merged flags, with --out for the destination. + */ +export async function browserScreenshotCommand(flags: BrowserFlags): Promise { + if (!flags.out) { + throw new CliError("--out is required: a PNG cannot share stdout with text output"); + } + + const box = await open(flags); + const tab = await resolveTab(box, flags); + const shot = await tab.screenshot({ ...(flags.fullPage ? { fullPage: true } : {}) }); + const bytes = typeof shot === "string" ? Buffer.from(shot, "base64") : Buffer.from(shot); + writeFileSync(flags.out, bytes); + + emit( + { path: flags.out, bytes: bytes.length }, + [`Wrote ${bytes.length} bytes to ${flags.out}`], + flags, + ); +} + +/** + * Act on the page in natural language ("click the login button"). + * @param instruction - what to do. + * @param flags - the merged flags. + */ +export async function browserActCommand(instruction: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const result = await tab.act(instruction); + emit(result, [typeof result === "string" ? result : JSON.stringify(result, undefined, 2)], flags); +} + +/** + * Close a tab. + * @param flags - the merged flags. + */ +export async function browserCloseCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + await tab.close(); + emit({ tab_id: tab.id, closed: true }, [`Closed ${tab.id}`], flags); +} + +/** + * Print the CDP URL, so Playwright or Puppeteer can drive the same browser. + * @param flags - the merged flags. + */ +export async function browserCdpUrlCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const url = await box.browser.cdpUrl(); + note("Connect with: chromium.connectOverCDP()"); + emit({ cdp_url: url }, [url], flags); +} diff --git a/packages/cli/src/commands/from-snapshot.ts b/packages/cli/src/commands/from-snapshot.ts index 8648babf..02c625f9 100644 --- a/packages/cli/src/commands/from-snapshot.ts +++ b/packages/cli/src/commands/from-snapshot.ts @@ -4,6 +4,8 @@ import { resolveToken } from "../auth.js"; import { resolveAgentApiKey } from "../agent-key.js"; import { startRepl } from "../repl/terminal.js"; import { CliError } from "../core/errors.js"; +import { writeBoxFile } from "../core/box-ref.js"; +import { emit, note } from "../core/io.js"; function resolveCliAgentHarness(harness: string | undefined): string | undefined { if (!harness) return undefined; @@ -35,6 +37,26 @@ interface FromSnapshotFlags { gitToken?: string; env?: string[]; label?: string[]; + /** false when --no-repl was passed. */ + repl?: boolean; + json?: boolean; + use?: boolean; +} + +/** + * Whether to restore without opening a REPL. + * + * Same rule as `box create`: an explicit --no-repl, --json, or the absence of a + * terminal on either stream. Without this the only way to restore a snapshot + * was a REPL that a script has nobody to drive, so listing and deleting + * snapshots was a write-only feature. + * @param flags - the flags as given. + * @returns true when the command should not open a REPL. + */ +function isHeadlessRestore(flags: FromSnapshotFlags): boolean { + if (flags.repl === false) return true; + if (flags.json) return true; + return !process.stdin.isTTY || !process.stdout.isTTY; } export async function fromSnapshotCommand( @@ -63,7 +85,9 @@ export async function fromSnapshotCommand( ); } - console.log("Creating box from snapshot..."); + const headless = isHeadlessRestore(flags); + if (!headless) console.log("Creating box from snapshot..."); + else note("Creating box from snapshot..."); const box = await Box.fromSnapshot(snapshotId, { apiKey, runtime: flags.runtime as Runtime, @@ -79,5 +103,16 @@ export async function fromSnapshotCommand( labels: flags.label && flags.label.length > 0 ? flags.label : undefined, }); - await startRepl(box); + if (!headless) { + await startRepl(box); + return; + } + + // Pin it, so the commands that follow need no --box. Same as headless create. + const pinned = flags.use === false ? undefined : writeBoxFile(box.id); + emit( + { id: box.id, ...(pinned === undefined ? {} : { box_file: pinned }) }, + [box.id, ...(pinned === undefined ? [] : [`Pinned to ${pinned}`])], + flags, + ); } diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 0ce5da58..d02ae06d 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -1,6 +1,7 @@ import { Box } from "@upstash/box"; import { resolveToken } from "../auth.js"; -import { emit, note, type GlobalFlags } from "../core/io.js"; +import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; +import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { interactiveSelect } from "../utils/interactive-select.js"; import { dim } from "../utils/ansi.js"; import { CliError } from "../core/errors.js"; @@ -56,3 +57,41 @@ export async function snapshotCommand( const snapshot = await box.snapshot({ name: snapshotName }); emit(snapshot, `Snapshot created: ${snapshot.id} (${snapshot.name})`, flags); } + +/** + * List the box's snapshots. + * @param flags - resolved global flags. + */ +export async function snapshotListCommand(flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + const snapshots = await box.listSnapshots(); + + emit( + snapshots, + snapshots.length === 0 + ? ["No snapshots."] + : snapshots.map( + (snap) => + `${snap.id}\t${snap.status}\t${Math.round(snap.size_bytes / 1024 / 1024)}MB\t${snap.name}`, + ), + flags, + ); +} + +/** + * Delete a snapshot. + * @param snapshotId - the snapshot to remove. + * @param flags - resolved global flags. + */ +export async function snapshotDeleteCommand(snapshotId: string, flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + await box.deleteSnapshot(snapshotId); + + emit({ id: snapshotId, deleted: true }, [`Deleted ${snapshotId}`], flags); +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index adb77042..0bd15503 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -40,3 +40,77 @@ export async function statusCommand(flags: GlobalFlags): Promise { flags, ); } + +/** + * List the box's runs, most recent first. + * + * The REPL has had this since the beginning; without it a non-interactive + * caller can see that a run happened but not which one, so it has no id to + * cancel or to read logs for. + * @param flags - resolved global flags. + */ +export async function statusRunsCommand(flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + const runs = await box.listRuns(); + + emit( + runs, + runs.length === 0 + ? ["No runs yet."] + : runs.map( + (run) => + `${run.id}\t${run.type}\t${run.status ?? ""}\t${Math.round(run.duration_ms / 1000)}s\t$${run.cost_usd.toFixed(4)}`, + ), + flags, + ); +} + +/** + * Print the box's log lines. + * @param flags - resolved global flags, plus paging. + */ +export async function statusLogsCommand( + flags: GlobalFlags & { limit?: string; offset?: string }, +): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + const logs = await box.logs({ + ...(flags.limit === undefined ? {} : { limit: Number(flags.limit) }), + ...(flags.offset === undefined ? {} : { offset: Number(flags.offset) }), + }); + + emit( + logs, + logs.length === 0 + ? ["No logs."] + : logs.map( + (entry) => + `${new Date(entry.timestamp * 1000).toISOString()}\t${entry.level}\t${entry.source}\t${entry.message}`, + ), + flags, + ); +} + +/** + * Cancel a run by id. + * + * An agent that starts a long run has no other way to stop it: the object with + * `.cancel()` on it lives in the process that started the run, which has + * usually exited by the time anyone wants it stopped. + * @param runId - the run to cancel, from `box status runs`. + * @param flags - resolved global flags. + */ +export async function cancelCommand(runId: string, flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + await box.cancelRun(runId); + + emit({ run_id: runId, cancelled: true }, [`Cancelled ${runId}`], flags); +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 0af4109a..c5c16a53 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2628,6 +2628,18 @@ export class Box { /** * List all runs for this box, newest first. */ + /** + * Cancel a run by id. + * + * `Run.cancel()` only works while you are holding the object the call + * returned, which a separate process never is. Listing runs and cancelling + * one by id is the only route open to a caller that did not start it. + * @param runId - the run to cancel. + */ + async cancelRun(runId: string): Promise { + await this._request("POST", `/v2/box/${this.id}/runs/${runId}/cancel`); + } + async listRuns(): Promise { const data = await this._request<{ runs: BoxRunData[] }>("GET", `/v2/box/${this.id}/runs`); return data.runs; From 19a41c4812c784617b0e0ffce627757f60d09930 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 11:57:18 +0300 Subject: [PATCH 2/7] DX-2998: complete the CLI's parity with the SDK 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 --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. --- .changeset/cli-browser-runs-snapshots.md | 9 +- .../commands/schedule-config.test.ts | 212 +++++++++++++ packages/cli/src/cli.ts | 279 +++++++++++++++++- packages/cli/src/commands/browser.ts | 186 +++++++++++- packages/cli/src/commands/config.ts | 178 +++++++++++ packages/cli/src/commands/exec.ts | 40 +++ packages/cli/src/commands/schedule.ts | 179 +++++++++++ 7 files changed, 1080 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/__tests__/commands/schedule-config.test.ts create mode 100644 packages/cli/src/commands/config.ts create mode 100644 packages/cli/src/commands/schedule.ts diff --git a/.changeset/cli-browser-runs-snapshots.md b/.changeset/cli-browser-runs-snapshots.md index ec2987d7..644b86a4 100644 --- a/.changeset/cli-browser-runs-snapshots.md +++ b/.changeset/cli-browser-runs-snapshots.md @@ -3,10 +3,17 @@ "@upstash/box": patch --- -Reach the parts of a box the CLI could not: the browser, run history, and snapshot restore. +Give the CLI parity with the SDK, so an agent driving a terminal can reach everything a program can. - `box browser open|tabs|content|screenshot|act|close|cdp-url`. The browser is the one thing in a box with no shell fallback, because it is driven through the coordinator rather than from inside the container, so `box exec` could never stand in for it. `--tab` is optional while a single tab is open and required once there are several: acting on the wrong page is worse than asking which one. `screenshot` writes to `--out` rather than stdout, which carries text a caller may pipe. - `box status runs` and `box status logs`. A run id was previously unobtainable, so a failed run could be observed but not investigated. - `box cancel `, 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`. - `box from-snapshot --no-repl`, matching `box create`: explicit flag, `--json`, or no terminal on either stream. Without it a script could take a snapshot and never restore one, which made listing and deleting them write-only. + +- `box schedule exec|agent|list|get|update|pause|resume|delete`. Cron on a box was reachable only from the SDK and the console. `update` sends just the fields named, because a partial update that also sent the command would clear it. +- `box skills add|remove|list`, `box config model|harness|network|init-command`, and `box resume`. +- `box code --lang js|ts|python`, with `-` reading stdin so the shell does not mangle a program on the way in. +- The rest of the browser: `goto`, `observe`, `extract`, `live-url`, and `recordings start|stop|list|get|download`. `extract` takes a flat JSON Schema file, since a Zod schema cannot travel through a command line, and refuses anything nested rather than silently dropping fields. + +`exec.session` is deliberately absent: a 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 for the public-URL calls, so `box public-url` already covers them. diff --git a/packages/cli/src/__tests__/commands/schedule-config.test.ts b/packages/cli/src/__tests__/commands/schedule-config.test.ts new file mode 100644 index 00000000..54c8874b --- /dev/null +++ b/packages/cli/src/__tests__/commands/schedule-config.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + scheduleExecCommand, + scheduleAgentCommand, + scheduleListCommand, + scheduleUpdateCommand, + schedulePauseCommand, + scheduleDeleteCommand, +} from "../../commands/schedule.js"; +import { + configureModelCommand, + networkPolicyCommand, + skillsAddCommand, + skillsListCommand, + initCommandSetCommand, +} from "../../commands/config.js"; +import { CliError } from "../../core/errors.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), +})); + +describe("schedule and config", () => { + let stdout: ReturnType; + const flags = { box: "b1", token: "box_test" }; + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + describe("schedule exec", () => { + it("sends the command as argv, so quoting survives", async () => { + const exec = vi.fn().mockResolvedValue({ id: "sch-1" }); + getBox.mockResolvedValue({ schedule: { exec } }); + + await scheduleExecCommand(["npm", "run", "backup"], { ...flags, cron: "0 9 * * *" }); + + expect(exec).toHaveBeenCalledWith({ cron: "0 9 * * *", command: ["npm", "run", "backup"] }); + expect(written()).toContain("sch-1"); + }); + + it("refuses without a cron, rather than creating something that never runs", async () => { + getBox.mockResolvedValue({ schedule: { exec: vi.fn() } }); + + await expect(scheduleExecCommand(["ls"], { ...flags })).rejects.toThrow(/--cron/); + }); + + it("refuses an empty command", async () => { + getBox.mockResolvedValue({ schedule: { exec: vi.fn() } }); + + await expect(scheduleExecCommand([], { ...flags, cron: "0 9 * * *" })).rejects.toThrow( + CliError, + ); + }); + }); + + describe("schedule agent", () => { + it("joins the prompt words", async () => { + const agent = vi.fn().mockResolvedValue({ id: "sch-2" }); + getBox.mockResolvedValue({ schedule: { agent } }); + + await scheduleAgentCommand(["check", "the", "logs"], { ...flags, cron: "@daily" }); + + expect(agent).toHaveBeenCalledWith({ cron: "@daily", prompt: "check the logs" }); + }); + + it("rejects a timeout that is not a positive number", async () => { + getBox.mockResolvedValue({ schedule: { agent: vi.fn() } }); + + await expect( + scheduleAgentCommand(["x"], { ...flags, cron: "@daily", timeout: "0" }), + ).rejects.toThrow(/--timeout/); + }); + }); + + it("lists schedules id-first", async () => { + getBox.mockResolvedValue({ + schedule: { + list: vi + .fn() + .mockResolvedValue([ + { id: "sch-1", type: "exec", cron: "0 9 * * *", status: "active", command: ["ls"] }, + ]), + }, + }); + + await scheduleListCommand({ ...flags }); + + expect(written()).toContain("sch-1"); + expect(written()).toContain("0 9 * * *"); + }); + + describe("schedule update", () => { + it("sends only what changed, leaving the rest alone", async () => { + const update = vi + .fn() + .mockResolvedValue({ id: "sch-1", type: "exec", cron: "@hourly", status: "active" }); + getBox.mockResolvedValue({ schedule: { update } }); + + await scheduleUpdateCommand("sch-1", [], { ...flags, cron: "@hourly" }); + + // A partial update that also sent command/prompt would clear them. + expect(update).toHaveBeenCalledWith("sch-1", { cron: "@hourly" }); + }); + + it("refuses an update that changes nothing", async () => { + getBox.mockResolvedValue({ schedule: { update: vi.fn() } }); + + await expect(scheduleUpdateCommand("sch-1", [], { ...flags })).rejects.toThrow( + /Nothing to update/, + ); + }); + }); + + it("pauses and deletes by id", async () => { + const pause = vi.fn().mockResolvedValue(undefined); + const del = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ schedule: { pause, delete: del } }); + + await schedulePauseCommand("sch-1", { ...flags }); + await scheduleDeleteCommand("sch-2", { ...flags }); + + expect(pause).toHaveBeenCalledWith("sch-1"); + expect(del).toHaveBeenCalledWith("sch-2"); + }); + + describe("network policy", () => { + it("sends a blanket mode with no lists", async () => { + const updateNetworkPolicy = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ updateNetworkPolicy }); + + await networkPolicyCommand("deny-all", { ...flags }); + + expect(updateNetworkPolicy).toHaveBeenCalledWith({ mode: "deny-all" }); + }); + + it("refuses lists on a blanket mode, which would look like they applied", async () => { + getBox.mockResolvedValue({ updateNetworkPolicy: vi.fn() }); + + await expect( + networkPolicyCommand("allow-all", { ...flags, allowDomain: ["example.com"] }), + ).rejects.toThrow(/only apply to 'custom'/); + }); + + it("refuses custom with no lists, which would be an empty policy", async () => { + getBox.mockResolvedValue({ updateNetworkPolicy: vi.fn() }); + + await expect(networkPolicyCommand("custom", { ...flags })).rejects.toThrow(/at least one/); + }); + + it("builds a custom policy from the lists given", async () => { + const updateNetworkPolicy = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ updateNetworkPolicy }); + + await networkPolicyCommand("custom", { + ...flags, + allowDomain: ["api.example.com"], + denyCidr: ["10.0.0.0/8"], + }); + + expect(updateNetworkPolicy).toHaveBeenCalledWith({ + mode: "custom", + allowedDomains: ["api.example.com"], + deniedCidrs: ["10.0.0.0/8"], + }); + }); + + it("rejects an unknown mode", async () => { + getBox.mockResolvedValue({ updateNetworkPolicy: vi.fn() }); + + await expect(networkPolicyCommand("everything", { ...flags })).rejects.toThrow( + /mode must be/, + ); + }); + }); + + it("configures the model", async () => { + const configureModel = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ configureModel }); + + await configureModelCommand("anthropic/claude-sonnet-5", { ...flags }); + + expect(configureModel).toHaveBeenCalledWith("anthropic/claude-sonnet-5"); + }); + + it("adds and lists skills", async () => { + const add = vi.fn().mockResolvedValue(undefined); + getBox.mockResolvedValue({ + skills: { add, list: vi.fn().mockResolvedValue(["upstash-redis"]) }, + }); + + await skillsAddCommand("upstash-redis", { ...flags }); + await skillsListCommand({ ...flags }); + + expect(add).toHaveBeenCalledWith("upstash-redis"); + expect(written()).toContain("upstash-redis"); + }); + + it("refuses an empty init command", async () => { + getBox.mockResolvedValue({ setInitCommand: vi.fn() }); + + await expect(initCommandSetCommand(" ", { ...flags })).rejects.toThrow(/empty/); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a2cbccee..b4cebdde 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -29,6 +29,37 @@ import { cancelCommand, } from "./commands/status.js"; import { + scheduleExecCommand, + scheduleAgentCommand, + scheduleListCommand, + scheduleGetCommand, + scheduleUpdateCommand, + schedulePauseCommand, + scheduleResumeCommand, + scheduleDeleteCommand, +} from "./commands/schedule.js"; +import { + configureModelCommand, + configureHarnessCommand, + initCommandGetCommand, + initCommandSetCommand, + initCommandDeleteCommand, + networkPolicyCommand, + skillsAddCommand, + skillsRemoveCommand, + skillsListCommand, + resumeCommand, +} from "./commands/config.js"; +import { + browserGotoCommand, + browserObserveCommand, + browserExtractCommand, + browserLiveUrlCommand, + recordingStartCommand, + recordingStopCommand, + recordingListCommand, + recordingGetCommand, + recordingDownloadCommand, browserOpenCommand, browserTabsCommand, browserContentCommand, @@ -38,7 +69,7 @@ import { browserCdpUrlCommand, } from "./commands/browser.js"; import { useCommand } from "./commands/use.js"; -import { execCommand } from "./commands/exec.js"; +import { execCommand, execCodeCommand } from "./commands/exec.js"; import { filesReadCommand, filesWriteCommand, @@ -185,6 +216,19 @@ program ); }); +program + .command("code") + .argument("", "Inline code, or - to read stdin") + .description("Run inline code in the box (js, ts or python)") + .option("--lang ", "js, ts or python (default python)") + .option("--timeout ", "Give up after this long") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (source: string, opts) => + runCommand(async () => execCodeCommand(source, merged(opts))), + ); + const files = program.command("files").description("File operations inside the box"); /** Flags every file verb accepts, declared once. */ const withCommon = (cmd: import("commander").Command) => @@ -512,6 +556,183 @@ program runCommand(async () => fromSnapshotCommand(snapshotId, merged(opts))), ); +const schedule = program + .command("schedule") + .description("Cron schedules that run a command or an agent prompt in the box"); + +/** Flags every schedule verb accepts, declared once. */ +const withScheduleCommon = (cmd: import("commander").Command) => + cmd + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token"); + +withScheduleCommon( + schedule + .command("exec") + .argument("[command...]", "Command to run on the cron; put it after --") + .description("Schedule a shell command"), +) + .option("--cron ", "Cron expression, UTC") + .option("-C, --folder ", "Working directory inside the box") + .option("--webhook-url ", "POST the result here after each run") + .action(async (parts: string[], opts) => + runCommand(async () => scheduleExecCommand(parts, merged(opts))), + ); + +withScheduleCommon( + schedule + .command("agent") + .argument("[prompt...]", "Prompt for the agent") + .description("Schedule an agent prompt"), +) + .option("--cron ", "Cron expression, UTC") + .option("-C, --folder ", "Working directory inside the box") + .option("--model ", "Model to run the prompt on") + .option("--timeout ", "Give up after this long") + .action(async (parts: string[], opts) => + runCommand(async () => scheduleAgentCommand(parts, merged(opts))), + ); + +withScheduleCommon(schedule.command("list").description("List schedules")).action(async (opts) => + runCommand(async () => scheduleListCommand(merged(opts))), +); + +withScheduleCommon( + schedule.command("get").argument("").description("Show one schedule"), +).action(async (id: string, opts) => runCommand(async () => scheduleGetCommand(id, merged(opts)))); + +withScheduleCommon( + schedule + .command("update") + .argument("") + .argument("[command...]", "Replacement command; put it after --") + .description("Change a schedule in place"), +) + .option("--cron ", "New cron expression") + .option("--prompt ", "New prompt") + .option("-C, --folder ", "New working directory") + .option("--model ", "New model") + .action(async (id: string, parts: string[], opts) => + runCommand(async () => scheduleUpdateCommand(id, parts, merged(opts))), + ); + +withScheduleCommon( + schedule.command("pause").argument("").description("Pause a schedule"), +).action(async (id: string, opts) => + runCommand(async () => schedulePauseCommand(id, merged(opts))), +); + +withScheduleCommon( + schedule.command("resume").argument("").description("Resume a schedule"), +).action(async (id: string, opts) => + runCommand(async () => scheduleResumeCommand(id, merged(opts))), +); + +withScheduleCommon( + schedule.command("delete").argument("").description("Delete a schedule"), +).action(async (id: string, opts) => + runCommand(async () => scheduleDeleteCommand(id, merged(opts))), +); + +const skills = program.command("skills").description("Skills enabled inside the box"); + +withScheduleCommon( + skills.command("add").argument("").description("Enable a skill"), +).action(async (id: string, opts) => runCommand(async () => skillsAddCommand(id, merged(opts)))); + +withScheduleCommon( + skills.command("remove").argument("").description("Disable a skill"), +).action(async (id: string, opts) => runCommand(async () => skillsRemoveCommand(id, merged(opts)))); + +withScheduleCommon(skills.command("list").description("List enabled skills")).action(async (opts) => + runCommand(async () => skillsListCommand(merged(opts))), +); + +const config = program.command("config").description("Box configuration"); + +withScheduleCommon( + config.command("model").argument("").description("Point the agent at another model"), +).action(async (model: string, opts) => + runCommand(async () => configureModelCommand(model, merged(opts))), +); + +withScheduleCommon(config.command("harness").description("Point the box at a custom harness")) + .requiredOption("--command ", "Harness executable") + .option( + "--arg ", + "Argument passed before the prompt (repeatable)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .action(async (opts) => + runCommand(async () => + configureHarnessCommand({ ...merged(opts), args: opts.arg as string[] }), + ), + ); + +withScheduleCommon( + config + .command("network") + .argument("", "allow-all, deny-all or custom") + .description("Set the network policy"), +) + .option( + "--allow-domain ", + "Allowed domain (repeatable, custom only)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option( + "--allow-cidr ", + "Allowed CIDR (repeatable, custom only)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option( + "--deny-cidr ", + "Denied CIDR (repeatable, custom only)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .action(async (mode: string, opts) => + runCommand(async () => + networkPolicyCommand(mode, { + ...merged(opts), + allowDomain: opts.allowDomain as string[], + allowCidr: opts.allowCidr as string[], + denyCidr: opts.denyCidr as string[], + }), + ), + ); + +const initCommand = config.command("init-command").description("Command the box runs at startup"); + +withScheduleCommon(initCommand.command("get").description("Show the init command")).action( + async (opts) => runCommand(async () => initCommandGetCommand(merged(opts))), +); + +withScheduleCommon( + initCommand + .command("set") + .argument("", "The command, or - to read stdin") + .description("Set the init command"), +).action(async (command: string, opts) => + runCommand(async () => initCommandSetCommand(command, merged(opts))), +); + +withScheduleCommon(initCommand.command("delete").description("Remove the init command")).action( + async (opts) => runCommand(async () => initCommandDeleteCommand(merged(opts))), +); + +program + .command("resume") + .description("Resume a paused box (every other command resumes it anyway)") + .option("--box ", "Box to act on") + .option("--json", "Emit machine-readable output") + .option("--token ", "Upstash Box API token") + .action(async (opts) => runCommand(async () => resumeCommand(merged(opts)))); + const browser = program .command("browser") .description("Drive the box's headless Chromium (needs box create --browser)"); @@ -560,6 +781,62 @@ withBrowserCommon( browser.command("cdp-url").description("Print the CDP URL for Playwright or Puppeteer"), ).action(async (opts) => runCommand(async () => browserCdpUrlCommand(merged(opts)))); +withBrowserCommon( + browser.command("goto").argument("").description("Navigate an open tab"), +).action(async (url: string, opts) => + runCommand(async () => browserGotoCommand(url, merged(opts))), +); + +withBrowserCommon( + browser + .command("observe") + .argument("") + .description("List the actions available on the page"), +).action(async (instruction: string, opts) => + runCommand(async () => browserObserveCommand(instruction, merged(opts))), +); + +withBrowserCommon( + browser + .command("extract") + .argument("") + .description("Pull structured data off the page against a JSON Schema"), +) + .requiredOption("--schema ", "Flat JSON Schema object describing the fields") + .action(async (instruction: string, opts) => + runCommand(async () => browserExtractCommand(instruction, merged(opts))), + ); + +withBrowserCommon( + browser.command("live-url").description("Print a URL for watching the tab live"), +).action(async (opts) => runCommand(async () => browserLiveUrlCommand(merged(opts)))); + +const recordings = browser.command("recordings").description("Browser session recordings"); + +withBrowserCommon(recordings.command("start").description("Start recording")) + .option("--max-seconds ", "Stop automatically after this long") + .action(async (opts) => runCommand(async () => recordingStartCommand(merged(opts)))); + +withBrowserCommon(recordings.command("stop").description("Stop the active recording")).action( + async (opts) => runCommand(async () => recordingStopCommand(merged(opts))), +); + +withBrowserCommon(recordings.command("list").description("List recordings")).action(async (opts) => + runCommand(async () => recordingListCommand(merged(opts))), +); + +withBrowserCommon( + recordings.command("get").argument("").description("Show one recording"), +).action(async (id: string, opts) => runCommand(async () => recordingGetCommand(id, merged(opts)))); + +withBrowserCommon( + recordings.command("download").argument("").description("Download a recording"), +) + .requiredOption("-o, --out ", "Write the video here") + .action(async (id: string, opts) => + runCommand(async () => recordingDownloadCommand(id, merged(opts))), + ); + program .command("list") .description("List all boxes") diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 94287671..be8a9e58 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -1,4 +1,5 @@ -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; +import { z } from "zod"; import { Box, type Tab } from "@upstash/box"; import { resolveBoxId, announceBox } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; @@ -8,6 +9,8 @@ export type BrowserFlags = GlobalFlags & { tab?: string; out?: string; fullPage?: boolean; + schema?: string; + maxSeconds?: string; }; async function open(flags: BrowserFlags): Promise { @@ -139,3 +142,184 @@ export async function browserCdpUrlCommand(flags: BrowserFlags): Promise { note("Connect with: chromium.connectOverCDP()"); emit({ cdp_url: url }, [url], flags); } + +/** + * Navigate an existing tab. + * @param url - where to go. + * @param flags - the merged flags. + */ +export async function browserGotoCommand(url: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const content = await tab.goto(url); + emit(content, [content.title, content.url], flags); +} + +/** + * List the actions available on the page. + * @param instruction - what to look for. + * @param flags - the merged flags. + */ +export async function browserObserveCommand( + instruction: string, + flags: BrowserFlags, +): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const result = await tab.observe(instruction); + emit(result, [JSON.stringify(result, undefined, 2)], flags); +} + +/** + * Build a Zod object from a flat JSON Schema. + * + * `extract` takes a Zod schema, which cannot travel through a command line, so + * the CLI accepts the JSON Schema an agent can write to a file. Only a flat + * object of scalars and string arrays converts; anything nested is refused + * rather than silently dropped, because a field that vanishes from the schema + * comes back as a missing key rather than as an error. + * @param path - file holding the JSON Schema. + * @returns the equivalent Zod object. + */ +function schemaFromFile(path: string): z.ZodTypeAny { + let parsed: { + type?: string; + properties?: Record; + }; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + throw new CliError(`Could not read the schema at ${path}: ${(error as Error).message}`); + } + + if (parsed.type !== "object" || !parsed.properties) { + throw new CliError('The schema must be {"type":"object","properties":{...}}'); + } + + const shape: Record = {}; + for (const [key, prop] of Object.entries(parsed.properties)) { + switch (prop.type) { + case "string": + shape[key] = z.string(); + break; + case "number": + case "integer": + shape[key] = z.number(); + break; + case "boolean": + shape[key] = z.boolean(); + break; + case "array": + if (prop.items?.type !== "string") { + throw new CliError(`Field ${key}: only arrays of string are supported`); + } + shape[key] = z.array(z.string()); + break; + default: + throw new CliError(`Field ${key}: unsupported type ${prop.type ?? "(missing)"}`); + } + } + return z.object(shape); +} + +/** + * Pull structured data off the page against a schema. + * @param instruction - what to extract. + * @param flags - the merged flags; --schema names a JSON Schema file. + */ +export async function browserExtractCommand( + instruction: string, + flags: BrowserFlags, +): Promise { + if (!flags.schema) { + throw new CliError("--schema is required, holding a flat JSON Schema object"); + } + + const box = await open(flags); + const tab = await resolveTab(box, flags); + const result = await tab.extract(instruction, schemaFromFile(flags.schema) as never); + emit(result, [JSON.stringify(result, undefined, 2)], flags); +} + +/** + * Print the live-view URL for a tab, to watch it in a browser. + * @param flags - the merged flags. + */ +export async function browserLiveUrlCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const tab = await resolveTab(box, flags); + const url = await tab.liveViewUrl(); + emit({ live_view_url: url }, [url], flags); +} + +/** + * Start recording the browser session. + * @param flags - the merged flags. + */ +export async function recordingStartCommand(flags: BrowserFlags): Promise { + 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"); + } + + const handle = await box.browser.recordings.start( + seconds === undefined ? undefined : { maxDurationSeconds: seconds }, + ); + emit(handle, [handle.id ?? "recording started"], flags); +} + +/** + * Stop the active recording. + * @param flags - the merged flags. + */ +export async function recordingStopCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const result = await box.browser.recordings.stop(); + emit(result, [result?.id ?? "recording stopped"], flags); +} + +/** + * List recordings. + * @param flags - the merged flags. + */ +export async function recordingListCommand(flags: BrowserFlags): Promise { + const box = await open(flags); + const recordings = await box.browser.recordings.list(); + emit( + recordings, + recordings.length === 0 + ? ["No recordings."] + : recordings.map((rec) => `${rec.id}\t${rec.status ?? ""}`), + flags, + ); +} + +/** + * Show one recording. + * @param recordingId - which recording. + * @param flags - the merged flags. + */ +export async function recordingGetCommand(recordingId: string, flags: BrowserFlags): Promise { + const box = await open(flags); + const recording = await box.browser.recordings.get(recordingId); + emit(recording, [JSON.stringify(recording, undefined, 2)], flags); +} + +/** + * Download a recording to a file. + * @param recordingId - which recording. + * @param flags - the merged flags; --out names the destination. + */ +export async function recordingDownloadCommand( + recordingId: string, + flags: BrowserFlags, +): Promise { + if (!flags.out) { + throw new CliError("--out is required: video bytes cannot share stdout with text"); + } + + const box = await open(flags); + await box.browser.recordings.download(recordingId, { path: flags.out }); + emit({ id: recordingId, path: flags.out }, [`Downloaded to ${flags.out}`], flags); +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 00000000..1a112ff4 --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,178 @@ +import { readFileSync } from "node:fs"; +import { Box, type CustomHarnessConfig, type NetworkPolicy } from "@upstash/box"; +import { announceBox, resolveBoxId } from "../core/box-ref.js"; +import { CliError } from "../core/errors.js"; +import { emit, requireToken, type GlobalFlags } from "../core/io.js"; + +export type ConfigFlags = GlobalFlags & { + command?: string; + args?: string[]; + allowDomain?: string[]; + allowCidr?: string[]; + denyCidr?: string[]; +}; + +async function open(flags: GlobalFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + return Box.get(resolved.id, { apiKey: requireToken(flags.token) }); +} + +/** + * Point the box's agent at a different model. + * @param model - the model identifier. + * @param flags - the merged flags. + */ +export async function configureModelCommand(model: string, flags: GlobalFlags): Promise { + const box = await open(flags); + await box.configureModel(model); + emit({ model }, [`Model set to ${model}`], flags); +} + +/** + * Point the box at a custom agent harness. + * @param flags - the merged flags; --command names the executable. + */ +export async function configureHarnessCommand(flags: ConfigFlags): Promise { + if (!flags.command) { + throw new CliError("--command is required"); + } + + const box = await open(flags); + const harness: CustomHarnessConfig = { + command: flags.command, + ...(flags.args && flags.args.length > 0 ? { args: flags.args } : {}), + }; + await box.configureCustomHarness(harness); + + emit(harness, [`Custom harness set to ${flags.command}`], flags); +} + +/** + * Show the box's init command. + * @param flags - the merged flags. + */ +export async function initCommandGetCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + const initCommand = await box.getInitCommand(); + emit( + { init_command: initCommand ?? null }, + initCommand ? [String(initCommand)] : ["No init command set."], + flags, + ); +} + +/** + * Set the command the box runs when it starts. + * + * `-` reads the command from stdin, so a multi-line script does not have to + * survive the shell's quoting on the way in. + * @param command - the command, or `-` for stdin. + * @param flags - the merged flags. + */ +export async function initCommandSetCommand(command: string, flags: GlobalFlags): Promise { + const text = command === "-" ? readFileSync(0, "utf8") : command; + if (!text.trim()) throw new CliError("Init command is empty"); + + const box = await open(flags); + await box.setInitCommand(text); + emit({ init_command: text }, ["Init command set."], flags); +} + +/** + * Remove the box's init command. + * @param flags - the merged flags. + */ +export async function initCommandDeleteCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + await box.deleteInitCommand(); + emit({ init_command: null }, ["Init command removed."], flags); +} + +/** + * Set the box's network policy. + * + * The modes are exclusive: `allow-all` and `deny-all` take no lists, and any + * list implies `custom`. Sending a list with a blanket mode would look like it + * narrowed the policy while doing nothing. + * @param mode - allow-all, deny-all, or custom. + * @param flags - the merged flags, with the allow and deny lists. + */ +export async function networkPolicyCommand(mode: string, flags: ConfigFlags): Promise { + const lists = + (flags.allowDomain?.length ?? 0) + + (flags.allowCidr?.length ?? 0) + + (flags.denyCidr?.length ?? 0); + + if (mode !== "allow-all" && mode !== "deny-all" && mode !== "custom") { + throw new CliError("mode must be one of: allow-all, deny-all, custom"); + } + if (mode !== "custom" && lists > 0) { + throw new CliError( + `--allow-domain, --allow-cidr and --deny-cidr only apply to 'custom', not '${mode}'`, + ); + } + if (mode === "custom" && lists === 0) { + throw new CliError("custom needs at least one of --allow-domain, --allow-cidr or --deny-cidr"); + } + + const policy: NetworkPolicy = + mode === "custom" + ? { + mode: "custom", + ...(flags.allowDomain?.length ? { allowedDomains: flags.allowDomain } : {}), + ...(flags.allowCidr?.length ? { allowedCidrs: flags.allowCidr } : {}), + ...(flags.denyCidr?.length ? { deniedCidrs: flags.denyCidr } : {}), + } + : { mode }; + + const box = await open(flags); + await box.updateNetworkPolicy(policy); + emit(policy, [`Network policy set to ${mode}`], flags); +} + +/** + * Add a skill to the box. + * @param skillId - the skill to enable. + * @param flags - the merged flags. + */ +export async function skillsAddCommand(skillId: string, flags: GlobalFlags): Promise { + const box = await open(flags); + await box.skills.add(skillId); + emit({ skill: skillId, enabled: true }, [`Added ${skillId}`], flags); +} + +/** + * Remove a skill from the box. + * @param skillId - the skill to disable. + * @param flags - the merged flags. + */ +export async function skillsRemoveCommand(skillId: string, flags: GlobalFlags): Promise { + const box = await open(flags); + await box.skills.remove(skillId); + emit({ skill: skillId, enabled: false }, [`Removed ${skillId}`], flags); +} + +/** + * List the box's enabled skills. + * @param flags - the merged flags. + */ +export async function skillsListCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + const skills = await box.skills.list(); + emit(skills, skills.length === 0 ? ["No skills enabled."] : skills, flags); +} + +/** + * Resume a paused box. + * + * Every other command resumes on its own, so this exists for the case where + * you want the box warm before timing something. + * @param flags - the merged flags. + */ +export async function resumeCommand(flags: GlobalFlags): Promise { + const box = await open(flags); + await box.resume(); + const { status } = await box.getStatus(); + emit({ id: box.id, status }, [`${box.id} is ${status}`], flags); +} diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index d92b2cdf..28826494 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { Box } from "@upstash/box"; import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; @@ -53,3 +54,42 @@ export async function execCommand(parts: string[], flags: ExecFlags): Promise { + const langs = new Set(["js", "ts", "python"]); + const lang = flags.lang ?? "python"; + if (!langs.has(lang)) { + throw new CliError(`--lang must be one of: ${[...langs].join(", ")}`); + } + + const code = source === "-" ? readFileSync(0, "utf8") : source; + if (!code.trim()) throw new CliError("No code to run"); + + const timeout = flags.timeout === undefined ? undefined : Number(flags.timeout); + if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { + throw new CliError("--timeout must be a positive number of seconds"); + } + + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); + + const run = await box.exec.code({ + code, + lang: lang as "js" | "ts" | "python", + ...(timeout === undefined ? {} : { timeout }), + }); + + emit({ output: run.result, exit_code: 0 }, [run.result], flags); +} diff --git a/packages/cli/src/commands/schedule.ts b/packages/cli/src/commands/schedule.ts new file mode 100644 index 00000000..25f214d4 --- /dev/null +++ b/packages/cli/src/commands/schedule.ts @@ -0,0 +1,179 @@ +import { Box } from "@upstash/box"; +import { announceBox, resolveBoxId } from "../core/box-ref.js"; +import { CliError } from "../core/errors.js"; +import { emit, requireToken, type GlobalFlags } from "../core/io.js"; + +export type ScheduleFlags = GlobalFlags & { + cron?: string; + folder?: string; + model?: string; + timeout?: string; + webhookUrl?: string; + prompt?: string; +}; + +async function open(flags: ScheduleFlags): Promise { + const resolved = resolveBoxId({ flag: flags.box }); + announceBox(resolved); + return Box.get(resolved.id, { apiKey: requireToken(flags.token) }); +} + +/** One line per schedule, id first so the other verbs have something to take. */ +function line(schedule: { + id: string; + type: string; + cron: string; + status: string; + command?: string[]; + prompt?: string; +}): string { + const what = schedule.command ? schedule.command.join(" ") : (schedule.prompt ?? ""); + return `${schedule.id}\t${schedule.status}\t${schedule.type}\t${schedule.cron}\t${what}`; +} + +function timeoutOf(flags: ScheduleFlags): number | undefined { + if (flags.timeout === undefined) return undefined; + const seconds = Number(flags.timeout); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new CliError("--timeout must be a positive number of seconds"); + } + return seconds; +} + +/** + * Schedule a shell command on a cron. + * + * The command goes after `--`, same as `box exec`, so its own flags are not + * read as this command's. + * @param command - the argv to run in the box. + * @param flags - the merged flags; --cron is required. + */ +export async function scheduleExecCommand(command: string[], flags: ScheduleFlags): Promise { + if (!flags.cron) throw new CliError("--cron is required, e.g. --cron '0 9 * * *'"); + if (command.length === 0) { + throw new CliError("Nothing to schedule. Put the command after --"); + } + + const box = await open(flags); + const schedule = await box.schedule.exec({ + cron: flags.cron, + command, + ...(flags.folder === undefined ? {} : { folder: flags.folder }), + ...(flags.webhookUrl === undefined ? {} : { webhookUrl: flags.webhookUrl }), + }); + + emit(schedule, [schedule.id], flags); +} + +/** + * Schedule an agent prompt on a cron. + * @param prompt - the prompt words. + * @param flags - the merged flags; --cron is required. + */ +export async function scheduleAgentCommand(prompt: string[], flags: ScheduleFlags): Promise { + if (!flags.cron) throw new CliError("--cron is required, e.g. --cron '0 9 * * *'"); + const text = prompt.join(" ").trim(); + if (!text) throw new CliError("Nothing to schedule. Give the agent a prompt."); + + const box = await open(flags); + const timeout = timeoutOf(flags); + const schedule = await box.schedule.agent({ + cron: flags.cron, + prompt: text, + ...(flags.folder === undefined ? {} : { folder: flags.folder }), + ...(flags.model === undefined ? {} : { model: flags.model }), + ...(timeout === undefined ? {} : { timeout }), + }); + + emit(schedule, [schedule.id], flags); +} + +/** + * List the box's schedules. + * @param flags - the merged flags. + */ +export async function scheduleListCommand(flags: ScheduleFlags): Promise { + const box = await open(flags); + const schedules = await box.schedule.list(); + emit(schedules, schedules.length === 0 ? ["No schedules."] : schedules.map(line), flags); +} + +/** + * Show one schedule, including its run counts. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function scheduleGetCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + const schedule = await box.schedule.get(id); + emit( + schedule, + [line(schedule), `runs: ${schedule.total_runs}, failures: ${schedule.total_failures}`], + flags, + ); +} + +/** + * Change a schedule in place. + * + * Only the fields named are sent, so an update that changes the cron leaves the + * command and the webhook alone. + * @param id - the schedule id. + * @param command - a replacement command, when given after `--`. + * @param flags - the merged flags. + */ +export async function scheduleUpdateCommand( + id: string, + command: string[], + flags: ScheduleFlags, +): Promise { + 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 }), + }; + if (Object.keys(changes).length === 0) { + throw new CliError( + "Nothing to update. Pass --cron, --prompt, --folder, --model, or a command after --", + ); + } + + const box = await open(flags); + const schedule = await box.schedule.update(id, changes); + emit(schedule, [line(schedule)], flags); +} + +/** + * Pause a schedule without deleting it. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function schedulePauseCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + await box.schedule.pause(id); + emit({ id, status: "paused" }, [`Paused ${id}`], flags); +} + +/** + * Resume a paused schedule. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function scheduleResumeCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + await box.schedule.resume(id); + emit({ id, status: "active" }, [`Resumed ${id}`], flags); +} + +/** + * Delete a schedule. + * @param id - the schedule id. + * @param flags - the merged flags. + */ +export async function scheduleDeleteCommand(id: string, flags: ScheduleFlags): Promise { + const box = await open(flags); + await box.schedule.delete(id); + emit({ id, deleted: true }, [`Deleted ${id}`], flags); +} From febaf8badaab1a405692b225940588f580a6aba6 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 12:06:08 +0300 Subject: [PATCH 3/7] DX-2998: fix the timeout units, box code's exit status, and Python parity 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. --- .changeset/cli-browser-runs-snapshots.md | 2 + .../commands/code-and-extract.test.ts | 186 ++++++++++++++++++ .../commands/schedule-config.test.ts | 11 ++ packages/cli/src/commands/exec.ts | 21 +- packages/cli/src/commands/schedule.ts | 13 +- packages/cli/src/commands/status.ts | 24 ++- packages/cli/src/core/io.ts | 28 +++ packages/python-sdk/PARITY.md | 2 +- .../python-sdk/tests/_async/test_box_misc.py | 14 +- .../python-sdk/upstash_box/_async/client.py | 9 + .../python-sdk/upstash_box/_sync/client.py | 9 + packages/sdk/src/client.ts | 6 +- 12 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/__tests__/commands/code-and-extract.test.ts diff --git a/.changeset/cli-browser-runs-snapshots.md b/.changeset/cli-browser-runs-snapshots.md index 644b86a4..a5cc390e 100644 --- a/.changeset/cli-browser-runs-snapshots.md +++ b/.changeset/cli-browser-runs-snapshots.md @@ -17,3 +17,5 @@ Give the CLI parity with the SDK, so an agent driving a terminal can reach every - The rest of the browser: `goto`, `observe`, `extract`, `live-url`, and `recordings start|stop|list|get|download`. `extract` takes a flat JSON Schema file, since a Zod schema cannot travel through a command line, and refuses anything nested rather than silently dropping fields. `exec.session` is deliberately absent: a 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 for the public-URL calls, so `box public-url` already covers them. + +`Box.cancelRun()` is added to the SDK, because `Run.cancel()` needs the object the original call returned, which another process never holds. The Python SDK gets the same method as `cancel_run`; it versions separately, so it is not in this changeset. diff --git a/packages/cli/src/__tests__/commands/code-and-extract.test.ts b/packages/cli/src/__tests__/commands/code-and-extract.test.ts new file mode 100644 index 00000000..83a8b4d0 --- /dev/null +++ b/packages/cli/src/__tests__/commands/code-and-extract.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execCodeCommand } from "../../commands/exec.js"; +import { browserExtractCommand } from "../../commands/browser.js"; +import { CliError } from "../../core/errors.js"; + +const getBox = vi.hoisted(() => vi.fn()); +vi.mock("@upstash/box", () => ({ Box: { get: getBox } })); + +vi.mock("../../core/box-ref.js", () => ({ + resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })), + announceBox: vi.fn(), +})); + +describe("box code", () => { + let stdout: ReturnType; + let stderr: ReturnType; + const flags = { box: "b1", token: "box_test" }; + const written = () => stdout.mock.calls.map((c) => String(c[0])).join(""); + + /** A box whose exec.code resolves to a Run-shaped result. */ + const codeReturns = (run: Record) => { + const code = vi.fn().mockResolvedValue(run); + getBox.mockResolvedValue({ exec: { code } }); + return code; + }; + + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + getBox.mockReset(); + process.exitCode = undefined; + }); + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("converts the timeout from seconds to milliseconds", async () => { + // --timeout 30 against a milliseconds option would kill the code in 30ms. + const code = codeReturns({ stdout: "", stderr: "", exitCode: 0 }); + + await execCodeCommand("print(1)", { ...flags, timeout: "30" }); + + expect(code).toHaveBeenCalledWith(expect.objectContaining({ timeout: 30_000 })); + }); + + it("passes a failing snippet's exit code through", async () => { + // Reporting 0 would make `box code ... && next` run the next thing. + codeReturns({ stdout: "", stderr: "Traceback", exitCode: 1 }); + + await execCodeCommand("raise SystemExit(1)", { ...flags }); + + expect(process.exitCode).toBe(1); + }); + + it("leaves the exit code alone on success", async () => { + codeReturns({ stdout: "2\n", stderr: "", exitCode: 0 }); + + await execCodeCommand("print(1+1)", { ...flags }); + + expect(process.exitCode).toBeUndefined(); + expect(written()).toContain("2"); + }); + + it("reports the real exit code under --json", async () => { + codeReturns({ stdout: "out", stderr: "err", exitCode: 3 }); + + await execCodeCommand("x", { ...flags, json: true }); + + expect(JSON.parse(written())).toEqual({ stdout: "out", stderr: "err", exit_code: 3 }); + expect(process.exitCode).toBe(3); + }); + + it("sends stderr to stderr, so stdout stays pipeable", async () => { + codeReturns({ stdout: "data", stderr: "warning", exitCode: 0 }); + + await execCodeCommand("x", { ...flags }); + + expect(written()).toContain("data"); + expect(written()).not.toContain("warning"); + expect(stderr.mock.calls.map((c) => String(c[0])).join("")).toContain("warning"); + }); + + it("rejects an unknown language rather than guessing", async () => { + codeReturns({ stdout: "", stderr: "", exitCode: 0 }); + + await expect(execCodeCommand("x", { ...flags, lang: "ruby" })).rejects.toThrow(/--lang/); + }); + + it("rejects a timeout that is not a positive number of seconds", async () => { + codeReturns({ stdout: "", stderr: "", exitCode: 0 }); + + await expect(execCodeCommand("x", { ...flags, timeout: "nope" })).rejects.toThrow(CliError); + }); +}); + +describe("browser extract schema", () => { + let dir: string; + const flags = { box: "b1", token: "box_test" }; + + beforeEach(() => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + dir = mkdtempSync(join(tmpdir(), "box-schema-")); + getBox.mockReset(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const schemaAt = (body: unknown) => { + const path = join(dir, "schema.json"); + writeFileSync(path, JSON.stringify(body)); + return path; + }; + + const tabReturning = (result: unknown) => { + const extract = vi.fn().mockResolvedValue(result); + getBox.mockResolvedValue({ + browser: { listTabs: vi.fn().mockResolvedValue([{ id: "t", extract }]) }, + }); + return extract; + }; + + it("converts a flat schema and extracts against it", async () => { + const extract = tabReturning({ title: "Hi", count: 2 }); + const schema = schemaAt({ + type: "object", + properties: { + title: { type: "string" }, + count: { type: "number" }, + ok: { type: "boolean" }, + tags: { type: "array", items: { type: "string" } }, + }, + }); + + await browserExtractCommand("read the page", { ...flags, schema }); + + expect(extract).toHaveBeenCalledWith("read the page", expect.anything()); + }); + + it("refuses a nested object rather than dropping the field", async () => { + // A dropped field comes back as a missing key, which reads as "the page did + // not have it" rather than as a schema the CLI could not express. + tabReturning({}); + const schema = schemaAt({ + type: "object", + properties: { author: { type: "object" } }, + }); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow( + /unsupported type object/, + ); + }); + + it("refuses an array of non-strings", async () => { + tabReturning({}); + const schema = schemaAt({ + type: "object", + properties: { rows: { type: "array", items: { type: "number" } } }, + }); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow( + /arrays of string/, + ); + }); + + it("refuses a schema that is not an object", async () => { + tabReturning({}); + const schema = schemaAt({ type: "string" }); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow(/type.*object/); + }); + + it("names the file when it cannot be read", async () => { + tabReturning({}); + + await expect( + browserExtractCommand("x", { ...flags, schema: join(dir, "missing.json") }), + ).rejects.toThrow(/Could not read the schema/); + }); +}); diff --git a/packages/cli/src/__tests__/commands/schedule-config.test.ts b/packages/cli/src/__tests__/commands/schedule-config.test.ts index 54c8874b..e773e9e5 100644 --- a/packages/cli/src/__tests__/commands/schedule-config.test.ts +++ b/packages/cli/src/__tests__/commands/schedule-config.test.ts @@ -72,6 +72,17 @@ describe("schedule and config", () => { expect(agent).toHaveBeenCalledWith({ cron: "@daily", prompt: "check the logs" }); }); + it("converts the timeout from seconds to the milliseconds the SDK takes", async () => { + // Every --timeout flag is documented in seconds and every SDK option is + // milliseconds. Passing the number straight through would ask for 60ms. + const agent = vi.fn().mockResolvedValue({ id: "sch-2" }); + getBox.mockResolvedValue({ schedule: { agent } }); + + await scheduleAgentCommand(["x"], { ...flags, cron: "@daily", timeout: "60" }); + + expect(agent).toHaveBeenCalledWith(expect.objectContaining({ timeout: 60_000 })); + }); + it("rejects a timeout that is not a positive number", async () => { getBox.mockResolvedValue({ schedule: { agent: vi.fn() } }); diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index 28826494..833a3db1 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -3,7 +3,7 @@ import { Box } from "@upstash/box"; import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; import { buildCommand, execCollect, execStream } from "../core/exec.js"; -import { emit, requireToken, type GlobalFlags } from "../core/io.js"; +import { emit, requireToken, timeoutMs, type GlobalFlags } from "../core/io.js"; export type ExecFlags = GlobalFlags & { cwd?: string }; @@ -76,10 +76,7 @@ export async function execCodeCommand( const code = source === "-" ? readFileSync(0, "utf8") : source; if (!code.trim()) throw new CliError("No code to run"); - const timeout = flags.timeout === undefined ? undefined : Number(flags.timeout); - if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { - throw new CliError("--timeout must be a positive number of seconds"); - } + const timeout = timeoutMs(flags.timeout); const resolved = resolveBoxId({ flag: flags.box }); announceBox(resolved); @@ -91,5 +88,17 @@ export async function execCodeCommand( ...(timeout === undefined ? {} : { timeout }), }); - emit({ output: run.result, exit_code: 0 }, [run.result], flags); + // Same contract as `box exec`: the remote status passes through, so `box code + // ... && next` chains correctly and --json reports the real code. Claiming 0 + // would make a failing snippet look successful to both. + const exitCode = run.exitCode ?? 0; + if (flags.json) { + emit({ stdout: run.stdout, stderr: run.stderr, exit_code: exitCode }, "", flags); + } else { + if (run.stdout) + process.stdout.write(run.stdout.endsWith("\n") ? run.stdout : `${run.stdout}\n`); + if (run.stderr) + process.stderr.write(run.stderr.endsWith("\n") ? run.stderr : `${run.stderr}\n`); + } + if (exitCode !== 0) process.exitCode = exitCode; } diff --git a/packages/cli/src/commands/schedule.ts b/packages/cli/src/commands/schedule.ts index 25f214d4..8f6318cf 100644 --- a/packages/cli/src/commands/schedule.ts +++ b/packages/cli/src/commands/schedule.ts @@ -1,7 +1,7 @@ import { Box } from "@upstash/box"; import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; -import { emit, requireToken, type GlobalFlags } from "../core/io.js"; +import { emit, requireToken, timeoutMs, type GlobalFlags } from "../core/io.js"; export type ScheduleFlags = GlobalFlags & { cron?: string; @@ -31,15 +31,6 @@ function line(schedule: { return `${schedule.id}\t${schedule.status}\t${schedule.type}\t${schedule.cron}\t${what}`; } -function timeoutOf(flags: ScheduleFlags): number | undefined { - if (flags.timeout === undefined) return undefined; - const seconds = Number(flags.timeout); - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new CliError("--timeout must be a positive number of seconds"); - } - return seconds; -} - /** * Schedule a shell command on a cron. * @@ -76,7 +67,7 @@ export async function scheduleAgentCommand(prompt: string[], flags: ScheduleFlag if (!text) throw new CliError("Nothing to schedule. Give the agent a prompt."); const box = await open(flags); - const timeout = timeoutOf(flags); + const timeout = timeoutMs(flags.timeout); const schedule = await box.schedule.agent({ cron: flags.cron, prompt: text, diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0bd15503..0da8a56c 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,6 +1,7 @@ import { Box } from "@upstash/box"; import { announceBox, findBoxFile, resolveBoxId } from "../core/box-ref.js"; import { emit, requireToken, type GlobalFlags } from "../core/io.js"; +import { CliError } from "../core/errors.js"; /** * Report which box is selected, where that came from, and what it is doing. @@ -41,6 +42,25 @@ export async function statusCommand(flags: GlobalFlags): Promise { ); } +/** + * Read a whole-number flag. + * + * `Number("nope")` is NaN, which serialises into the query string as `NaN` and + * is silently ignored upstream, so the caller gets a default page and no idea + * the flag was wrong. + * @param raw - the flag as given. + * @param name - the flag name, for the message. + * @returns the parsed count. + * @throws CliError when it is not a non-negative whole number. + */ +function countFlag(raw: string, name: string): number { + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) { + throw new CliError(`${name} must be a whole number`); + } + return value; +} + /** * List the box's runs, most recent first. * @@ -80,8 +100,8 @@ export async function statusLogsCommand( const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); const logs = await box.logs({ - ...(flags.limit === undefined ? {} : { limit: Number(flags.limit) }), - ...(flags.offset === undefined ? {} : { offset: Number(flags.offset) }), + ...(flags.limit === undefined ? {} : { limit: countFlag(flags.limit, "--limit") }), + ...(flags.offset === undefined ? {} : { offset: countFlag(flags.offset, "--offset") }), }); emit( diff --git a/packages/cli/src/core/io.ts b/packages/cli/src/core/io.ts index 037af68f..6721df45 100644 --- a/packages/cli/src/core/io.ts +++ b/packages/cli/src/core/io.ts @@ -84,3 +84,31 @@ export function requireToken(flagToken?: string): string { } return token; } + +/** Largest delay Node's timers accept before clamping. */ +const MAX_TIMER_MS = 2_147_483_647; + +/** + * Convert a `--timeout` flag from seconds to the milliseconds the SDK takes. + * + * Every timeout flag is documented in seconds and every SDK option is in + * milliseconds, so a caller that passes the number straight through asks for a + * 30ms limit when it said 30 seconds, and the work is killed before it starts. + * @param value - the raw flag, when given. + * @returns the timeout in milliseconds, or undefined when unset. + * @throws CliError when the value is not a usable number of seconds. + */ +export function timeoutMs(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new CliError("--timeout must be a positive number of seconds"); + } + // Node clamps anything past this to about a millisecond, which would abort + // the run immediately rather than after the long wait that was asked for. + if (seconds * 1000 > MAX_TIMER_MS) { + throw new CliError(`--timeout must be at most ${Math.floor(MAX_TIMER_MS / 1000)} seconds`); + } + return seconds * 1000; +} diff --git a/packages/python-sdk/PARITY.md b/packages/python-sdk/PARITY.md index 614eaeef..bf3ad74c 100644 --- a/packages/python-sdk/PARITY.md +++ b/packages/python-sdk/PARITY.md @@ -38,7 +38,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun` | `getStatus`, `pause`, `resume`, `delete` | `get_status`, `pause`, `resume`, `delete` | | `snapshot`, `listSnapshots`, `deleteSnapshot` | `snapshot`, `list_snapshots`, `delete_snapshot` | | `getInitCommand`, `setInitCommand`, `deleteInitCommand` | `get_init_command`, `set_init_command`, `delete_init_command` | -| `logs`, `listRuns` | `logs`, `list_runs` | +| `logs`, `listRuns`, `cancelRun` | `logs`, `list_runs`, `cancel_run` | | `getPublicURL`, `listPublicURLs`, `deletePublicURL` | `get_public_url`, `list_public_urls`, `delete_public_url` | | `id`, `size`, `keepAlive` | `id`, `size`, `keep_alive` | | `browser.tab.create` | `browser.tab.create` | diff --git a/packages/python-sdk/tests/_async/test_box_misc.py b/packages/python-sdk/tests/_async/test_box_misc.py index 0e50b757..01f6c887 100644 --- a/packages/python-sdk/tests/_async/test_box_misc.py +++ b/packages/python-sdk/tests/_async/test_box_misc.py @@ -1,4 +1,4 @@ -"""skills, config_model, public URLs, lifecycle, init-command, logs, list_runs.""" +"""skills, config_model, public URLs, lifecycle, init-command, logs, list_runs, cancel_run.""" import httpx import pytest @@ -168,6 +168,18 @@ async def test_init_command_crud(): await box.aclose() +# ---------- cancel_run ---------- + + +@respx.mock +async def test_cancel_run(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/runs/r1/cancel").mock(return_value=httpx.Response(200, json={})) + await box.cancel_run("r1") + assert route.called + await box.aclose() + + # ---------- logs / list_runs ---------- diff --git a/packages/python-sdk/upstash_box/_async/client.py b/packages/python-sdk/upstash_box/_async/client.py index 8319060c..36eff543 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -1569,6 +1569,15 @@ async def logs( data = await self._request("GET", f"/v2/box/{self.id}/logs{qs}") return [LogEntry.model_validate(entry) for entry in data.get("logs", [])] + async def cancel_run(self, run_id: str) -> None: + """Cancel a run by id. + + ``Run.cancel()`` only works while holding the object the call returned, + which a separate process never is, so cancelling by id is the only route + open to a caller that did not start the run. + """ + await self._request("POST", f"/v2/box/{self.id}/runs/{run_id}/cancel") + async def list_runs(self) -> List[BoxRunData]: data = await self._request("GET", f"/v2/box/{self.id}/runs") diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 9c49635e..6f20352a 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -1554,6 +1554,15 @@ def logs(self, *, offset: Optional[int] = None, limit: Optional[int] = None) -> data = self._request("GET", f"/v2/box/{self.id}/logs{qs}") return [LogEntry.model_validate(entry) for entry in data.get("logs", [])] + def cancel_run(self, run_id: str) -> None: + """Cancel a run by id. + + ``Run.cancel()`` only works while holding the object the call returned, + which a separate process never is, so cancelling by id is the only route + open to a caller that did not start the run. + """ + self._request("POST", f"/v2/box/{self.id}/runs/{run_id}/cancel") + def list_runs(self) -> List[BoxRunData]: data = self._request("GET", f"/v2/box/{self.id}/runs") diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c5c16a53..80f86f76 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2625,9 +2625,6 @@ export class Box { return data.logs; } - /** - * List all runs for this box, newest first. - */ /** * Cancel a run by id. * @@ -2640,6 +2637,9 @@ export class Box { await this._request("POST", `/v2/box/${this.id}/runs/${runId}/cancel`); } + /** + * List all runs for this box, newest first. + */ async listRuns(): Promise { const data = await this._request<{ runs: BoxRunData[] }>("GET", `/v2/box/${this.id}/runs`); return data.runs; From e4762840d991fb616111315f0251f7898ee0085b Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 12:17:09 +0300 Subject: [PATCH 4/7] DX-2998: use the timeout helper in box run, and log cancel_run in the 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. --- packages/cli/src/commands/run.ts | 18 +++--------------- packages/python-sdk/CHANGELOG.md | 7 +++++++ packages/python-sdk/pyproject.toml | 2 +- packages/python-sdk/upstash_box/_version.py | 2 +- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 8668ed56..74cfa79f 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -2,10 +2,7 @@ import { readFileSync } from "node:fs"; import { Box } from "@upstash/box"; import { announceBox, resolveBoxId } from "../core/box-ref.js"; import { CliError } from "../core/errors.js"; -import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; - -/** Largest delay Node's timers accept before clamping. */ -const MAX_TIMER_MS = 2_147_483_647; +import { emit, note, requireToken, timeoutMs, type GlobalFlags } from "../core/io.js"; export type RunFlags = GlobalFlags & { timeout?: string; @@ -61,16 +58,7 @@ function toolLine(name: string, input: Record): string { */ export async function runCommandAction(parts: string[], flags: RunFlags): Promise { const prompt = promptFrom(parts); - const timeout = flags.timeout === undefined ? undefined : Number(flags.timeout); - if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { - throw new CliError("--timeout must be a positive number of seconds"); - } - // The SDK arms this with setTimeout, and Node clamps a delay beyond its timer - // range to about a millisecond, so an out-of-range timeout would abort the - // run almost immediately instead of allowing more time. - if (timeout !== undefined && timeout * 1000 > MAX_TIMER_MS) { - throw new CliError(`--timeout must be at most ${Math.floor(MAX_TIMER_MS / 1000)} seconds`); - } + const timeout = timeoutMs(flags.timeout); const resolved = resolveBoxId({ flag: flags.box }); announceBox(resolved); @@ -78,7 +66,7 @@ export async function runCommandAction(parts: string[], flags: RunFlags): Promis const run = await box.agent.stream({ prompt, - ...(timeout === undefined ? {} : { timeout: timeout * 1000 }), + ...(timeout === undefined ? {} : { timeout }), }); let output = ""; diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index e07b62fa..eb88460d 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `upstash-box` (Python) are documented here. +## 0.3.2 + +- Add `cancel_run(run_id)`, matching `cancelRun` in `@upstash/box`. `Run.cancel()` + only works while holding the object the original call returned, which a + separate process never is, so cancelling by id is the only route open to a + caller that did not start the run. + ## 0.3.1 - Fix `git.update_config()` sending its request to `/v2/box/{id}/git-config`, diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index b3be4d07..6b2e74bf 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "upstash-box" -version = "0.3.1" +version = "0.3.2" description = "Upstash Box SDK - Python client for async and parallel AI coding agents" readme = "README.md" license = { text = "MIT" } diff --git a/packages/python-sdk/upstash_box/_version.py b/packages/python-sdk/upstash_box/_version.py index 704784e3..6273e158 100644 --- a/packages/python-sdk/upstash_box/_version.py +++ b/packages/python-sdk/upstash_box/_version.py @@ -3,4 +3,4 @@ Keep in sync with pyproject.toml — bumped by the release process. """ -__version__ = "0.3.0" +__version__ = "0.3.2" From 2b6f65af5aa558a5b176adccd34f29f0677b4d33 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 12:35:21 +0300 Subject: [PATCH 5/7] DX-2998: address the Copilot review 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. --- .../src/__tests__/commands/browser.test.ts | 16 ++++++++++++ .../commands/code-and-extract.test.ts | 9 +++++++ .../__tests__/commands/from-snapshot.test.ts | 21 +++++++++++++++ .../commands/runs-and-snapshots.test.ts | 17 ++++++++++++ .../commands/schedule-config.test.ts | 15 +++++++++++ packages/cli/src/cli.ts | 2 ++ packages/cli/src/commands/browser.ts | 26 ++++++++++++++++--- packages/cli/src/commands/from-snapshot.ts | 13 ++++++++-- packages/cli/src/commands/schedule.ts | 4 ++- packages/cli/src/commands/status.ts | 16 +++++++----- .../sdk/src/__tests__/box-instance.test.ts | 15 +++++++++++ 11 files changed, 142 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/__tests__/commands/browser.test.ts b/packages/cli/src/__tests__/commands/browser.test.ts index 60772060..9ddd06f1 100644 --- a/packages/cli/src/__tests__/commands/browser.test.ts +++ b/packages/cli/src/__tests__/commands/browser.test.ts @@ -93,6 +93,22 @@ describe("box browser", () => { expect(written()).toContain("hello"); }); + it("prints the links, which the description promises", async () => { + // Without this only --json exposed a destination URL, so the default output + // did not match what the command says it reads. + const content = vi.fn().mockResolvedValue({ + title: "T", + url: "u", + text: "body", + links: [{ text: "Docs", href: "https://docs.test" }], + }); + boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1", content }]) }); + + await browserContentCommand({ ...flags }); + + expect(written()).toContain("https://docs.test"); + }); + it("refuses to guess when several tabs are open", async () => { boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1" }, { id: "tab-2" }]) }); diff --git a/packages/cli/src/__tests__/commands/code-and-extract.test.ts b/packages/cli/src/__tests__/commands/code-and-extract.test.ts index 83a8b4d0..6a60e19f 100644 --- a/packages/cli/src/__tests__/commands/code-and-extract.test.ts +++ b/packages/cli/src/__tests__/commands/code-and-extract.test.ts @@ -176,6 +176,15 @@ describe("browser extract schema", () => { await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow(/type.*object/); }); + it("refuses a schema file holding null with a message, not a TypeError", async () => { + // JSON.parse("null") succeeds, so the property read below it threw a raw + // TypeError past the CLI's own validation. + tabReturning({}); + const schema = schemaAt(null); + + await expect(browserExtractCommand("x", { ...flags, schema })).rejects.toThrow(/type.*object/); + }); + it("names the file when it cannot be read", async () => { tabReturning({}); diff --git a/packages/cli/src/__tests__/commands/from-snapshot.test.ts b/packages/cli/src/__tests__/commands/from-snapshot.test.ts index 9de918c8..ec1d407d 100644 --- a/packages/cli/src/__tests__/commands/from-snapshot.test.ts +++ b/packages/cli/src/__tests__/commands/from-snapshot.test.ts @@ -132,6 +132,27 @@ describe("fromSnapshotCommand", () => { expect(startRepl).not.toHaveBeenCalled(); }); + it("still reports the id when the directory cannot be written", async () => { + // The box exists and is billing by this point; swallowing its id would + // leave it running with no way to find it. + vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); + vi.mocked(writeBoxFile).mockImplementationOnce(() => { + throw new Error("EROFS: read-only file system"); + }); + + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + let written = ""; + try { + await fromSnapshotCommand("snap-1", { token: "key", repl: false }); + // mockRestore also clears mock.calls, so read them before restoring. + written = stdout.mock.calls.map((call) => String(call[0])).join(""); + } finally { + stdout.mockRestore(); + } + + expect(written).toContain("box-9"); + }); + it("does not pin when --no-use is given", async () => { vi.mocked(Box.fromSnapshot).mockResolvedValueOnce({ id: "box-9" } as any); diff --git a/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts index cc958cf2..3de6d490 100644 --- a/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts +++ b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts @@ -73,6 +73,23 @@ describe("runs, logs, cancel and snapshots", () => { expect(logs).toHaveBeenCalledWith({ limit: 50, offset: 10 }); }); + it("rejects --limit 0, which the SDK would drop as falsy", async () => { + // box.logs only sets limit when truthy, so 0 returned a full default + // page instead of nothing, with no sign the flag was ignored. + getBox.mockResolvedValue({ logs: vi.fn() }); + + await expect(statusLogsCommand({ ...flags, limit: "0" })).rejects.toThrow(/at least 1/); + }); + + it("still allows --offset 0", async () => { + const logs = vi.fn().mockResolvedValue([]); + getBox.mockResolvedValue({ logs }); + + await statusLogsCommand({ ...flags, offset: "0" }); + + expect(logs).toHaveBeenCalledWith({ offset: 0 }); + }); + it("renders the timestamp as a date rather than a number", async () => { getBox.mockResolvedValue({ logs: vi diff --git a/packages/cli/src/__tests__/commands/schedule-config.test.ts b/packages/cli/src/__tests__/commands/schedule-config.test.ts index e773e9e5..d29952b4 100644 --- a/packages/cli/src/__tests__/commands/schedule-config.test.ts +++ b/packages/cli/src/__tests__/commands/schedule-config.test.ts @@ -83,6 +83,21 @@ describe("schedule and config", () => { expect(agent).toHaveBeenCalledWith(expect.objectContaining({ timeout: 60_000 })); }); + it("forwards a webhook, which agent schedules support too", async () => { + const agent = vi.fn().mockResolvedValue({ id: "sch-2" }); + getBox.mockResolvedValue({ schedule: { agent } }); + + await scheduleAgentCommand(["x"], { + ...flags, + cron: "@daily", + webhookUrl: "https://hook.test", + }); + + expect(agent).toHaveBeenCalledWith( + expect.objectContaining({ webhookUrl: "https://hook.test" }), + ); + }); + it("rejects a timeout that is not a positive number", async () => { getBox.mockResolvedValue({ schedule: { agent: vi.fn() } }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b4cebdde..ec0ca8e9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -590,6 +590,7 @@ withScheduleCommon( .option("-C, --folder ", "Working directory inside the box") .option("--model ", "Model to run the prompt on") .option("--timeout ", "Give up after this long") + .option("--webhook-url ", "POST the result here after each run") .action(async (parts: string[], opts) => runCommand(async () => scheduleAgentCommand(parts, merged(opts))), ); @@ -613,6 +614,7 @@ withScheduleCommon( .option("--prompt ", "New prompt") .option("-C, --folder ", "New working directory") .option("--model ", "New model") + .option("--webhook-url ", "New webhook URL") .action(async (id: string, parts: string[], opts) => runCommand(async () => scheduleUpdateCommand(id, parts, merged(opts))), ); diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index be8a9e58..eb6e5f00 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -81,7 +81,22 @@ export async function browserContentCommand(flags: BrowserFlags): Promise const box = await open(flags); const tab = await resolveTab(box, flags); const content = await tab.content(); - emit(content, [content.title, content.url, "", content.text], flags); + const links = content.links ?? []; + emit( + content, + [ + content.title, + content.url, + "", + content.text, + // The command says it reads links, so the default output has to carry + // them; without this only --json exposed a destination URL. + ...(links.length === 0 + ? [] + : ["", ...links.map((link) => `${link.text ?? ""}\t${link.href ?? ""}`)]), + ], + flags, + ); } /** @@ -185,13 +200,18 @@ function schemaFromFile(path: string): z.ZodTypeAny { let parsed: { type?: string; properties?: Record; - }; + } | null; try { - parsed = JSON.parse(readFileSync(path, "utf8")); + parsed = JSON.parse(readFileSync(path, "utf8")) as typeof parsed; } catch (error) { throw new CliError(`Could not read the schema at ${path}: ${(error as Error).message}`); } + // JSON.parse("null") succeeds, and reading .type off the result is a raw + // TypeError rather than the message the caller needs. + if (parsed === null || typeof parsed !== "object") { + throw new CliError('The schema must be {"type":"object","properties":{...}}'); + } if (parsed.type !== "object" || !parsed.properties) { throw new CliError('The schema must be {"type":"object","properties":{...}}'); } diff --git a/packages/cli/src/commands/from-snapshot.ts b/packages/cli/src/commands/from-snapshot.ts index 02c625f9..7b3d7d4e 100644 --- a/packages/cli/src/commands/from-snapshot.ts +++ b/packages/cli/src/commands/from-snapshot.ts @@ -108,8 +108,17 @@ export async function fromSnapshotCommand( return; } - // Pin it, so the commands that follow need no --box. Same as headless create. - const pinned = flags.use === false ? undefined : writeBoxFile(box.id); + // Pin it, so the commands that follow need no --box. Same as headless create, + // including the catch: the box already exists and is billing, so losing its id + // to a read-only directory would leave it running and undiscoverable. + let pinned: string | undefined; + if (flags.use !== false) { + try { + pinned = writeBoxFile(box.id); + } catch (error) { + note(`Could not write a .box file: ${(error as Error).message}`); + } + } emit( { id: box.id, ...(pinned === undefined ? {} : { box_file: pinned }) }, [box.id, ...(pinned === undefined ? [] : [`Pinned to ${pinned}`])], diff --git a/packages/cli/src/commands/schedule.ts b/packages/cli/src/commands/schedule.ts index 8f6318cf..fac15fe1 100644 --- a/packages/cli/src/commands/schedule.ts +++ b/packages/cli/src/commands/schedule.ts @@ -74,6 +74,7 @@ export async function scheduleAgentCommand(prompt: string[], flags: ScheduleFlag ...(flags.folder === undefined ? {} : { folder: flags.folder }), ...(flags.model === undefined ? {} : { model: flags.model }), ...(timeout === undefined ? {} : { timeout }), + ...(flags.webhookUrl === undefined ? {} : { webhookUrl: flags.webhookUrl }), }); emit(schedule, [schedule.id], flags); @@ -124,10 +125,11 @@ export async function scheduleUpdateCommand( ...(flags.prompt === undefined ? {} : { prompt: flags.prompt }), ...(flags.folder === undefined ? {} : { folder: flags.folder }), ...(flags.model === undefined ? {} : { model: flags.model }), + ...(flags.webhookUrl === undefined ? {} : { webhookUrl: flags.webhookUrl }), }; if (Object.keys(changes).length === 0) { throw new CliError( - "Nothing to update. Pass --cron, --prompt, --folder, --model, or a command after --", + "Nothing to update. Pass --cron, --prompt, --folder, --model, --webhook-url, or a command after --", ); } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0da8a56c..7e8cb7fe 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -47,16 +47,20 @@ export async function statusCommand(flags: GlobalFlags): Promise { * * `Number("nope")` is NaN, which serialises into the query string as `NaN` and * is silently ignored upstream, so the caller gets a default page and no idea - * the flag was wrong. + * the flag was wrong. `--limit 0` is rejected for the same reason: the SDK only + * sends a limit when it is truthy, so zero would quietly return a full page. * @param raw - the flag as given. * @param name - the flag name, for the message. + * @param min - the smallest value the endpoint honours. * @returns the parsed count. * @throws CliError when it is not a non-negative whole number. */ -function countFlag(raw: string, name: string): number { +function countFlag(raw: string, name: string, min: number): number { const value = Number(raw); - if (!Number.isInteger(value) || value < 0) { - throw new CliError(`${name} must be a whole number`); + if (!Number.isInteger(value) || value < min) { + throw new CliError( + min === 1 ? `${name} must be a whole number of at least 1` : `${name} must be a whole number`, + ); } return value; } @@ -100,8 +104,8 @@ export async function statusLogsCommand( const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); const logs = await box.logs({ - ...(flags.limit === undefined ? {} : { limit: countFlag(flags.limit, "--limit") }), - ...(flags.offset === undefined ? {} : { offset: countFlag(flags.offset, "--offset") }), + ...(flags.limit === undefined ? {} : { limit: countFlag(flags.limit, "--limit", 1) }), + ...(flags.offset === undefined ? {} : { offset: countFlag(flags.offset, "--offset", 0) }), }); emit( diff --git a/packages/sdk/src/__tests__/box-instance.test.ts b/packages/sdk/src/__tests__/box-instance.test.ts index bd8a49c0..f6d6c7cc 100644 --- a/packages/sdk/src/__tests__/box-instance.test.ts +++ b/packages/sdk/src/__tests__/box-instance.test.ts @@ -422,6 +422,21 @@ describe("Box instance methods", () => { }); }); + describe("cancelRun", () => { + it("POSTs to the run's cancel endpoint", async () => { + // The CLI's own tests mock the SDK, so nothing else pins the method and + // the path; a typo here would only surface against a live box. + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({})); + + await box.cancelRun("run-9"); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/runs/run-9/cancel"); + expect((init as RequestInit).method).toBe("POST"); + }); + }); + describe("listRuns", () => { it("returns runs for the box", async () => { const { box, fetchMock } = await createTestBox(); From 97b5e7007998de65c280b2f6f8a3545168d7abdc Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 12:37:58 +0300 Subject: [PATCH 6/7] DX-2998: drop the SDK changes and cancel through the box's own request 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. --- .changeset/cli-browser-runs-snapshots.md | 5 +---- .../__tests__/commands/runs-and-snapshots.test.ts | 10 ++++++---- packages/cli/src/commands/status.ts | 6 +++++- packages/python-sdk/CHANGELOG.md | 7 ------- packages/python-sdk/PARITY.md | 2 +- packages/python-sdk/pyproject.toml | 2 +- packages/python-sdk/tests/_async/test_box_misc.py | 14 +------------- packages/python-sdk/upstash_box/_async/client.py | 9 --------- packages/python-sdk/upstash_box/_sync/client.py | 9 --------- packages/python-sdk/upstash_box/_version.py | 2 +- packages/sdk/src/__tests__/box-instance.test.ts | 15 --------------- packages/sdk/src/client.ts | 12 ------------ 12 files changed, 16 insertions(+), 77 deletions(-) diff --git a/.changeset/cli-browser-runs-snapshots.md b/.changeset/cli-browser-runs-snapshots.md index a5cc390e..c46a03e9 100644 --- a/.changeset/cli-browser-runs-snapshots.md +++ b/.changeset/cli-browser-runs-snapshots.md @@ -1,13 +1,12 @@ --- "@upstash/box-cli": patch -"@upstash/box": patch --- Give the CLI parity with the SDK, so an agent driving a terminal can reach everything a program can. - `box browser open|tabs|content|screenshot|act|close|cdp-url`. The browser is the one thing in a box with no shell fallback, because it is driven through the coordinator rather than from inside the container, so `box exec` could never stand in for it. `--tab` is optional while a single tab is open and required once there are several: acting on the wrong page is worse than asking which one. `screenshot` writes to `--out` rather than stdout, which carries text a caller may pipe. - `box status runs` and `box status logs`. A run id was previously unobtainable, so a failed run could be observed but not investigated. -- `box cancel `, 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 cancel `. `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`. - `box from-snapshot --no-repl`, matching `box create`: explicit flag, `--json`, or no terminal on either stream. Without it a script could take a snapshot and never restore one, which made listing and deleting them write-only. @@ -17,5 +16,3 @@ Give the CLI parity with the SDK, so an agent driving a terminal can reach every - The rest of the browser: `goto`, `observe`, `extract`, `live-url`, and `recordings start|stop|list|get|download`. `extract` takes a flat JSON Schema file, since a Zod schema cannot travel through a command line, and refuses anything nested rather than silently dropping fields. `exec.session` is deliberately absent: a 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 for the public-URL calls, so `box public-url` already covers them. - -`Box.cancelRun()` is added to the SDK, because `Run.cancel()` needs the object the original call returned, which another process never holds. The Python SDK gets the same method as `cancel_run`; it versions separately, so it is not in this changeset. diff --git a/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts index 3de6d490..4d857ad6 100644 --- a/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts +++ b/packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts @@ -107,13 +107,15 @@ describe("runs, logs, cancel and snapshots", () => { }); describe("cancel", () => { - it("cancels the run it was given", async () => { - const cancelRun = vi.fn().mockResolvedValue(undefined); - getBox.mockResolvedValue({ cancelRun }); + it("posts to the run's cancel endpoint", async () => { + // Pins the method and the path, since the SDK has no cancel-by-id to + // typecheck against: a wrong path here would only fail on a live box. + const request = vi.fn().mockResolvedValue({}); + getBox.mockResolvedValue({ id: "b1", _request: request }); await cancelCommand("run-9", { ...flags }); - expect(cancelRun).toHaveBeenCalledWith("run-9"); + expect(request).toHaveBeenCalledWith("POST", "/v2/box/b1/runs/run-9/cancel"); expect(written()).toContain("run-9"); }); }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 7e8cb7fe..2f67164b 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -134,7 +134,11 @@ export async function cancelCommand(runId: string, flags: GlobalFlags): Promise< announceBox(resolved); const box = await Box.get(resolved.id, { apiKey: requireToken(flags.token) }); - await box.cancelRun(runId); + // The SDK has no cancel-by-id: Run.cancel() lives on the object the original + // call returned, which this process never holds. Going through the box's own + // request method keeps the base URL, auth and timeouts in one place rather + // than rebuilding them here. + await box._request("POST", `/v2/box/${box.id}/runs/${runId}/cancel`); emit({ run_id: runId, cancelled: true }, [`Cancelled ${runId}`], flags); } diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index eb88460d..e07b62fa 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -2,13 +2,6 @@ All notable changes to `upstash-box` (Python) are documented here. -## 0.3.2 - -- Add `cancel_run(run_id)`, matching `cancelRun` in `@upstash/box`. `Run.cancel()` - only works while holding the object the original call returned, which a - separate process never is, so cancelling by id is the only route open to a - caller that did not start the run. - ## 0.3.1 - Fix `git.update_config()` sending its request to `/v2/box/{id}/git-config`, diff --git a/packages/python-sdk/PARITY.md b/packages/python-sdk/PARITY.md index bf3ad74c..614eaeef 100644 --- a/packages/python-sdk/PARITY.md +++ b/packages/python-sdk/PARITY.md @@ -38,7 +38,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun` | `getStatus`, `pause`, `resume`, `delete` | `get_status`, `pause`, `resume`, `delete` | | `snapshot`, `listSnapshots`, `deleteSnapshot` | `snapshot`, `list_snapshots`, `delete_snapshot` | | `getInitCommand`, `setInitCommand`, `deleteInitCommand` | `get_init_command`, `set_init_command`, `delete_init_command` | -| `logs`, `listRuns`, `cancelRun` | `logs`, `list_runs`, `cancel_run` | +| `logs`, `listRuns` | `logs`, `list_runs` | | `getPublicURL`, `listPublicURLs`, `deletePublicURL` | `get_public_url`, `list_public_urls`, `delete_public_url` | | `id`, `size`, `keepAlive` | `id`, `size`, `keep_alive` | | `browser.tab.create` | `browser.tab.create` | diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 6b2e74bf..b3be4d07 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "upstash-box" -version = "0.3.2" +version = "0.3.1" description = "Upstash Box SDK - Python client for async and parallel AI coding agents" readme = "README.md" license = { text = "MIT" } diff --git a/packages/python-sdk/tests/_async/test_box_misc.py b/packages/python-sdk/tests/_async/test_box_misc.py index 01f6c887..0e50b757 100644 --- a/packages/python-sdk/tests/_async/test_box_misc.py +++ b/packages/python-sdk/tests/_async/test_box_misc.py @@ -1,4 +1,4 @@ -"""skills, config_model, public URLs, lifecycle, init-command, logs, list_runs, cancel_run.""" +"""skills, config_model, public URLs, lifecycle, init-command, logs, list_runs.""" import httpx import pytest @@ -168,18 +168,6 @@ async def test_init_command_crud(): await box.aclose() -# ---------- cancel_run ---------- - - -@respx.mock -async def test_cancel_run(): - box = await make_async_box(respx.mock) - route = respx.post(f"{BASE}/runs/r1/cancel").mock(return_value=httpx.Response(200, json={})) - await box.cancel_run("r1") - assert route.called - await box.aclose() - - # ---------- logs / list_runs ---------- diff --git a/packages/python-sdk/upstash_box/_async/client.py b/packages/python-sdk/upstash_box/_async/client.py index 36eff543..8319060c 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -1569,15 +1569,6 @@ async def logs( data = await self._request("GET", f"/v2/box/{self.id}/logs{qs}") return [LogEntry.model_validate(entry) for entry in data.get("logs", [])] - async def cancel_run(self, run_id: str) -> None: - """Cancel a run by id. - - ``Run.cancel()`` only works while holding the object the call returned, - which a separate process never is, so cancelling by id is the only route - open to a caller that did not start the run. - """ - await self._request("POST", f"/v2/box/{self.id}/runs/{run_id}/cancel") - async def list_runs(self) -> List[BoxRunData]: data = await self._request("GET", f"/v2/box/{self.id}/runs") diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 6f20352a..9c49635e 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -1554,15 +1554,6 @@ def logs(self, *, offset: Optional[int] = None, limit: Optional[int] = None) -> data = self._request("GET", f"/v2/box/{self.id}/logs{qs}") return [LogEntry.model_validate(entry) for entry in data.get("logs", [])] - def cancel_run(self, run_id: str) -> None: - """Cancel a run by id. - - ``Run.cancel()`` only works while holding the object the call returned, - which a separate process never is, so cancelling by id is the only route - open to a caller that did not start the run. - """ - self._request("POST", f"/v2/box/{self.id}/runs/{run_id}/cancel") - def list_runs(self) -> List[BoxRunData]: data = self._request("GET", f"/v2/box/{self.id}/runs") diff --git a/packages/python-sdk/upstash_box/_version.py b/packages/python-sdk/upstash_box/_version.py index 6273e158..704784e3 100644 --- a/packages/python-sdk/upstash_box/_version.py +++ b/packages/python-sdk/upstash_box/_version.py @@ -3,4 +3,4 @@ Keep in sync with pyproject.toml — bumped by the release process. """ -__version__ = "0.3.2" +__version__ = "0.3.0" diff --git a/packages/sdk/src/__tests__/box-instance.test.ts b/packages/sdk/src/__tests__/box-instance.test.ts index f6d6c7cc..bd8a49c0 100644 --- a/packages/sdk/src/__tests__/box-instance.test.ts +++ b/packages/sdk/src/__tests__/box-instance.test.ts @@ -422,21 +422,6 @@ describe("Box instance methods", () => { }); }); - describe("cancelRun", () => { - it("POSTs to the run's cancel endpoint", async () => { - // The CLI's own tests mock the SDK, so nothing else pins the method and - // the path; a typo here would only surface against a live box. - const { box, fetchMock } = await createTestBox(); - fetchMock.mockResolvedValueOnce(mockResponse({})); - - await box.cancelRun("run-9"); - - const [url, init] = fetchMock.mock.calls[1]!; - expect(url).toContain("/v2/box/box-123/runs/run-9/cancel"); - expect((init as RequestInit).method).toBe("POST"); - }); - }); - describe("listRuns", () => { it("returns runs for the box", async () => { const { box, fetchMock } = await createTestBox(); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 80f86f76..0af4109a 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2625,18 +2625,6 @@ export class Box { return data.logs; } - /** - * Cancel a run by id. - * - * `Run.cancel()` only works while you are holding the object the call - * returned, which a separate process never is. Listing runs and cancelling - * one by id is the only route open to a caller that did not start it. - * @param runId - the run to cancel. - */ - async cancelRun(runId: string): Promise { - await this._request("POST", `/v2/box/${this.id}/runs/${runId}/cancel`); - } - /** * List all runs for this box, newest first. */ From d8b1e35dd8118106ec5d9e892e003938b20e0ede Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 4 Sep 2026 12:46:03 +0300 Subject: [PATCH 7/7] DX-2998: honour the schema's required array, and let schedule update 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. --- .../commands/code-and-extract.test.ts | 42 +++++++++++++++++++ .../commands/schedule-config.test.ts | 15 +++++++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/browser.ts | 6 +++ packages/cli/src/commands/schedule.ts | 4 +- packages/cli/src/core/io.ts | 9 +++- 6 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/commands/code-and-extract.test.ts b/packages/cli/src/__tests__/commands/code-and-extract.test.ts index 6a60e19f..46e98d66 100644 --- a/packages/cli/src/__tests__/commands/code-and-extract.test.ts +++ b/packages/cli/src/__tests__/commands/code-and-extract.test.ts @@ -143,6 +143,48 @@ describe("browser extract schema", () => { expect(extract).toHaveBeenCalledWith("read the page", expect.anything()); }); + it("leaves a property out of `required` optional, so a missing field parses", async () => { + // tab.extract() ends in schema.parse(), so marking everything required + // makes a page that simply lacks an optional field fail client-side. + let captured: { parse: (value: unknown) => unknown } | undefined; + const extract = vi.fn().mockImplementation((_instruction, schema) => { + captured = schema; + return Promise.resolve({ title: "Hi" }); + }); + getBox.mockResolvedValue({ + browser: { listTabs: vi.fn().mockResolvedValue([{ id: "t", extract }]) }, + }); + const schema = schemaAt({ + type: "object", + properties: { title: { type: "string" }, subtitle: { type: "string" } }, + required: ["title"], + }); + + await browserExtractCommand("read it", { ...flags, schema }); + + expect(() => captured!.parse({ title: "Hi" })).not.toThrow(); + }); + + it("keeps a property named in `required` required", async () => { + let captured: { parse: (value: unknown) => unknown } | undefined; + const extract = vi.fn().mockImplementation((_instruction, schema) => { + captured = schema; + return Promise.resolve({ title: "Hi" }); + }); + getBox.mockResolvedValue({ + browser: { listTabs: vi.fn().mockResolvedValue([{ id: "t", extract }]) }, + }); + const schema = schemaAt({ + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + }); + + await browserExtractCommand("read it", { ...flags, schema }); + + expect(() => captured!.parse({})).toThrow(); + }); + it("refuses a nested object rather than dropping the field", async () => { // A dropped field comes back as a missing key, which reads as "the page did // not have it" rather than as a schema the CLI could not express. diff --git a/packages/cli/src/__tests__/commands/schedule-config.test.ts b/packages/cli/src/__tests__/commands/schedule-config.test.ts index d29952b4..74f0bf57 100644 --- a/packages/cli/src/__tests__/commands/schedule-config.test.ts +++ b/packages/cli/src/__tests__/commands/schedule-config.test.ts @@ -137,6 +137,21 @@ describe("schedule and config", () => { expect(update).toHaveBeenCalledWith("sch-1", { cron: "@hourly" }); }); + it("updates the timeout in milliseconds, and lets 0 clear it", async () => { + const update = vi + .fn() + .mockResolvedValue({ id: "sch-1", type: "prompt", cron: "@daily", status: "active" }); + getBox.mockResolvedValue({ schedule: { update } }); + + await scheduleUpdateCommand("sch-1", [], { ...flags, timeout: "45" }); + expect(update).toHaveBeenCalledWith("sch-1", { timeout: 45_000 }); + + // 0 is how the SDK clears the field, so it has to survive the conversion + // that rejects every other non-positive value. + await scheduleUpdateCommand("sch-1", [], { ...flags, timeout: "0" }); + expect(update).toHaveBeenLastCalledWith("sch-1", { timeout: 0 }); + }); + it("refuses an update that changes nothing", async () => { getBox.mockResolvedValue({ schedule: { update: vi.fn() } }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ec0ca8e9..4b7f6973 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -614,6 +614,7 @@ withScheduleCommon( .option("--prompt ", "New prompt") .option("-C, --folder ", "New working directory") .option("--model ", "New model") + .option("--timeout ", "New timeout; 0 clears it") .option("--webhook-url ", "New webhook URL") .action(async (id: string, parts: string[], opts) => runCommand(async () => scheduleUpdateCommand(id, parts, merged(opts))), diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index eb6e5f00..c8392818 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -200,6 +200,7 @@ function schemaFromFile(path: string): z.ZodTypeAny { let parsed: { type?: string; properties?: Record; + required?: string[]; } | null; try { parsed = JSON.parse(readFileSync(path, "utf8")) as typeof parsed; @@ -216,6 +217,10 @@ function schemaFromFile(path: string): z.ZodTypeAny { throw new CliError('The schema must be {"type":"object","properties":{...}}'); } + // JSON Schema says a property is optional unless it is named in `required`. + // Marking everything required instead makes tab.extract() throw client-side + // the moment the page is missing a field the caller knew might be absent. + const required = new Set(parsed.required ?? []); const shape: Record = {}; for (const [key, prop] of Object.entries(parsed.properties)) { switch (prop.type) { @@ -238,6 +243,7 @@ function schemaFromFile(path: string): z.ZodTypeAny { default: throw new CliError(`Field ${key}: unsupported type ${prop.type ?? "(missing)"}`); } + if (!required.has(key)) shape[key] = shape[key]!.optional(); } return z.object(shape); } diff --git a/packages/cli/src/commands/schedule.ts b/packages/cli/src/commands/schedule.ts index fac15fe1..4e06adc6 100644 --- a/packages/cli/src/commands/schedule.ts +++ b/packages/cli/src/commands/schedule.ts @@ -119,8 +119,10 @@ export async function scheduleUpdateCommand( command: string[], flags: ScheduleFlags, ): Promise { + const timeout = timeoutMs(flags.timeout, { allowZero: true }); const changes = { ...(flags.cron === undefined ? {} : { cron: flags.cron }), + ...(timeout === undefined ? {} : { timeout }), ...(command.length === 0 ? {} : { command }), ...(flags.prompt === undefined ? {} : { prompt: flags.prompt }), ...(flags.folder === undefined ? {} : { folder: flags.folder }), @@ -129,7 +131,7 @@ export async function scheduleUpdateCommand( }; if (Object.keys(changes).length === 0) { throw new CliError( - "Nothing to update. Pass --cron, --prompt, --folder, --model, --webhook-url, or a command after --", + "Nothing to update. Pass --cron, --prompt, --folder, --model, --timeout, --webhook-url, or a command after --", ); } diff --git a/packages/cli/src/core/io.ts b/packages/cli/src/core/io.ts index 6721df45..9a2dd48a 100644 --- a/packages/cli/src/core/io.ts +++ b/packages/cli/src/core/io.ts @@ -95,13 +95,20 @@ const MAX_TIMER_MS = 2_147_483_647; * milliseconds, so a caller that passes the number straight through asks for a * 30ms limit when it said 30 seconds, and the work is killed before it starts. * @param value - the raw flag, when given. + * @param options - allowZero accepts 0, which clears a value rather than setting one. * @returns the timeout in milliseconds, or undefined when unset. * @throws CliError when the value is not a usable number of seconds. */ -export function timeoutMs(value: string | undefined): number | undefined { +export function timeoutMs( + value: string | undefined, + options: { allowZero?: boolean } = {}, +): number | undefined { if (value === undefined) return undefined; const seconds = Number(value); + // 0 clears a schedule's timeout, which only makes sense on an update: at + // creation it would ask for a run that is out of time before it starts. + if (options.allowZero && seconds === 0) return 0; if (!Number.isFinite(seconds) || seconds <= 0) { throw new CliError("--timeout must be a positive number of seconds"); }