From c02c1590a26880d777ecbbb1a90d2906dc38f6b7 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Mon, 21 Sep 2026 15:26:51 +0300 Subject: [PATCH 1/7] DX-3029: Wake-on-request public URLs and init commands on every box The SDKs and CLI catch up to the backend: `wakeOnRequest` on a public URL resumes a paused box on an incoming request, and init commands are no longer restricted to keep-alive boxes. `listPublicURLs()` returned the wrong type. The list endpoint returns id, created_at, basic_auth, bearer_token and wake_on_request, and never the token/username/password the old `PublicURL` type advertised, since those are only returned once at creation. It now returns `PublicURLListItem[]`. Removing the keep-alive checks left `_requireKeepAlive` (TS) and `_require_keep_alive` (Python) with no callers, so both are gone. The inline guard that stops a keep-alive box being paused is untouched. The Python sync client is generated, so it was regenerated rather than edited. --- .../wake-on-request-and-startup-scripts.md | 28 ++++++++ .../cli/src/__tests__/commands/create.test.ts | 20 +++--- .../src/__tests__/commands/public-url.test.ts | 25 +++++++ packages/cli/src/cli.ts | 3 +- packages/cli/src/commands/create.ts | 5 -- packages/cli/src/commands/public-url.ts | 12 +++- packages/cli/src/repl/commands/public-url.ts | 4 +- packages/python-sdk/CHANGELOG.md | 7 ++ packages/python-sdk/README.md | 4 ++ .../tests/_async/test_box_create.py | 11 ++- .../python-sdk/tests/_async/test_box_misc.py | 69 ++++++++++++++++--- .../python-sdk/upstash_box/_async/client.py | 19 +++-- .../python-sdk/upstash_box/_sync/client.py | 19 +++-- packages/python-sdk/upstash_box/types.py | 1 + packages/sdk/src/__tests__/box-create.test.ts | 15 ++-- .../sdk/src/__tests__/box-instance.test.ts | 17 ++--- .../sdk/src/__tests__/box-preview.test.ts | 30 ++++++++ packages/sdk/src/client.ts | 36 +++++----- packages/sdk/src/index.ts | 1 + packages/sdk/src/types.ts | 22 +++++- 20 files changed, 259 insertions(+), 89 deletions(-) create mode 100644 .changeset/wake-on-request-and-startup-scripts.md diff --git a/.changeset/wake-on-request-and-startup-scripts.md b/.changeset/wake-on-request-and-startup-scripts.md new file mode 100644 index 00000000..14cf1d77 --- /dev/null +++ b/.changeset/wake-on-request-and-startup-scripts.md @@ -0,0 +1,28 @@ +--- +"@upstash/box": minor +"@upstash/box-cli": minor +--- + +Add `wakeOnRequest` to public URLs, and allow init commands on every box. + +`box.getPublicURL(port, { wakeOnRequest: true })` marks a public URL so that an +incoming HTTP request resumes a paused box. The request is held until the app's +port is listening, bounded at 30 seconds, so the caller gets the app's own +response instead of an error. It is off by default: anyone who can reach a +wake-enabled URL can start the box and incur compute charges, so pair it with +`bearerToken` or `basicAuth`. The CLI exposes it as `box public-url +--wake-on-request`, and warns when the URL has no authentication. + +Init commands are no longer restricted to keep-alive boxes. `Box.create` accepts +`initCommand` without `keepAlive`, and `getInitCommand`, `setInitCommand` and +`deleteInitCommand` work on any box, including a paused one, where the change is +stored and applied on the next resume. The CLI no longer rejects +`--init-command` without `--keep-alive`. The two features are meant to be used +together: the init command is what restarts your app when a request wakes the +box. + +`listPublicURLs()` now returns `PublicURLListItem[]` rather than `PublicURL[]`. +The previous type was wrong: the list endpoint returns `id`, `created_at`, +`basic_auth`, `bearer_token` and `wake_on_request`, and never returns the +`token`, `username` or `password` fields the old type advertised, since those +are only returned once at creation. diff --git a/packages/cli/src/__tests__/commands/create.test.ts b/packages/cli/src/__tests__/commands/create.test.ts index 0ad95934..1db5128a 100644 --- a/packages/cli/src/__tests__/commands/create.test.ts +++ b/packages/cli/src/__tests__/commands/create.test.ts @@ -170,15 +170,17 @@ describe("createCommand", () => { if (key !== undefined) process.env.UPSTASH_BOX_API_KEY = key; }); - it("rejects --init-command without --keep-alive, which the backend would 400", async () => { - await expect( - createCommand({ - token: "key", - initCommand: "npm start", - repl: false, - }), - ).rejects.toThrow(/keep-alive/); - expect(Box.create).not.toHaveBeenCalled(); + it("accepts --init-command without --keep-alive", async () => { + const mockBox = { id: "box-1" }; + vi.mocked(Box.create).mockResolvedValueOnce(mockBox as any); + + await createCommand({ + token: "key", + initCommand: "npm start", + repl: false, + }); + + expect(Box.create).toHaveBeenCalledWith(expect.objectContaining({ initCommand: "npm start" })); }); it("passes runtime, git token, and env vars", async () => { diff --git a/packages/cli/src/__tests__/commands/public-url.test.ts b/packages/cli/src/__tests__/commands/public-url.test.ts index 003f6fa8..c894f9fe 100644 --- a/packages/cli/src/__tests__/commands/public-url.test.ts +++ b/packages/cli/src/__tests__/commands/public-url.test.ts @@ -52,6 +52,31 @@ describe("box public-url", () => { expect(out()).toContain("user: u password: p"); }); + it("passes wakeOnRequest through and warns when the URL is unprotected", async () => { + const getPublicURL = vi.fn().mockResolvedValue({ + url: "https://b1-3000.example", + port: 3000, + wake_on_request: true, + }); + getBox.mockResolvedValue({ getPublicURL }); + await publicUrlCommand("3000", { ...flags, wakeOnRequest: true }); + expect(getPublicURL).toHaveBeenCalledWith(3000, { wakeOnRequest: true }); + expect(err()).toContain("anyone with the URL can start this box"); + }); + + it("does not warn when a wake-enabled URL is protected", async () => { + const getPublicURL = vi.fn().mockResolvedValue({ + url: "https://b1-3000.example", + port: 3000, + token: "t", + wake_on_request: true, + }); + getBox.mockResolvedValue({ getPublicURL }); + await publicUrlCommand("3000", { ...flags, wakeOnRequest: true, bearerToken: true }); + expect(getPublicURL).toHaveBeenCalledWith(3000, { bearerToken: true, wakeOnRequest: true }); + expect(err()).not.toContain("anyone with the URL can start this box"); + }); + it("rejects a port outside the valid range rather than calling the API", async () => { const getPublicURL = vi.fn(); getBox.mockResolvedValue({ getPublicURL }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d268e226..ffbe29a6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -408,6 +408,7 @@ const publicUrl = program .argument("[port]", "Port to publish; omit to list") .option("--basic-auth", "Protect the URL with generated basic-auth credentials") .option("--bearer-token", "Protect the URL with a generated bearer token") + .option("--wake-on-request", "Resume the box when a request hits this URL") .option("--box ", "Box to act on") .option("--json", "Emit machine-readable output") .option("--token ", "Upstash Box API token") @@ -526,7 +527,7 @@ program .option("--name ", "Human-readable name for the box") .option("--size ", "Resource size (small, medium, large)") .option("--keep-alive", "Keep the box running instead of pausing when idle") - .option("--init-command ", "Startup script, for keep-alive boxes") + .option("--init-command ", "Startup script, run once each time the box starts") .option("--browser", "Provision a headless Chromium in the box") .option("--clone-repo ", "Clone this repository into the box after creating it") .option( diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 301191b5..c2fcbd0a 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -262,11 +262,6 @@ export async function createCommand(flags: CreateFlags): Promise { ); } - // The backend rejects a startup script on a box that is allowed to pause. - if (flags.initCommand !== undefined && !flags.keepAlive) { - throw new CliError("--init-command only applies to a keep-alive box; add --keep-alive"); - } - // In headless mode stdout carries the box id and nothing else, so progress // goes to stderr. if (headless) note("Creating box..."); diff --git a/packages/cli/src/commands/public-url.ts b/packages/cli/src/commands/public-url.ts index 4c1be8cd..4e508694 100644 --- a/packages/cli/src/commands/public-url.ts +++ b/packages/cli/src/commands/public-url.ts @@ -6,6 +6,7 @@ import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; export type PublicUrlFlags = GlobalFlags & { basicAuth?: boolean; bearerToken?: boolean; + wakeOnRequest?: boolean; }; /** Resolve the box once, shared by every verb. */ @@ -40,14 +41,18 @@ export async function publicUrlCommand(portArg: string, flags: PublicUrlFlags): const created = await box.getPublicURL(port, { ...(flags.basicAuth ? { basicAuth: true } : {}), ...(flags.bearerToken ? { bearerToken: true } : {}), + ...(flags.wakeOnRequest ? { wakeOnRequest: true } : {}), }); const lines = [created.url]; if (created.username) lines.push(`user: ${created.username} password: ${created.password}`); if (created.token) lines.push(`bearer token: ${created.token}`); emit(created, lines, flags); + if (flags.wakeOnRequest && !flags.basicAuth && !flags.bearerToken) { + note("Unprotected and wake-enabled: anyone with the URL can start this box."); + } // A server started as a plain background job is reaped when the command that // launched it finishes, and the URL then 502s. - note("Start the server detached — ( npm run dev & ) — or it stops with the command."); + note("Start the server detached, ( npm run dev & ), or it stops with the command."); } /** List the box's public URLs. */ @@ -57,7 +62,10 @@ export async function publicUrlListCommand(flags: GlobalFlags): Promise { if (publicURLs.length === 0 && !flags.json) note("No public URLs."); emit( publicURLs, - publicURLs.map((entry) => `${String(entry.port).padEnd(6)}${entry.url}`), + publicURLs.map( + (entry) => + `${String(entry.port).padEnd(6)}${entry.url}${entry.wake_on_request ? " (wakes)" : ""}`, + ), flags, ); } diff --git a/packages/cli/src/repl/commands/public-url.ts b/packages/cli/src/repl/commands/public-url.ts index 141ebe7a..c6cb67f1 100644 --- a/packages/cli/src/repl/commands/public-url.ts +++ b/packages/cli/src/repl/commands/public-url.ts @@ -46,15 +46,17 @@ export async function* handlePublicUrl(box: Box, args: string): AsyncGenerator [--basic-auth|--bearer-token] | public-url list | public-url delete ", + "Usage: public-url [--basic-auth|--bearer-token|--wake-on-request] | public-url list | public-url delete ", }; return; } const basicAuth = parts.includes("--basic-auth"); const bearerToken = parts.includes("--bearer-token"); + const wakeOnRequest = parts.includes("--wake-on-request"); const created = await box.getPublicURL(port, { ...(basicAuth ? { basicAuth: true } : {}), ...(bearerToken ? { bearerToken: true } : {}), + ...(wakeOnRequest ? { wakeOnRequest: true } : {}), }); yield { type: "log", message: created.url }; if (created.username) { diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index bef6cd02..d9c125c4 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to `upstash-box` (Python) are documented here. ## Unreleased +- Add `wake_on_request` to `get_public_url()` and to the `PublicURL` model. When + true, a request to the public URL resumes a paused box and is held until the + app's port is listening. When false (the default) such a request gets a + "box is sleeping" response. +- Init commands now work on every box, not only keep-alive ones. `create(init_command=...)` + no longer requires `keep_alive=True`, and `get_init_command()`, `set_init_command()` + and `delete_init_command()` no longer raise on a non-keep-alive box. - Fix `delete_boxes(box_ids=[])` deleting every box on the account. The API read an empty id list as "no filter". `delete_boxes` and `delete_snapshots` now raise `BoxError` before any request is made when the list is empty or contains a diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index e3b0ff35..be45ccf4 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -266,6 +266,10 @@ await box.delete_snapshot(snapshot.id) url = await box.get_public_url(3000) urls = await box.list_public_urls() await box.delete_public_url(3000) + +# wake_on_request: a request to the URL resumes a paused box and waits for the +# port, so anyone who can reach the URL can start the box. +url = await box.get_public_url(3000, wake_on_request=True) ``` ### Browser diff --git a/packages/python-sdk/tests/_async/test_box_create.py b/packages/python-sdk/tests/_async/test_box_create.py index 9b9f6c94..81a1959d 100644 --- a/packages/python-sdk/tests/_async/test_box_create.py +++ b/packages/python-sdk/tests/_async/test_box_create.py @@ -104,9 +104,14 @@ async def test_create_requires_api_key(monkeypatch): await AsyncBox.create(base_url=TEST_BASE_URL) -async def test_init_command_requires_keep_alive(): - with pytest.raises(BoxError, match="init_command requires keep_alive"): - await AsyncBox.create(init_command="x", **_opts()) +@respx.mock +async def test_create_init_command_without_keep_alive(): + route = respx.post(CREATE_URL).mock(return_value=httpx.Response(200, json=TEST_BOX_DATA)) + box = await AsyncBox.create(init_command="npm run dev", **_opts()) + body = last_json_body(route) + assert body["init_command"] == "npm run dev" + assert "keep_alive" not in body + await box.aclose() @respx.mock diff --git a/packages/python-sdk/tests/_async/test_box_misc.py b/packages/python-sdk/tests/_async/test_box_misc.py index 0e50b757..bf9d2ba2 100644 --- a/packages/python-sdk/tests/_async/test_box_misc.py +++ b/packages/python-sdk/tests/_async/test_box_misc.py @@ -104,22 +104,57 @@ async def test_configure_model(): async def test_public_urls(): box = await make_async_box(respx.mock) respx.post(f"{BASE}/preview").mock( - return_value=httpx.Response(200, json={"url": "https://x", "port": 3000, "token": "t"}) + return_value=httpx.Response( + 200, + json={"url": "https://x", "port": 3000, "token": "t", "wake_on_request": False}, + ) ) respx.get(f"{BASE}/preview").mock( - return_value=httpx.Response(200, json={"previews": [{"url": "https://x", "port": 3000}]}) + return_value=httpx.Response( + 200, + json={"previews": [{"url": "https://x", "port": 3000, "wake_on_request": False}]}, + ) ) respx.delete(f"{BASE}/preview/3000").mock(return_value=httpx.Response(200, json={})) url = await box.get_public_url(3000, bearer_token=True) assert url.url == "https://x" assert url.token == "t" + assert url.wake_on_request is False listed = await box.list_public_urls() assert listed["public_urls"][0].port == 3000 + assert listed["public_urls"][0].wake_on_request is False await box.delete_public_url(3000) await box.aclose() +@respx.mock +async def test_public_url_wake_on_request(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/preview").mock( + return_value=httpx.Response( + 200, json={"url": "https://x", "port": 3000, "wake_on_request": True} + ) + ) + url = await box.get_public_url(3000, wake_on_request=True) + assert last_json_body(route) == {"port": 3000, "wake_on_request": True} + assert url.wake_on_request is True + await box.aclose() + + +@respx.mock +async def test_public_url_omits_wake_on_request_by_default(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/preview").mock( + return_value=httpx.Response( + 200, json={"url": "https://x", "port": 3000, "wake_on_request": False} + ) + ) + await box.get_public_url(3000) + assert last_json_body(route) == {"port": 3000} + await box.aclose() + + # ---------- lifecycle ---------- @@ -147,27 +182,43 @@ async def test_keep_alive_box_cannot_pause(): @respx.mock -async def test_init_command_requires_keep_alive(): - box = await make_async_box(respx.mock) - with pytest.raises(BoxError, match="only available for keep-alive"): - await box.get_init_command() +async def test_init_command_crud(): + box = await make_async_box(respx.mock, {"keep_alive": True}) + respx.get(f"{BASE}/startup").mock( + return_value=httpx.Response(200, json={"init_command": "npm run dev"}) + ) + respx.put(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={})) + respx.delete(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={})) + assert await box.get_init_command() == "npm run dev" + await box.set_init_command("npm start") + await box.delete_init_command() await box.aclose() @respx.mock -async def test_init_command_crud(): - box = await make_async_box(respx.mock, {"keep_alive": True}) +async def test_init_command_crud_without_keep_alive(): + box = await make_async_box(respx.mock) respx.get(f"{BASE}/startup").mock( return_value=httpx.Response(200, json={"init_command": "npm run dev"}) ) - respx.put(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={})) + put = respx.put(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={})) respx.delete(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={})) + assert box.keep_alive is False assert await box.get_init_command() == "npm run dev" await box.set_init_command("npm start") + assert last_json_body(put) == {"init_command": "npm start"} await box.delete_init_command() await box.aclose() +@respx.mock +async def test_set_init_command_requires_a_command(): + box = await make_async_box(respx.mock) + with pytest.raises(BoxError, match="init_command is required"): + await box.set_init_command("") + 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 18388d0a..2cafe024 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -882,10 +882,6 @@ def _require_agent(self) -> None: "No agent configured. Pass an `agent` option to create() to use box.agent.run()." ) - def _require_keep_alive(self, feature: str) -> None: - if not self.keep_alive: - raise BoxError(f"{feature} is only available for keep-alive boxes") - def _log(self, *args: Any) -> None: if self._debug: _logger.debug("[Box] %s", " ".join(str(a) for a in args)) @@ -1521,12 +1517,10 @@ async def update_network_policy(self, policy: NetworkPolicy) -> None: self._network_policy = policy async def get_init_command(self) -> str: - self._require_keep_alive("Init command") data = await self._request("GET", f"/v2/box/{self.id}/startup") return data.get("init_command", "") async def set_init_command(self, init_command: str) -> None: - self._require_keep_alive("Init command") if not init_command: raise BoxError("init_command is required") await self._request( @@ -1534,7 +1528,6 @@ async def set_init_command(self, init_command: str) -> None: ) async def delete_init_command(self) -> None: - self._require_keep_alive("Init command") await self._request("DELETE", f"/v2/box/{self.id}/startup") async def pause(self) -> None: @@ -1792,13 +1785,21 @@ async def _label_list(self) -> List[str]: # ==================== Public URLs ==================== async def get_public_url( - self, port: int, *, bearer_token: Optional[bool] = None, basic_auth: Optional[bool] = None + self, + port: int, + *, + bearer_token: Optional[bool] = None, + basic_auth: Optional[bool] = None, + wake_on_request: Optional[bool] = None, ) -> PublicURL: body: Dict[str, Any] = {"port": port} if bearer_token is not None: body["bearer_token"] = bearer_token if basic_auth is not None: body["basic_auth"] = basic_auth + # wake_on_request: a request to a paused box resumes it and waits for the port. + if wake_on_request is not None: + body["wake_on_request"] = wake_on_request data = await self._request("POST", f"/v2/box/{self.id}/preview", body=body) return PublicURL.model_validate(data) @@ -1963,8 +1964,6 @@ async def create(cls, **config: Unpack[BoxConfig]) -> "AsyncBox": agent = config.get("agent") if agent: common.resolve_agent_model(agent) - if config.get("init_command") is not None and not config.get("keep_alive"): - raise BoxError("init_command requires keep_alive=True") base_url = common.resolve_base_url(config.get("base_url")) headers = common.build_headers(api_key) timeout = config.get("timeout", _DEFAULT_TIMEOUT_MS) diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 998fb6a4..25287b35 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -873,10 +873,6 @@ def _require_agent(self) -> None: "No agent configured. Pass an `agent` option to create() to use box.agent.run()." ) - def _require_keep_alive(self, feature: str) -> None: - if not self.keep_alive: - raise BoxError(f"{feature} is only available for keep-alive boxes") - def _log(self, *args: Any) -> None: if self._debug: _logger.debug("[Box] %s", " ".join(str(a) for a in args)) @@ -1510,18 +1506,15 @@ def update_network_policy(self, policy: NetworkPolicy) -> None: self._network_policy = policy def get_init_command(self) -> str: - self._require_keep_alive("Init command") data = self._request("GET", f"/v2/box/{self.id}/startup") return data.get("init_command", "") def set_init_command(self, init_command: str) -> None: - self._require_keep_alive("Init command") if not init_command: raise BoxError("init_command is required") self._request("PUT", f"/v2/box/{self.id}/startup", body={"init_command": init_command}) def delete_init_command(self) -> None: - self._require_keep_alive("Init command") self._request("DELETE", f"/v2/box/{self.id}/startup") def pause(self) -> None: @@ -1775,13 +1768,21 @@ def _label_list(self) -> List[str]: # ==================== Public URLs ==================== def get_public_url( - self, port: int, *, bearer_token: Optional[bool] = None, basic_auth: Optional[bool] = None + self, + port: int, + *, + bearer_token: Optional[bool] = None, + basic_auth: Optional[bool] = None, + wake_on_request: Optional[bool] = None, ) -> PublicURL: body: Dict[str, Any] = {"port": port} if bearer_token is not None: body["bearer_token"] = bearer_token if basic_auth is not None: body["basic_auth"] = basic_auth + # wake_on_request: a request to a paused box resumes it and waits for the port. + if wake_on_request is not None: + body["wake_on_request"] = wake_on_request data = self._request("POST", f"/v2/box/{self.id}/preview", body=body) return PublicURL.model_validate(data) @@ -1940,8 +1941,6 @@ def create(cls, **config: Unpack[BoxConfig]) -> "Box": agent = config.get("agent") if agent: common.resolve_agent_model(agent) - if config.get("init_command") is not None and not config.get("keep_alive"): - raise BoxError("init_command requires keep_alive=True") base_url = common.resolve_base_url(config.get("base_url")) headers = common.build_headers(api_key) timeout = config.get("timeout", _DEFAULT_TIMEOUT_MS) diff --git a/packages/python-sdk/upstash_box/types.py b/packages/python-sdk/upstash_box/types.py index 277afe27..791ade5f 100644 --- a/packages/python-sdk/upstash_box/types.py +++ b/packages/python-sdk/upstash_box/types.py @@ -594,6 +594,7 @@ class PublicURL(_Model): token: Optional[str] = None username: Optional[str] = None password: Optional[str] = None + wake_on_request: Optional[bool] = None class BoxData(_Model): diff --git a/packages/sdk/src/__tests__/box-create.test.ts b/packages/sdk/src/__tests__/box-create.test.ts index 2cfc5efa..2f8934c2 100644 --- a/packages/sdk/src/__tests__/box-create.test.ts +++ b/packages/sdk/src/__tests__/box-create.test.ts @@ -314,13 +314,14 @@ describe("Box.create", () => { expect(box.keepAlive).toBe(true); }); - it("throws when initCommand is provided without keepAlive", async () => { - await expect( - Box.create({ - ...TEST_CONFIG, - initCommand: "echo hi", - }), - ).rejects.toThrow("initCommand requires keepAlive: true"); + it("sends initCommand without keepAlive", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ ...TEST_BOX_DATA, status: "running" })); + + await Box.create({ ...TEST_CONFIG, initCommand: "echo hi" }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.init_command).toBe("echo hi"); + expect(body.keep_alive).toBeUndefined(); }); it("sends skills and mcpServers in body", async () => { diff --git a/packages/sdk/src/__tests__/box-instance.test.ts b/packages/sdk/src/__tests__/box-instance.test.ts index bd8a49c0..345c3994 100644 --- a/packages/sdk/src/__tests__/box-instance.test.ts +++ b/packages/sdk/src/__tests__/box-instance.test.ts @@ -380,20 +380,15 @@ describe("Box instance methods", () => { await expect(box.setInitCommand("")).rejects.toThrow("initCommand is required"); }); - it("throws for non-keep-alive boxes", async () => { + it("works on boxes that are not keep-alive", async () => { const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ init_command: "npm run dev" })); - await expect(box.getInitCommand()).rejects.toThrow( - "Init command is only available for keep-alive boxes", - ); - await expect(box.setInitCommand("echo hi")).rejects.toThrow( - "Init command is only available for keep-alive boxes", - ); - await expect(box.deleteInitCommand()).rejects.toThrow( - "Init command is only available for keep-alive boxes", - ); + await expect(box.getInitCommand()).resolves.toBe("npm run dev"); - expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/startup"); + expect(init?.method).toBe("GET"); }); }); diff --git a/packages/sdk/src/__tests__/box-preview.test.ts b/packages/sdk/src/__tests__/box-preview.test.ts index c01f7ba7..db89ca3b 100644 --- a/packages/sdk/src/__tests__/box-preview.test.ts +++ b/packages/sdk/src/__tests__/box-preview.test.ts @@ -62,6 +62,36 @@ describe("Box public URL operations", () => { const body = JSON.parse(init?.body as string); expect(body.basic_auth).toBe(true); }); + + it("creates a public URL that wakes a paused box", async () => { + const { box, fetchMock } = await createTestBox(); + const mockPublicUrl = { + url: "https://box-123-3000.preview.box.upstash.com", + port: 3000, + wake_on_request: true, + }; + fetchMock.mockResolvedValueOnce(mockResponse(mockPublicUrl)); + + const publicUrl = await box.getPublicURL(3000, { wakeOnRequest: true }); + expect(publicUrl.wake_on_request).toBe(true); + + const [, init] = fetchMock.mock.calls[1]!; + const body = JSON.parse(init?.body as string); + expect(body.wake_on_request).toBe(true); + }); + + it("omits wake_on_request when not requested", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ url: "https://box-123-3000.preview.box.upstash.com", port: 3000 }), + ); + + await box.getPublicURL(3000); + + const [, init] = fetchMock.mock.calls[1]!; + const body = JSON.parse(init?.body as string); + expect(body).not.toHaveProperty("wake_on_request"); + }); }); describe("listPublicURLs", () => { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index a9bd9ab5..026e10cc 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -40,6 +40,7 @@ import { type UploadFileEntry, type Snapshot, type Preview, + type PublicURLListItem, type PublicURL, type EphemeralBoxConfig, type EphemeralBoxData, @@ -973,9 +974,6 @@ export class Box { ); } if (config?.agent) resolveAgentModel(config.agent); - if (config?.initCommand !== undefined && !config.keepAlive) { - throw new BoxError("initCommand requires keepAlive: true"); - } const baseUrl = ( config?.baseUrl ?? process.env.UPSTASH_BOX_BASE_URL ?? @@ -2514,10 +2512,9 @@ export class Box { } /** - * Read the current init command for a keep-alive box. + * Read the current init command. */ async getInitCommand(): Promise { - this._requireKeepAlive("Init command"); const data = await this._request<{ init_command?: string }>( "GET", `/v2/box/${this.id}/startup`, @@ -2526,10 +2523,10 @@ export class Box { } /** - * Set or replace the init command for a keep-alive box. + * Set or replace the init command. On a paused box the change is stored and + * applied on the next resume. */ async setInitCommand(initCommand: string): Promise { - this._requireKeepAlive("Init command"); if (!initCommand) { throw new BoxError("initCommand is required"); } @@ -2539,10 +2536,9 @@ export class Box { } /** - * Delete the init command for a keep-alive box. + * Delete the init command. */ async deleteInitCommand(): Promise { - this._requireKeepAlive("Init command"); await this._request("DELETE", `/v2/box/${this.id}/startup`); } @@ -2728,12 +2724,6 @@ export class Box { if (this._debug) console.log("[Box]", ...args); } - private _requireKeepAlive(feature: string): void { - if (!this.keepAlive) { - throw new BoxError(`${feature} is only available for keep-alive boxes`); - } - } - private async _browserCreateTab(url: string, options?: BrowserTabCreateOptions): Promise { const operationTimeout = options?.timeout === 0 ? 2_147_000_000 : options?.timeout; const resp = await this._request<{ id: string; url?: string; title?: string }>( @@ -3207,21 +3197,27 @@ export class Box { // ==================== Public URLs ==================== + /** + * Expose a port on a public URL. With `wakeOnRequest`, a request to the URL + * resumes a paused box and is held until the port is listening, which means + * anyone who can reach the URL can start the box and incur compute charges. + */ async getPublicURL( port: number, - options?: { bearerToken?: boolean; basicAuth?: boolean }, + options?: { bearerToken?: boolean; basicAuth?: boolean; wakeOnRequest?: boolean }, ): Promise { return this._request("POST", `/v2/box/${this.id}/preview`, { body: { port, ...(options?.bearerToken !== undefined && { bearer_token: options.bearerToken }), ...(options?.basicAuth !== undefined && { basic_auth: options.basicAuth }), + ...(options?.wakeOnRequest !== undefined && { wake_on_request: options.wakeOnRequest }), }, }); } - async listPublicURLs(): Promise<{ publicURLs: PublicURL[] }> { - const data = await this._request<{ previews: PublicURL[] }>( + async listPublicURLs(): Promise<{ publicURLs: PublicURLListItem[] }> { + const data = await this._request<{ previews: PublicURLListItem[] }>( "GET", `/v2/box/${this.id}/preview`, ); @@ -3235,13 +3231,13 @@ export class Box { /** @deprecated Use `getPublicURL` instead. */ async getPreviewUrl( port: number, - options?: { bearerToken?: boolean; basicAuth?: boolean }, + options?: { bearerToken?: boolean; basicAuth?: boolean; wakeOnRequest?: boolean }, ): Promise { return this.getPublicURL(port, options); } /** @deprecated Use `listPublicURLs` instead. */ - async listPreviews(): Promise<{ previews: Preview[] }> { + async listPreviews(): Promise<{ previews: PublicURLListItem[] }> { const data = await this.listPublicURLs(); return { previews: data.publicURLs }; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b1089cbe..10e1fa68 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -74,6 +74,7 @@ export type { ErrorResponse, BoxRunData, PublicURL, + PublicURLListItem, Preview, NetworkPolicy, EphemeralBoxConfig, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 7ee14c11..f76efc9a 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -475,7 +475,7 @@ export interface BoxConfig extends BoxConnectionOptions { browser?: boolean; /** Keep the box alive instead of allowing pause-based idle lifecycle. */ keepAlive?: boolean; - /** Optional startup script for keep-alive boxes. */ + /** Startup script run once per container start, after create, resume and snapshot restore. */ initCommand?: string; agent?: AgentConfig; git?: { @@ -1238,6 +1238,26 @@ export interface PublicURL { username?: string; /** Basic auth password (only returned when basicAuth is true) */ password?: string; + /** Whether a request to this URL resumes the box when it is paused */ + wake_on_request?: boolean; +} + +/** One entry returned by `listPublicURLs()`. Secrets are only returned at creation time. */ +export interface PublicURLListItem { + /** Preview id, `{boxId}-{port}` */ + id: string; + /** Port number exposed */ + port: number; + /** Public URL to access the exposed port */ + url: string; + /** Unix seconds */ + created_at: number; + /** Whether the URL is protected by basic auth */ + basic_auth: boolean; + /** Whether the URL is protected by a bearer token */ + bearer_token: boolean; + /** Whether a request to this URL resumes the box when it is paused */ + wake_on_request: boolean; } /** @deprecated Use `PublicURL` instead. */ From ebff543829146d4c938fc9295f38088e33ea0e7c Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Mon, 21 Sep 2026 16:01:43 +0300 Subject: [PATCH 2/7] DX-3029: Address review on the wake-on-request client changes The REPL accepted --wake-on-request but never printed the unprotected warning the non-REPL command prints, so a REPL user could enable a billable URL without seeing it. Same warning, same condition, now covered by tests. The list tests never exercised the (wakes) marker, so the fixture now carries wake_on_request true, false and absent. Changeset: the CLI change is additive, so it is a patch rather than a minor, and the note no longer claims the caller always gets the app's response. It says what happens when the 30 second wait runs out. PARITY.md records the one real drift this created, which is that JS types the list response separately and Python still reuses PublicURL for both. --- .../wake-on-request-and-startup-scripts.md | 8 +++-- .../src/__tests__/commands/public-url.test.ts | 18 ++++++++++ .../repl/commands/public-url.test.ts | 34 +++++++++++++++++++ packages/cli/src/repl/commands/public-url.ts | 8 ++++- packages/python-sdk/PARITY.md | 2 ++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/.changeset/wake-on-request-and-startup-scripts.md b/.changeset/wake-on-request-and-startup-scripts.md index 14cf1d77..ae59327b 100644 --- a/.changeset/wake-on-request-and-startup-scripts.md +++ b/.changeset/wake-on-request-and-startup-scripts.md @@ -1,14 +1,16 @@ --- "@upstash/box": minor -"@upstash/box-cli": minor +"@upstash/box-cli": patch --- Add `wakeOnRequest` to public URLs, and allow init commands on every box. `box.getPublicURL(port, { wakeOnRequest: true })` marks a public URL so that an incoming HTTP request resumes a paused box. The request is held until the app's -port is listening, bounded at 30 seconds, so the caller gets the app's own -response instead of an error. It is off by default: anyone who can reach a +port is listening, so the caller gets the app's own response rather than an +error. That wait is bounded at 30 seconds: if the port is still not listening, +the caller gets a `503` with `Retry-After: 5` while the box keeps resuming in +the background, so a retry usually succeeds. It is off by default: anyone who can reach a wake-enabled URL can start the box and incur compute charges, so pair it with `bearerToken` or `basicAuth`. The CLI exposes it as `box public-url --wake-on-request`, and warns when the URL has no authentication. diff --git a/packages/cli/src/__tests__/commands/public-url.test.ts b/packages/cli/src/__tests__/commands/public-url.test.ts index c894f9fe..eabe9d9a 100644 --- a/packages/cli/src/__tests__/commands/public-url.test.ts +++ b/packages/cli/src/__tests__/commands/public-url.test.ts @@ -95,6 +95,24 @@ describe("box public-url", () => { expect(out()).toContain("3000 https://b1-3000.example"); }); + it("marks only the wake-enabled URLs in the list", async () => { + getBox.mockResolvedValue({ + listPublicURLs: vi.fn().mockResolvedValue({ + publicURLs: [ + { port: 3000, url: "https://b1-3000.example", wake_on_request: true }, + { port: 8080, url: "https://b1-8080.example", wake_on_request: false }, + // An older server may omit the field entirely. + { port: 9090, url: "https://b1-9090.example" }, + ], + }), + }); + await publicUrlListCommand({ ...flags }); + const lines = out().split("\n"); + expect(lines.find((l) => l.includes("3000"))).toContain("(wakes)"); + expect(lines.find((l) => l.includes("8080"))).not.toContain("(wakes)"); + expect(lines.find((l) => l.includes("9090"))).not.toContain("(wakes)"); + }); + it("says so on stderr when there are none, leaving stdout empty", async () => { getBox.mockResolvedValue({ listPublicURLs: vi.fn().mockResolvedValue({ publicURLs: [] }) }); await publicUrlListCommand({ ...flags }); diff --git a/packages/cli/src/__tests__/repl/commands/public-url.test.ts b/packages/cli/src/__tests__/repl/commands/public-url.test.ts index 7ff8a053..5d71df1d 100644 --- a/packages/cli/src/__tests__/repl/commands/public-url.test.ts +++ b/packages/cli/src/__tests__/repl/commands/public-url.test.ts @@ -41,6 +41,40 @@ describe("handlePublicUrl", () => { expect(events.some((e) => String(e.message).includes("user: user"))).toBe(true); }); + it("warns when a wake-enabled URL has no authentication", async () => { + const box = createMockBox(); + box.getPublicURL.mockResolvedValue({ + url: "https://box-1-3000.preview", + port: 3000, + wake_on_request: true, + }); + const events = await collectEvents(handlePublicUrl(box as any, "3000 --wake-on-request")); + expect(box.getPublicURL).toHaveBeenCalledWith(3000, { wakeOnRequest: true }); + expect( + events.some((e) => String(e.message).includes("anyone with the URL can start this box")), + ).toBe(true); + }); + + it("does not warn when a wake-enabled URL is protected", async () => { + const box = createMockBox(); + box.getPublicURL.mockResolvedValue({ + url: "https://box-1-3000.preview", + port: 3000, + token: "t", + wake_on_request: true, + }); + const events = await collectEvents( + handlePublicUrl(box as any, "3000 --wake-on-request --bearer-token"), + ); + expect(box.getPublicURL).toHaveBeenCalledWith(3000, { + bearerToken: true, + wakeOnRequest: true, + }); + expect( + events.some((e) => String(e.message).includes("anyone with the URL can start this box")), + ).toBe(false); + }); + it("lists the public URLs, and with no argument", async () => { const box = createMockBox(); for (const args of ["list", ""]) { diff --git a/packages/cli/src/repl/commands/public-url.ts b/packages/cli/src/repl/commands/public-url.ts index c6cb67f1..ad357362 100644 --- a/packages/cli/src/repl/commands/public-url.ts +++ b/packages/cli/src/repl/commands/public-url.ts @@ -63,11 +63,17 @@ export async function* handlePublicUrl(box: Box, args: string): AsyncGenerator Date: Mon, 21 Sep 2026 16:18:59 +0300 Subject: [PATCH 3/7] DX-3029: Make wake_on_request optional on the list item type The type promised the field is always present while the CLI list test covers a server that omits it, which cannot both be true. The field is new, and baseUrl is configurable, so an older endpoint returns entries without it. Optional matches PublicURL and matches what the test already asserts. --- packages/sdk/src/types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f76efc9a..f512e970 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1256,8 +1256,9 @@ export interface PublicURLListItem { basic_auth: boolean; /** Whether the URL is protected by a bearer token */ bearer_token: boolean; - /** Whether a request to this URL resumes the box when it is paused */ - wake_on_request: boolean; + /** Whether a request to this URL resumes the box when it is paused. Absent on + * servers older than the wake-on-request release. */ + wake_on_request?: boolean; } /** @deprecated Use `PublicURL` instead. */ From fbea11ed516a5976ccbf6baca12ab48f113ed48c Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Mon, 21 Sep 2026 16:23:37 +0300 Subject: [PATCH 4/7] DX-3029: Cover init command and wake-on-request behaviour, not just wiring The unit test only exercised getInitCommand, but the keep-alive guard was removed from set and delete too, so either could have regressed to rejecting a normal box while the suite stayed green. All three are covered now. The mocked preview test only proved the request body is serialised correctly against a response it invents. Two integration cases cover what the option actually promises: a wake-enabled URL resumes a paused box and returns the app's own body, and a URL without the flag leaves the box asleep. Both run against a real box, verified green. --- .../sdk/src/__tests__/box-instance.test.ts | 27 +++++++- .../integration/preview.integration.test.ts | 62 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/__tests__/box-instance.test.ts b/packages/sdk/src/__tests__/box-instance.test.ts index 345c3994..1eee3d81 100644 --- a/packages/sdk/src/__tests__/box-instance.test.ts +++ b/packages/sdk/src/__tests__/box-instance.test.ts @@ -380,7 +380,9 @@ describe("Box instance methods", () => { await expect(box.setInitCommand("")).rejects.toThrow("initCommand is required"); }); - it("works on boxes that are not keep-alive", async () => { + // All three used to reject a box that was not keep-alive, so all three are + // covered here: one of them could otherwise regress unnoticed. + it("reads the init command on a box that is not keep-alive", async () => { const { box, fetchMock } = await createTestBox(); fetchMock.mockResolvedValueOnce(mockResponse({ init_command: "npm run dev" })); @@ -390,6 +392,29 @@ describe("Box instance methods", () => { expect(url).toContain("/v2/box/box-123/startup"); expect(init?.method).toBe("GET"); }); + + it("sets the init command on a box that is not keep-alive", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ message: "startup script saved" })); + + await expect(box.setInitCommand("npm run dev")).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/startup"); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(init?.body as string)).toEqual({ init_command: "npm run dev" }); + }); + + it("deletes the init command on a box that is not keep-alive", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ message: "startup script deleted" })); + + await expect(box.deleteInitCommand()).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/startup"); + expect(init?.method).toBe("DELETE"); + }); }); describe("logs", () => { diff --git a/packages/sdk/src/__tests__/integration/preview.integration.test.ts b/packages/sdk/src/__tests__/integration/preview.integration.test.ts index 666cd8f5..d35428ca 100644 --- a/packages/sdk/src/__tests__/integration/preview.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/preview.integration.test.ts @@ -97,3 +97,65 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs", () => { expect(res.publicURLs.every((p) => p.port !== 3000)).toBe(true); }); }); + +const WAKE_SECRET = "box-wake-on-request-integration-secret-7"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs: wake on request", () => { + let box: Box; + + beforeAll(async () => { + // The init command is what restarts the server after a resume, so it is + // part of the behaviour under test rather than setup convenience. + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + runtime: "node", + initCommand: `node -e 'require("http").createServer((_,r)=>r.end("${WAKE_SECRET}")).listen(3000)' &`, + }); + await new Promise((r) => setTimeout(r, 4000)); + }, 180000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("resumes a paused box and serves the app's own response", async () => { + const created = await box.getPublicURL(3000, { wakeOnRequest: true }); + expect(created.wake_on_request).toBe(true); + + const listed = await box.listPublicURLs(); + expect(listed.publicURLs.find((p) => p.port === 3000)?.wake_on_request).toBe(true); + + const awake = await fetch(created.url, { signal: AbortSignal.timeout(60_000) }); + expect(await awake.text()).toBe(WAKE_SECRET); + + await box.pause(); + expect((await box.getStatus()).status).toBe("paused"); + + // The wait is bounded at 30s server-side, so allow more than that here. + const woken = await fetch(created.url, { signal: AbortSignal.timeout(90_000) }); + expect(woken.status).toBe(200); + expect(await woken.text()).toBe(WAKE_SECRET); + expect((await box.getStatus()).status).not.toBe("paused"); + + await box.deletePublicURL(3000); + }, 240000); + + it("leaves a paused box asleep when the URL is not wake-enabled", async () => { + const created = await box.getPublicURL(3000); + expect(created.wake_on_request).toBe(false); + + await box.pause(); + expect((await box.getStatus()).status).toBe("paused"); + + const response = await fetch(created.url, { signal: AbortSignal.timeout(60_000) }); + expect(response.status).not.toBe(200); + expect((await box.getStatus()).status).toBe("paused"); + + await box.resume(); + await box.deletePublicURL(3000); + }, 240000); +}); From e4532c46e17ed1a111895a3f2f466980e8570631 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Tue, 22 Sep 2026 12:04:20 +0300 Subject: [PATCH 5/7] DX-3029: Drop the wake option, waking is now what every public URL does The backend made waking a paused box on a public URL request unconditional and removed wake_on_request from the API. Nothing had shipped the option, so it goes without a trace: the SDK option in both languages, the field on the two public URL types, the CLI and REPL flag, the list marker, the unprotected warning, and their tests. What stays is what the workflow actually needs. Init commands on every box, so a woken box has something listening, and the corrected list type. The integration case for a URL without the option staying asleep described the old behaviour and is gone. The remaining case creates the URL with no option, pauses, requests it, and expects the app's own body back. --- .changeset/init-commands-on-every-box.md | 22 ++++++++++ .../wake-on-request-and-startup-scripts.md | 30 ------------- .../src/__tests__/commands/public-url.test.ts | 43 ------------------- .../repl/commands/public-url.test.ts | 34 --------------- packages/cli/src/cli.ts | 1 - packages/cli/src/commands/public-url.ts | 10 +---- packages/cli/src/repl/commands/public-url.ts | 10 +---- packages/python-sdk/CHANGELOG.md | 4 -- packages/python-sdk/PARITY.md | 4 +- packages/python-sdk/README.md | 4 -- .../python-sdk/tests/_async/test_box_misc.py | 39 +---------------- .../python-sdk/upstash_box/_async/client.py | 10 +---- .../python-sdk/upstash_box/_sync/client.py | 10 +---- packages/python-sdk/upstash_box/types.py | 1 - .../sdk/src/__tests__/box-preview.test.ts | 30 ------------- .../integration/preview.integration.test.ts | 27 ++---------- packages/sdk/src/client.ts | 10 ++--- packages/sdk/src/types.ts | 5 --- 18 files changed, 38 insertions(+), 256 deletions(-) create mode 100644 .changeset/init-commands-on-every-box.md delete mode 100644 .changeset/wake-on-request-and-startup-scripts.md diff --git a/.changeset/init-commands-on-every-box.md b/.changeset/init-commands-on-every-box.md new file mode 100644 index 00000000..be6f1e0a --- /dev/null +++ b/.changeset/init-commands-on-every-box.md @@ -0,0 +1,22 @@ +--- +"@upstash/box": minor +"@upstash/box-cli": patch +--- + +Allow init commands on every box, and correct the public URL list type. + +Init commands are no longer restricted to keep-alive boxes. `Box.create` accepts +`initCommand` without `keepAlive`, and `getInitCommand`, `setInitCommand` and +`deleteInitCommand` work on any box, including a paused one, where the change is +stored and applied on the next resume. The CLI no longer rejects +`--init-command` without `--keep-alive`. + +This matters because a public URL now resumes a paused box on any request and +holds the request until the app's port is listening. The init command is what +restarts the app when that happens, so the two go together. + +`listPublicURLs()` now returns `PublicURLListItem[]` rather than `PublicURL[]`. +The previous type was wrong: the list endpoint returns `id`, `created_at`, +`basic_auth` and `bearer_token`, and never returns the `token`, `username` or +`password` fields the old type advertised, since those are only returned once at +creation. diff --git a/.changeset/wake-on-request-and-startup-scripts.md b/.changeset/wake-on-request-and-startup-scripts.md deleted file mode 100644 index ae59327b..00000000 --- a/.changeset/wake-on-request-and-startup-scripts.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@upstash/box": minor -"@upstash/box-cli": patch ---- - -Add `wakeOnRequest` to public URLs, and allow init commands on every box. - -`box.getPublicURL(port, { wakeOnRequest: true })` marks a public URL so that an -incoming HTTP request resumes a paused box. The request is held until the app's -port is listening, so the caller gets the app's own response rather than an -error. That wait is bounded at 30 seconds: if the port is still not listening, -the caller gets a `503` with `Retry-After: 5` while the box keeps resuming in -the background, so a retry usually succeeds. It is off by default: anyone who can reach a -wake-enabled URL can start the box and incur compute charges, so pair it with -`bearerToken` or `basicAuth`. The CLI exposes it as `box public-url ---wake-on-request`, and warns when the URL has no authentication. - -Init commands are no longer restricted to keep-alive boxes. `Box.create` accepts -`initCommand` without `keepAlive`, and `getInitCommand`, `setInitCommand` and -`deleteInitCommand` work on any box, including a paused one, where the change is -stored and applied on the next resume. The CLI no longer rejects -`--init-command` without `--keep-alive`. The two features are meant to be used -together: the init command is what restarts your app when a request wakes the -box. - -`listPublicURLs()` now returns `PublicURLListItem[]` rather than `PublicURL[]`. -The previous type was wrong: the list endpoint returns `id`, `created_at`, -`basic_auth`, `bearer_token` and `wake_on_request`, and never returns the -`token`, `username` or `password` fields the old type advertised, since those -are only returned once at creation. diff --git a/packages/cli/src/__tests__/commands/public-url.test.ts b/packages/cli/src/__tests__/commands/public-url.test.ts index eabe9d9a..003f6fa8 100644 --- a/packages/cli/src/__tests__/commands/public-url.test.ts +++ b/packages/cli/src/__tests__/commands/public-url.test.ts @@ -52,31 +52,6 @@ describe("box public-url", () => { expect(out()).toContain("user: u password: p"); }); - it("passes wakeOnRequest through and warns when the URL is unprotected", async () => { - const getPublicURL = vi.fn().mockResolvedValue({ - url: "https://b1-3000.example", - port: 3000, - wake_on_request: true, - }); - getBox.mockResolvedValue({ getPublicURL }); - await publicUrlCommand("3000", { ...flags, wakeOnRequest: true }); - expect(getPublicURL).toHaveBeenCalledWith(3000, { wakeOnRequest: true }); - expect(err()).toContain("anyone with the URL can start this box"); - }); - - it("does not warn when a wake-enabled URL is protected", async () => { - const getPublicURL = vi.fn().mockResolvedValue({ - url: "https://b1-3000.example", - port: 3000, - token: "t", - wake_on_request: true, - }); - getBox.mockResolvedValue({ getPublicURL }); - await publicUrlCommand("3000", { ...flags, wakeOnRequest: true, bearerToken: true }); - expect(getPublicURL).toHaveBeenCalledWith(3000, { bearerToken: true, wakeOnRequest: true }); - expect(err()).not.toContain("anyone with the URL can start this box"); - }); - it("rejects a port outside the valid range rather than calling the API", async () => { const getPublicURL = vi.fn(); getBox.mockResolvedValue({ getPublicURL }); @@ -95,24 +70,6 @@ describe("box public-url", () => { expect(out()).toContain("3000 https://b1-3000.example"); }); - it("marks only the wake-enabled URLs in the list", async () => { - getBox.mockResolvedValue({ - listPublicURLs: vi.fn().mockResolvedValue({ - publicURLs: [ - { port: 3000, url: "https://b1-3000.example", wake_on_request: true }, - { port: 8080, url: "https://b1-8080.example", wake_on_request: false }, - // An older server may omit the field entirely. - { port: 9090, url: "https://b1-9090.example" }, - ], - }), - }); - await publicUrlListCommand({ ...flags }); - const lines = out().split("\n"); - expect(lines.find((l) => l.includes("3000"))).toContain("(wakes)"); - expect(lines.find((l) => l.includes("8080"))).not.toContain("(wakes)"); - expect(lines.find((l) => l.includes("9090"))).not.toContain("(wakes)"); - }); - it("says so on stderr when there are none, leaving stdout empty", async () => { getBox.mockResolvedValue({ listPublicURLs: vi.fn().mockResolvedValue({ publicURLs: [] }) }); await publicUrlListCommand({ ...flags }); diff --git a/packages/cli/src/__tests__/repl/commands/public-url.test.ts b/packages/cli/src/__tests__/repl/commands/public-url.test.ts index 5d71df1d..7ff8a053 100644 --- a/packages/cli/src/__tests__/repl/commands/public-url.test.ts +++ b/packages/cli/src/__tests__/repl/commands/public-url.test.ts @@ -41,40 +41,6 @@ describe("handlePublicUrl", () => { expect(events.some((e) => String(e.message).includes("user: user"))).toBe(true); }); - it("warns when a wake-enabled URL has no authentication", async () => { - const box = createMockBox(); - box.getPublicURL.mockResolvedValue({ - url: "https://box-1-3000.preview", - port: 3000, - wake_on_request: true, - }); - const events = await collectEvents(handlePublicUrl(box as any, "3000 --wake-on-request")); - expect(box.getPublicURL).toHaveBeenCalledWith(3000, { wakeOnRequest: true }); - expect( - events.some((e) => String(e.message).includes("anyone with the URL can start this box")), - ).toBe(true); - }); - - it("does not warn when a wake-enabled URL is protected", async () => { - const box = createMockBox(); - box.getPublicURL.mockResolvedValue({ - url: "https://box-1-3000.preview", - port: 3000, - token: "t", - wake_on_request: true, - }); - const events = await collectEvents( - handlePublicUrl(box as any, "3000 --wake-on-request --bearer-token"), - ); - expect(box.getPublicURL).toHaveBeenCalledWith(3000, { - bearerToken: true, - wakeOnRequest: true, - }); - expect( - events.some((e) => String(e.message).includes("anyone with the URL can start this box")), - ).toBe(false); - }); - it("lists the public URLs, and with no argument", async () => { const box = createMockBox(); for (const args of ["list", ""]) { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ffbe29a6..11492a57 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -408,7 +408,6 @@ const publicUrl = program .argument("[port]", "Port to publish; omit to list") .option("--basic-auth", "Protect the URL with generated basic-auth credentials") .option("--bearer-token", "Protect the URL with a generated bearer token") - .option("--wake-on-request", "Resume the box when a request hits this URL") .option("--box ", "Box to act on") .option("--json", "Emit machine-readable output") .option("--token ", "Upstash Box API token") diff --git a/packages/cli/src/commands/public-url.ts b/packages/cli/src/commands/public-url.ts index 4e508694..113355d0 100644 --- a/packages/cli/src/commands/public-url.ts +++ b/packages/cli/src/commands/public-url.ts @@ -6,7 +6,6 @@ import { emit, note, requireToken, type GlobalFlags } from "../core/io.js"; export type PublicUrlFlags = GlobalFlags & { basicAuth?: boolean; bearerToken?: boolean; - wakeOnRequest?: boolean; }; /** Resolve the box once, shared by every verb. */ @@ -41,15 +40,11 @@ export async function publicUrlCommand(portArg: string, flags: PublicUrlFlags): const created = await box.getPublicURL(port, { ...(flags.basicAuth ? { basicAuth: true } : {}), ...(flags.bearerToken ? { bearerToken: true } : {}), - ...(flags.wakeOnRequest ? { wakeOnRequest: true } : {}), }); const lines = [created.url]; if (created.username) lines.push(`user: ${created.username} password: ${created.password}`); if (created.token) lines.push(`bearer token: ${created.token}`); emit(created, lines, flags); - if (flags.wakeOnRequest && !flags.basicAuth && !flags.bearerToken) { - note("Unprotected and wake-enabled: anyone with the URL can start this box."); - } // A server started as a plain background job is reaped when the command that // launched it finishes, and the URL then 502s. note("Start the server detached, ( npm run dev & ), or it stops with the command."); @@ -62,10 +57,7 @@ export async function publicUrlListCommand(flags: GlobalFlags): Promise { if (publicURLs.length === 0 && !flags.json) note("No public URLs."); emit( publicURLs, - publicURLs.map( - (entry) => - `${String(entry.port).padEnd(6)}${entry.url}${entry.wake_on_request ? " (wakes)" : ""}`, - ), + publicURLs.map((entry) => `${String(entry.port).padEnd(6)}${entry.url}`), flags, ); } diff --git a/packages/cli/src/repl/commands/public-url.ts b/packages/cli/src/repl/commands/public-url.ts index ad357362..e1fdfbfe 100644 --- a/packages/cli/src/repl/commands/public-url.ts +++ b/packages/cli/src/repl/commands/public-url.ts @@ -46,29 +46,21 @@ export async function* handlePublicUrl(box: Box, args: string): AsyncGenerator [--basic-auth|--bearer-token|--wake-on-request] | public-url list | public-url delete ", + "Usage: public-url [--basic-auth|--bearer-token] | public-url list | public-url delete ", }; return; } const basicAuth = parts.includes("--basic-auth"); const bearerToken = parts.includes("--bearer-token"); - const wakeOnRequest = parts.includes("--wake-on-request"); const created = await box.getPublicURL(port, { ...(basicAuth ? { basicAuth: true } : {}), ...(bearerToken ? { bearerToken: true } : {}), - ...(wakeOnRequest ? { wakeOnRequest: true } : {}), }); yield { type: "log", message: created.url }; if (created.username) { yield { type: "log", message: `user: ${created.username} password: ${created.password}` }; } if (created.token) yield { type: "log", message: `bearer token: ${created.token}` }; - if (wakeOnRequest && !basicAuth && !bearerToken) { - yield { - type: "log", - message: "Unprotected and wake-enabled: anyone with the URL can start this box.", - }; - } // Detaching matters: a server started as a plain background job is reaped // when the command that launched it finishes, and the URL then 502s. yield { diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index d9c125c4..06cf497a 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -4,10 +4,6 @@ All notable changes to `upstash-box` (Python) are documented here. ## Unreleased -- Add `wake_on_request` to `get_public_url()` and to the `PublicURL` model. When - true, a request to the public URL resumes a paused box and is held until the - app's port is listening. When false (the default) such a request gets a - "box is sleeping" response. - Init commands now work on every box, not only keep-alive ones. `create(init_command=...)` no longer requires `keep_alive=True`, and `get_init_command()`, `set_init_command()` and `delete_init_command()` no longer raise on a non-keep-alive box. diff --git a/packages/python-sdk/PARITY.md b/packages/python-sdk/PARITY.md index b7e79c2d..20bd5df4 100644 --- a/packages/python-sdk/PARITY.md +++ b/packages/python-sdk/PARITY.md @@ -99,7 +99,7 @@ statics `create`, `from_snapshot`, `get_by_name`, `delete_boxes`, | Browser `schema` = Pydantic model or raw dict (Python) vs Zod (JS) | Same `ResponseSchema` contract as `agent.run`; raw dicts skip client-side validation. | | `screenshot` `type: "png"\|"base64"` (JS) → `encoding: "bytes"\|"base64"` (Python) | Python returns native `bytes`; `encoding` matches `files.read` naming. | | `browser` on `from_snapshot` (Python) | Python's shared create-body builder forwards `browser=True` on `from_snapshot`; JS `fromSnapshot` currently omits it (JS gap). | -| `PublicURLListItem` type (JS) | JS types `listPublicURLs()` as a distinct list item (`id`, `created_at`, `basic_auth`, `bearer_token`, `wake_on_request`, no secrets); Python reuses `PublicURL` for create and list, so the list-only fields arrive through `extra="allow"` untyped (Python gap). | +| `PublicURLListItem` type (JS) | JS types `listPublicURLs()` as a distinct list item (`id`, `created_at`, `basic_auth`, `bearer_token`, no secrets); Python reuses `PublicURL` for create and list, so the list-only fields arrive through `extra="allow"` untyped (Python gap). | ## Behavioral quirks mirrored exactly @@ -109,7 +109,7 @@ statics `create`, `from_snapshot`, `get_by_name`, `delete_boxes`, - `Run.cancel()`: swallows endpoint errors, always sets `cancelled`. - `files.download` destination: `./{basename}` | `./workspace` | `./{basename(cwd)}`. - 3-mode run request: file paths → multipart, base64 objects → JSON `files`, else plain JSON. -- `wake_on_request` on `get_public_url` / `list_public_urls`, and init commands on any box (not just keep-alive): both SDKs dropped the keep-alive guard together. +- Init commands on any box (not just keep-alive): both SDKs dropped the keep-alive guard together. ## Test mapping (JS unit file → Python) diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index be45ccf4..e3b0ff35 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -266,10 +266,6 @@ await box.delete_snapshot(snapshot.id) url = await box.get_public_url(3000) urls = await box.list_public_urls() await box.delete_public_url(3000) - -# wake_on_request: a request to the URL resumes a paused box and waits for the -# port, so anyone who can reach the URL can start the box. -url = await box.get_public_url(3000, wake_on_request=True) ``` ### Browser diff --git a/packages/python-sdk/tests/_async/test_box_misc.py b/packages/python-sdk/tests/_async/test_box_misc.py index bf9d2ba2..b7bfac78 100644 --- a/packages/python-sdk/tests/_async/test_box_misc.py +++ b/packages/python-sdk/tests/_async/test_box_misc.py @@ -104,57 +104,22 @@ async def test_configure_model(): async def test_public_urls(): box = await make_async_box(respx.mock) respx.post(f"{BASE}/preview").mock( - return_value=httpx.Response( - 200, - json={"url": "https://x", "port": 3000, "token": "t", "wake_on_request": False}, - ) + return_value=httpx.Response(200, json={"url": "https://x", "port": 3000, "token": "t"}) ) respx.get(f"{BASE}/preview").mock( - return_value=httpx.Response( - 200, - json={"previews": [{"url": "https://x", "port": 3000, "wake_on_request": False}]}, - ) + return_value=httpx.Response(200, json={"previews": [{"url": "https://x", "port": 3000}]}) ) respx.delete(f"{BASE}/preview/3000").mock(return_value=httpx.Response(200, json={})) url = await box.get_public_url(3000, bearer_token=True) assert url.url == "https://x" assert url.token == "t" - assert url.wake_on_request is False listed = await box.list_public_urls() assert listed["public_urls"][0].port == 3000 - assert listed["public_urls"][0].wake_on_request is False await box.delete_public_url(3000) await box.aclose() -@respx.mock -async def test_public_url_wake_on_request(): - box = await make_async_box(respx.mock) - route = respx.post(f"{BASE}/preview").mock( - return_value=httpx.Response( - 200, json={"url": "https://x", "port": 3000, "wake_on_request": True} - ) - ) - url = await box.get_public_url(3000, wake_on_request=True) - assert last_json_body(route) == {"port": 3000, "wake_on_request": True} - assert url.wake_on_request is True - await box.aclose() - - -@respx.mock -async def test_public_url_omits_wake_on_request_by_default(): - box = await make_async_box(respx.mock) - route = respx.post(f"{BASE}/preview").mock( - return_value=httpx.Response( - 200, json={"url": "https://x", "port": 3000, "wake_on_request": False} - ) - ) - await box.get_public_url(3000) - assert last_json_body(route) == {"port": 3000} - await box.aclose() - - # ---------- lifecycle ---------- diff --git a/packages/python-sdk/upstash_box/_async/client.py b/packages/python-sdk/upstash_box/_async/client.py index 2cafe024..231b5de9 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -1785,21 +1785,13 @@ async def _label_list(self) -> List[str]: # ==================== Public URLs ==================== async def get_public_url( - self, - port: int, - *, - bearer_token: Optional[bool] = None, - basic_auth: Optional[bool] = None, - wake_on_request: Optional[bool] = None, + self, port: int, *, bearer_token: Optional[bool] = None, basic_auth: Optional[bool] = None ) -> PublicURL: body: Dict[str, Any] = {"port": port} if bearer_token is not None: body["bearer_token"] = bearer_token if basic_auth is not None: body["basic_auth"] = basic_auth - # wake_on_request: a request to a paused box resumes it and waits for the port. - if wake_on_request is not None: - body["wake_on_request"] = wake_on_request data = await self._request("POST", f"/v2/box/{self.id}/preview", body=body) return PublicURL.model_validate(data) diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 25287b35..b8de30fa 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -1768,21 +1768,13 @@ def _label_list(self) -> List[str]: # ==================== Public URLs ==================== def get_public_url( - self, - port: int, - *, - bearer_token: Optional[bool] = None, - basic_auth: Optional[bool] = None, - wake_on_request: Optional[bool] = None, + self, port: int, *, bearer_token: Optional[bool] = None, basic_auth: Optional[bool] = None ) -> PublicURL: body: Dict[str, Any] = {"port": port} if bearer_token is not None: body["bearer_token"] = bearer_token if basic_auth is not None: body["basic_auth"] = basic_auth - # wake_on_request: a request to a paused box resumes it and waits for the port. - if wake_on_request is not None: - body["wake_on_request"] = wake_on_request data = self._request("POST", f"/v2/box/{self.id}/preview", body=body) return PublicURL.model_validate(data) diff --git a/packages/python-sdk/upstash_box/types.py b/packages/python-sdk/upstash_box/types.py index 791ade5f..277afe27 100644 --- a/packages/python-sdk/upstash_box/types.py +++ b/packages/python-sdk/upstash_box/types.py @@ -594,7 +594,6 @@ class PublicURL(_Model): token: Optional[str] = None username: Optional[str] = None password: Optional[str] = None - wake_on_request: Optional[bool] = None class BoxData(_Model): diff --git a/packages/sdk/src/__tests__/box-preview.test.ts b/packages/sdk/src/__tests__/box-preview.test.ts index db89ca3b..c01f7ba7 100644 --- a/packages/sdk/src/__tests__/box-preview.test.ts +++ b/packages/sdk/src/__tests__/box-preview.test.ts @@ -62,36 +62,6 @@ describe("Box public URL operations", () => { const body = JSON.parse(init?.body as string); expect(body.basic_auth).toBe(true); }); - - it("creates a public URL that wakes a paused box", async () => { - const { box, fetchMock } = await createTestBox(); - const mockPublicUrl = { - url: "https://box-123-3000.preview.box.upstash.com", - port: 3000, - wake_on_request: true, - }; - fetchMock.mockResolvedValueOnce(mockResponse(mockPublicUrl)); - - const publicUrl = await box.getPublicURL(3000, { wakeOnRequest: true }); - expect(publicUrl.wake_on_request).toBe(true); - - const [, init] = fetchMock.mock.calls[1]!; - const body = JSON.parse(init?.body as string); - expect(body.wake_on_request).toBe(true); - }); - - it("omits wake_on_request when not requested", async () => { - const { box, fetchMock } = await createTestBox(); - fetchMock.mockResolvedValueOnce( - mockResponse({ url: "https://box-123-3000.preview.box.upstash.com", port: 3000 }), - ); - - await box.getPublicURL(3000); - - const [, init] = fetchMock.mock.calls[1]!; - const body = JSON.parse(init?.body as string); - expect(body).not.toHaveProperty("wake_on_request"); - }); }); describe("listPublicURLs", () => { diff --git a/packages/sdk/src/__tests__/integration/preview.integration.test.ts b/packages/sdk/src/__tests__/integration/preview.integration.test.ts index d35428ca..1c867404 100644 --- a/packages/sdk/src/__tests__/integration/preview.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/preview.integration.test.ts @@ -98,9 +98,9 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs", () => { }); }); -const WAKE_SECRET = "box-wake-on-request-integration-secret-7"; +const WAKE_SECRET = "box-paused-url-integration-secret-7"; -describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs: wake on request", () => { +describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs: paused box", () => { let box: Box; beforeAll(async () => { @@ -122,12 +122,8 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs: wake on request", () => { } }, 30000); - it("resumes a paused box and serves the app's own response", async () => { - const created = await box.getPublicURL(3000, { wakeOnRequest: true }); - expect(created.wake_on_request).toBe(true); - - const listed = await box.listPublicURLs(); - expect(listed.publicURLs.find((p) => p.port === 3000)?.wake_on_request).toBe(true); + it("a request to the URL resumes the box and serves the app's own response", async () => { + const created = await box.getPublicURL(3000); const awake = await fetch(created.url, { signal: AbortSignal.timeout(60_000) }); expect(await awake.text()).toBe(WAKE_SECRET); @@ -143,19 +139,4 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs: wake on request", () => { await box.deletePublicURL(3000); }, 240000); - - it("leaves a paused box asleep when the URL is not wake-enabled", async () => { - const created = await box.getPublicURL(3000); - expect(created.wake_on_request).toBe(false); - - await box.pause(); - expect((await box.getStatus()).status).toBe("paused"); - - const response = await fetch(created.url, { signal: AbortSignal.timeout(60_000) }); - expect(response.status).not.toBe(200); - expect((await box.getStatus()).status).toBe("paused"); - - await box.resume(); - await box.deletePublicURL(3000); - }, 240000); }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 026e10cc..7109a249 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -3198,20 +3198,18 @@ export class Box { // ==================== Public URLs ==================== /** - * Expose a port on a public URL. With `wakeOnRequest`, a request to the URL - * resumes a paused box and is held until the port is listening, which means - * anyone who can reach the URL can start the box and incur compute charges. + * Expose a port on a public URL. A request to the URL resumes the box if it + * is paused and is held until the port is listening. */ async getPublicURL( port: number, - options?: { bearerToken?: boolean; basicAuth?: boolean; wakeOnRequest?: boolean }, + options?: { bearerToken?: boolean; basicAuth?: boolean }, ): Promise { return this._request("POST", `/v2/box/${this.id}/preview`, { body: { port, ...(options?.bearerToken !== undefined && { bearer_token: options.bearerToken }), ...(options?.basicAuth !== undefined && { basic_auth: options.basicAuth }), - ...(options?.wakeOnRequest !== undefined && { wake_on_request: options.wakeOnRequest }), }, }); } @@ -3231,7 +3229,7 @@ export class Box { /** @deprecated Use `getPublicURL` instead. */ async getPreviewUrl( port: number, - options?: { bearerToken?: boolean; basicAuth?: boolean; wakeOnRequest?: boolean }, + options?: { bearerToken?: boolean; basicAuth?: boolean }, ): Promise { return this.getPublicURL(port, options); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f512e970..caaf6018 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1238,8 +1238,6 @@ export interface PublicURL { username?: string; /** Basic auth password (only returned when basicAuth is true) */ password?: string; - /** Whether a request to this URL resumes the box when it is paused */ - wake_on_request?: boolean; } /** One entry returned by `listPublicURLs()`. Secrets are only returned at creation time. */ @@ -1256,9 +1254,6 @@ export interface PublicURLListItem { basic_auth: boolean; /** Whether the URL is protected by a bearer token */ bearer_token: boolean; - /** Whether a request to this URL resumes the box when it is paused. Absent on - * servers older than the wake-on-request release. */ - wake_on_request?: boolean; } /** @deprecated Use `PublicURL` instead. */ From b5436ef396889ab822b249035a1d94409a82cb74 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Tue, 22 Sep 2026 13:01:38 +0300 Subject: [PATCH 6/7] DX-3029: Release as a patch --- .changeset/init-commands-on-every-box.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/init-commands-on-every-box.md b/.changeset/init-commands-on-every-box.md index be6f1e0a..6cf99f70 100644 --- a/.changeset/init-commands-on-every-box.md +++ b/.changeset/init-commands-on-every-box.md @@ -1,5 +1,5 @@ --- -"@upstash/box": minor +"@upstash/box": patch "@upstash/box-cli": patch --- From ac3f7ad6379ac0ec812404cd6cd5272996cc8256 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 23 Sep 2026 22:31:22 +0300 Subject: [PATCH 7/7] Correct init command docs for snapshot restore and non-keep-alive boxes --- packages/cli/README.md | 2 +- packages/sdk/README.md | 2 +- packages/sdk/src/types.ts | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index f9edc358..8cd2c6e2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -217,7 +217,7 @@ box create --no-repl \ | `--name ` | Human-readable name | | | `--size ` | Resource size | `small` | | `--keep-alive` | Keep the box running instead of pausing when idle | | -| `--init-command ` | Startup script, for keep-alive boxes | | +| `--init-command ` | Startup script, run once each time the box starts | | | `--browser` | Provision a headless Chromium | | | `--clone-repo ` | Clone this repository after creating the box | | | `--agent-model ` | Agent model identifier | | diff --git a/packages/sdk/README.md b/packages/sdk/README.md index f35a7e48..e4de86b1 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -326,7 +326,7 @@ await box.delete(); // Permanent delete const { status } = await box.getStatus(); ``` -Keep-alive boxes also support init-command management: +Init commands can be read, set and removed on any box, including a paused one: ```ts const script = await box.getInitCommand(); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index caaf6018..26311345 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -475,7 +475,11 @@ export interface BoxConfig extends BoxConnectionOptions { browser?: boolean; /** Keep the box alive instead of allowing pause-based idle lifecycle. */ keepAlive?: boolean; - /** Startup script run once per container start, after create, resume and snapshot restore. */ + /** + * Startup script run once per container start: after create and on every resume. + * A box restored from a snapshot does not inherit it; pass `initCommand` to + * `fromSnapshot` to set one there. + */ initCommand?: string; agent?: AgentConfig; git?: {