instabox 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,9 +17,10 @@ Any other command line is the instabox CLI's ([`cmd/instabox`](../cmd/instabox))
17
17
 
18
18
  - `bin/instabox.mjs` runs instabox's device-code sign-in (RFC 8628, `POST /v1/auth/device`, then polls `/v1/auth/device/token`), as `instabox login` does, and saves the tokens where the CLI keeps its own, `~/.config/instabox/credentials.json`, so both are signed in at once. There is one copy on purpose: the server rotates refresh tokens and ends the session when a rotated one comes back.
19
19
  - `sh` always starts a new box, with no timeout and no idle auto-suspend of its own. An existing box is entered with the CLI: `instabox ssh <box>`.
20
- - An installed CLI is checked against what the server's `/install.sh` would install: the binary's sha256 against the server's `/dl/SHA256SUMS` (release builds are named after a commit, so their versions do not sort). One the server no longer serves is replaced in place through the same installer; a development build (`instabox dev`, or `-dirty`) is left alone with a one-line notice of the install command.
20
+ - The package carries the Go CLI: all six builds (darwin, linux and windows; amd64 and arm64) under `cli/`, and `bin/instabox.mjs` picks the one for this machine ([`lib/platforms.mjs`](lib/platforms.mjs)). One package rather than one per platform keeps publishing to a single package and a single trusted-publisher setting; the price is that every install downloads the other platforms' builds too (about 22 MB compressed, cached by npx per version). It copies that binary onto PATH (`$INSTABOX_INSTALL_DIR`, else `/usr/local/bin` when writable, else `~/.local/bin`; Windows: `%LOCALAPPDATA%\Programs\instabox`), replaces an installed CLI whose content differs in place, and leaves a development build (`instabox dev`, or `-dirty`) alone. No network is needed for the CLI, and `npx instabox@latest <command>` runs the bundled binary directly, whatever is on PATH.
21
+ - Without a bundled binary (a platform with no build, or this directory run from a checkout, where `cli/` does not exist), an installed CLI is checked against what the server's `/install.sh` would install: the binary's sha256 against the server's `/dl/SHA256SUMS` (release builds are named after a commit, so their versions do not sort). One the server no longer serves is replaced in place through the same installer; a development build is left alone with a one-line notice of the install command.
21
22
  - Skills are written only over copies marked `installed-by: instabox`; the output says which were installed, updated, already up to date, or kept because they are the user's own.
22
- - On Windows, where `/install.sh` does not run, the CLI is installed by `lib/cli.mjs` itself: `/dl/instabox-windows-<arch>.exe`, checked against the `X-Instabox-Sha256` header sent with it (`SHA256SUMS` on older servers; a mismatch downloads it again, up to 3 times) and `--version`, renamed to `%LOCALAPPDATA%\Programs\instabox\instabox.exe`. The output gives the PowerShell line that puts that folder on the user's PATH; `npx instabox@latest <command>` finds it without that.
23
+ - Without a bundled binary on Windows, where `/install.sh` does not run, the CLI is installed by `lib/cli.mjs` itself: `/dl/instabox-windows-<arch>.exe`, checked against the `X-Instabox-Sha256` header sent with it (`SHA256SUMS` on older servers; a mismatch downloads it again, up to 3 times) and `--version`, renamed to `%LOCALAPPDATA%\Programs\instabox\instabox.exe`. The output gives the PowerShell line that puts that folder on the user's PATH; `npx instabox@latest <command>` finds it without that.
23
24
  - The bin is also called `instabox`, so `lib/cli.mjs` finds the real CLI the way the insta CLI does: PATH without `node_modules` dirs and without links back to this script, then the installer's own targets.
24
25
  - `prepack` copies `../skills` into the package; every skill there ships on the next publish.
25
26
 
@@ -33,10 +34,23 @@ npm pack # copies ../skills, then builds instabox-<version>.tgz
33
34
  npx --package=./instabox-0.4.3.tgz instabox --help
34
35
  ```
35
36
 
36
- `INSTABOX_API_URL` points it at another instabox server.
37
+ `INSTABOX_API_URL` points it at another instabox server. From a checkout there is no bundled binary (`cli/` is not in version control), so the wrapper takes the server-installer path; to try the bundled path, fill `cli/` the way CI does:
38
+
39
+ ```sh
40
+ ../scripts/build-cli.sh --out ../out/cli --version 0.0.0-local
41
+ node scripts/bundle-cli.mjs --cli-dir ../out/cli # -> cli/, checked against SHA256SUMS
42
+ node bin/instabox.mjs --version # runs cli/<build> for this machine
43
+ npm pack # a tarball with the binaries, ~22 MB
44
+ ```
37
45
 
38
46
  ## Release
39
47
 
40
- Bump `version` in `package.json` and merge that to `main`. [`npm-release.yml`](../.github/workflows/npm-release.yml) publishes when the merged commit is the one that changed the version: it runs the tests, publishes through npm trusted publishing (so there is no npm token anywhere), then tags the commit `npm-v<version>`. Nothing to tag or push by hand, and anyone who can merge can release - npm maintainer access is not needed. A version with a `-` (`0.5.0-rc.1`) goes to the `next` dist-tag, never to `latest`. If a publish fails after its merge, re-run the workflow from the Actions tab; it does nothing when the version is already on the registry.
48
+ Nothing to do by hand. [`npm-release.yml`](../.github/workflows/npm-release.yml) runs on every push to `main`. When anything the package ships has changed since the last `npm-v*` tag (`cmd/instabox` and the packages of this module it imports, `npm/`, `skills/`, `go.mod`, `go.sum`, `scripts/build-cli.sh`), it builds the six CLI binaries with `scripts/build-cli.sh`, puts them in `cli/` with [`scripts/bundle-cli.mjs`](scripts/bundle-cli.mjs) (each checked against `SHA256SUMS`), publishes `instabox` through npm trusted publishing (no npm token anywhere), and tags the commit `npm-v<version>`. Anyone who can merge can release.
49
+
50
+ The version is [`scripts/next-version.mjs`](scripts/next-version.mjs)'s: `package.json` names a line (`0.8.0`), and merges ship `0.8.0`, `0.8.1`, `0.8.2`, … on it. `package.json` changes only to start a new line (a new minor or major); a line below the highest published stable version is refused, so a reverted `package.json` cannot move `latest` back. A version with a `-` (`0.9.0-rc.1`) is published as written, once, under the `next` dist-tag. The workflow sets the version with `npm version` at publish time; the committed `package.json` keeps the line's base. `instabox --version` prints `<npm version>+<commit>`.
51
+
52
+ A commit is published once. npm records the commit of every version it publishes (`gitHead`), and the version step reads the registry's packument first: when a version built from this commit is already there (a rerun after the publish succeeded but the tag job failed), nothing is published again and only the missing `npm-v<version>` tag is created. So a failed run is simply re-run from the Actions tab (`workflow_dispatch`). Its `force` input publishes a new patch with nothing changed.
53
+
54
+ Trusted publishing is one setting on npmjs.com, package `instabox` → Settings → Trusted Publisher: GitHub Actions, `InsForge` / `instabox` / `npm-release.yml`. It is set. Renaming the workflow file breaks publishing until the setting is updated; npm does not follow renames.
41
55
 
42
- The trusted publisher is set on npmjs.com, package `instabox` → Settings → Trusted Publisher: GitHub Actions, `InsForge` / `instabox` / `npm-release.yml`. The very first publish, before the package existed, was by hand with `npm publish --access public` from this directory.
56
+ The CLI a user gets from npm is the newest merged, which can be ahead of the release a site runs; a server's `/install.sh` always serves the build of its own release.
package/bin/instabox.mjs CHANGED
@@ -20,7 +20,19 @@ import { fileURLToPath } from 'node:url';
20
20
  import { createClient } from '../lib/api.mjs';
21
21
  import { orderBoxes } from '../lib/boxes.mjs';
22
22
  import { guestUserNote, localGuestUser } from '../lib/guestuser.mjs';
23
- import { checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
23
+ import {
24
+ bundledCli,
25
+ checkCli,
26
+ cliVersion,
27
+ findCli,
28
+ installBundled,
29
+ installCli,
30
+ installCommand,
31
+ isOnboarding,
32
+ real,
33
+ runCli,
34
+ sha256File,
35
+ } from '../lib/cli.mjs';
24
36
  import { browserCommand, canOpenBrowser, deviceLogin } from '../lib/login.mjs';
25
37
  import { makeDirPrivate, sessionArgs } from '../lib/ssh.mjs';
26
38
  import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
@@ -199,6 +211,8 @@ async function newBox() {
199
211
  async function ensureCli() {
200
212
  if (process.argv.includes('--no-cli')) return null;
201
213
  const found = findCli();
214
+ const bundled = bundledCli();
215
+ if (bundled) return ensureBundledCli(bundled, found);
202
216
  if (found) {
203
217
  const { state, version } = await checkCli(found, API);
204
218
  const v = version ? ` ${version}` : '';
@@ -229,6 +243,42 @@ async function ensureCli() {
229
243
  );
230
244
  }
231
245
 
246
+ // This package carries the CLI for this machine (cli/<build>, lib/platforms.mjs): put
247
+ // that build on PATH, no network needed. An installed CLI is compared by content, since a release
248
+ // build's version is a commit and says nothing about which is newer; a development build is left
249
+ // alone. The build is this package's, so `npx instabox@latest` and `@next` each install theirs.
250
+ async function ensureBundledCli(bundled, found) {
251
+ const want = await sha256File(bundled).catch(() => null);
252
+ if (found) {
253
+ const [version, have] = await Promise.all([cliVersion(found), sha256File(found).catch(() => null)]);
254
+ const v = version ? ` ${version}` : '';
255
+ if (want && have === want) return `instabox CLI${v} is up to date ${dim('· try')} instabox ls`;
256
+ if (version === 'dev' || version?.endsWith('-dirty'))
257
+ return `instabox CLI at ${found} is a development build (${version}), left as is`;
258
+ // Replace it where it is, through a symlink the file it points to, so whatever manages the
259
+ // link stays as it was. Installing anywhere else would leave the old copy first on PATH.
260
+ const dir = dirname(real(found));
261
+ try {
262
+ accessSync(dir, fsConstants.W_OK);
263
+ } catch {
264
+ const copy =
265
+ process.platform === 'win32'
266
+ ? `copy "${bundled}" "${found}"`
267
+ : `sudo install -m 0755 "${bundled}" "${real(found)}"`;
268
+ return `instabox CLI${v} at ${found} is not this package's build and ${dir} is not writable ${dim('· to replace it:')} ${copy}`;
269
+ }
270
+ const r = await installBundled(bundled, { path: real(found) });
271
+ if (r.error) return `instabox CLI${v} at ${found} could not be updated ${dim(`(${r.error})`)}`;
272
+ return `instabox CLI updated${v ? ` from${v}` : ''} to ${r.version} at ${found} ${dim('· try')} instabox ls`;
273
+ }
274
+ const r = await installBundled(bundled);
275
+ if (r.error) return `instabox CLI not installed ${dim(`(${r.error})`)}`;
276
+ return (
277
+ `instabox CLI ${r.version} installed at ${r.path} ${dim('· try')} instabox ls` +
278
+ (r.pathHint ? `\n ${dim('add it to your PATH:')} ${r.pathHint}` : '')
279
+ );
280
+ }
281
+
232
282
  // A skill directory's files, relative to it, sorted.
233
283
  function skillFiles(dir, rel = '') {
234
284
  return readdirSync(join(dir, rel), { withFileTypes: true })
@@ -447,12 +497,13 @@ async function onboard(wantBox) {
447
497
  });
448
498
  }
449
499
 
450
- // Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`.
500
+ // Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`:
501
+ // this package's own build when it carries one, else the installed CLI, else a fresh install.
451
502
  const args = process.argv.slice(2);
452
503
  if (args.length === 1 && (args[0] === '-h' || args[0] === '--help')) usage();
453
504
  else if (isOnboarding(args)) await onboard(args.includes('sh'));
454
505
  else {
455
- let path = findCli();
506
+ let path = bundledCli() || findCli();
456
507
  if (!path) {
457
508
  console.error(dim(' installing the instabox CLI…'));
458
509
  const r = await installCli(API);
package/cli/SHA256SUMS ADDED
@@ -0,0 +1,6 @@
1
+ 0531b11251df8343d84361621f6f7bec9f2690fc3c75fe980fd53edb8d1759b4 instabox-darwin-amd64
2
+ 7141bfa8f6e733d14cdcb8a688655383be965c62d82fb78cfd31f5cd6b037ebf instabox-darwin-arm64
3
+ f7e343ccb682468efea8cc20e619a7b8911e6353e936ac2bf6c3c7e9e30d84ea instabox-linux-amd64
4
+ 861f7608a3881bea7c5a4d78b6edfc2e7fc27338ccb5fa52d5657ad3cf317ef8 instabox-linux-arm64
5
+ f61d537736cff51c2e0f12d72ec49dd4fffe7da4268052beb13c96ff09321ee0 instabox-windows-amd64.exe
6
+ 1dbdb239175487c5f036dd2e364f95febc8477d4970b99128302153e2b58cd5d instabox-windows-arm64.exe
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/cli.mjs CHANGED
@@ -1,9 +1,23 @@
1
1
  // The Go instabox CLI: find it, install it, hand it the command line.
2
2
  import { execFile, spawn, spawnSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { createReadStream, mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import {
5
+ accessSync,
6
+ chmodSync,
7
+ constants as fsConstants,
8
+ copyFileSync,
9
+ createReadStream,
10
+ mkdirSync,
11
+ realpathSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
5
17
  import { homedir } from 'node:os';
6
- import { join, sep } from 'node:path';
18
+ import { dirname, join, sep } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { buildName, platformFor } from './platforms.mjs';
7
21
 
8
22
  const OUR_FLAGS = new Set(['--github', '--google', '--no-browser', '--no-cli', '--no-skills']);
9
23
 
@@ -20,6 +34,15 @@ export const real = (p) => {
20
34
  }
21
35
  };
22
36
 
37
+ // Removes a temporary file if it exists; an error here must not hide the one being reported.
38
+ function tryRm(p) {
39
+ try {
40
+ rmSync(p, { force: true });
41
+ } catch {
42
+ // ENOTDIR and the like: there is nothing to remove.
43
+ }
44
+ }
45
+
23
46
  // Windows has no execute bit: any instabox.exe file runs.
24
47
  function runnable(p, platform) {
25
48
  try {
@@ -36,6 +59,71 @@ export function windowsInstallDir(env = process.env) {
36
59
  return join(local, 'Programs', 'instabox');
37
60
  }
38
61
 
62
+ // The CLI this package carries for this machine: cli/<build> next to lib/ (lib/platforms.mjs).
63
+ // Null when it is not there (a platform without a build, or this directory run from a checkout,
64
+ // where cli/ does not exist), and the caller falls back to the server's installer.
65
+ const CLI_DIR = fileURLToPath(new URL('../cli/', import.meta.url));
66
+ export function bundledCli({ platform = process.platform, arch = process.arch, dir = CLI_DIR } = {}) {
67
+ const p = platformFor(platform, arch);
68
+ if (!p) return null;
69
+ const bin = join(dir, buildName(p));
70
+ return runnable(bin, platform) ? bin : null;
71
+ }
72
+
73
+ // Where a first install goes, as install.sh chooses: $INSTABOX_INSTALL_DIR, else /usr/local/bin
74
+ // when writable, else ~/.local/bin (Windows: windowsInstallDir).
75
+ export function installDir({ env = process.env, platform = process.platform } = {}) {
76
+ if (env.INSTABOX_INSTALL_DIR) return env.INSTABOX_INSTALL_DIR;
77
+ if (platform === 'win32') return windowsInstallDir(env);
78
+ try {
79
+ accessSync('/usr/local/bin', fsConstants.W_OK);
80
+ return '/usr/local/bin';
81
+ } catch {
82
+ return join(env.HOME || homedir(), '.local', 'bin');
83
+ }
84
+ }
85
+
86
+ // The line that puts dir on the user's PATH, or null when it is there already.
87
+ export function pathHint(dir, { env = process.env, platform = process.platform } = {}) {
88
+ const win = platform === 'win32';
89
+ const norm = (d) => d.replace(/[\\/]+$/, '');
90
+ const dirs = (env.PATH || '').split(win ? ';' : ':').map(norm);
91
+ const onPath = win ? dirs.some((d) => d.toLowerCase() === norm(dir).toLowerCase()) : dirs.includes(norm(dir));
92
+ if (onPath) return null;
93
+ return win
94
+ ? `[Environment]::SetEnvironmentVariable('Path', ([string][Environment]::GetEnvironmentVariable('Path', 'User')).TrimEnd(';') + ';${dir.replaceAll("'", "''")}', 'User')`
95
+ : `export PATH="${dir}:$PATH"`;
96
+ }
97
+
98
+ // Installs the bundled CLI at src as path (default: instabox in dir, default installDir), the way install.sh and
99
+ // installWindows do: written next to its destination, checked with --version, renamed into place,
100
+ // so a reader of the old file never sees a partial one. {path, version, pathHint} or {error};
101
+ // never throws, and never needs the network.
102
+ export async function installBundled(
103
+ src,
104
+ { dir, path: dest, env = process.env, platform = process.platform, version = cliVersion } = {},
105
+ ) {
106
+ const win = platform === 'win32';
107
+ const to = dest ? dirname(dest) : dir || installDir({ env, platform });
108
+ const tmp = join(to, `.instabox.install.${process.pid}${win ? '.exe' : ''}`);
109
+ try {
110
+ mkdirSync(to, { recursive: true });
111
+ copyFileSync(src, tmp);
112
+ chmodSync(tmp, 0o755);
113
+ const v = await version(tmp);
114
+ if (!v) {
115
+ tryRm(tmp);
116
+ return { error: 'the bundled instabox does not run (instabox --version failed)' };
117
+ }
118
+ const path = dest || join(to, win ? 'instabox.exe' : 'instabox');
119
+ renameSync(tmp, path);
120
+ return { path, version: v, pathHint: pathHint(to, { env, platform }) };
121
+ } catch (e) {
122
+ tryRm(tmp);
123
+ return { error: e.message };
124
+ }
125
+ }
126
+
39
127
  // Skips node_modules dirs and links back to us, like insta's findDurableOnPath.
40
128
  export function findCli({ env = process.env, self = process.argv[1], platform = process.platform } = {}) {
41
129
  const me = real(self);
@@ -84,7 +172,7 @@ export function cliVersion(path) {
84
172
  }
85
173
 
86
174
  // The file's sha256, streamed: the CLI is tens of MB and this runs while the box starts.
87
- function sha256File(path) {
175
+ export function sha256File(path) {
88
176
  return new Promise((resolve, reject) => {
89
177
  const h = createHash('sha256');
90
178
  createReadStream(path)
@@ -209,21 +297,14 @@ async function installWindows(
209
297
  writeFileSync(tmp, body, { mode: 0o755 });
210
298
  const v = await version(tmp);
211
299
  if (!v) {
212
- rmSync(tmp, { force: true });
300
+ tryRm(tmp);
213
301
  return { error: 'the downloaded instabox does not run (instabox --version failed)' };
214
302
  }
215
303
  const path = join(to, 'instabox.exe');
216
304
  renameSync(tmp, path);
217
- const onPath = (env.PATH || '').split(';').some((d) => d.replace(/[\\/]+$/, '').toLowerCase() === to.toLowerCase());
218
- return {
219
- path,
220
- version: v,
221
- pathHint: onPath
222
- ? null
223
- : `[Environment]::SetEnvironmentVariable('Path', ([string][Environment]::GetEnvironmentVariable('Path', 'User')).TrimEnd(';') + ';${to.replaceAll("'", "''")}', 'User')`,
224
- };
305
+ return { path, version: v, pathHint: pathHint(to, { env, platform: 'win32' }) };
225
306
  } catch (e) {
226
- rmSync(tmp, { force: true });
307
+ tryRm(tmp);
227
308
  return { error: e.message };
228
309
  }
229
310
  }
@@ -0,0 +1,23 @@
1
+ // The Go CLI builds the `instabox` package carries, one per OS and CPU, under cli/ (not in git:
2
+ // scripts/bundle-cli.mjs copies them in from scripts/build-cli.sh's output at publish time). One
3
+ // package holds all six, so publishing is one package and one trusted-publisher setting; the
4
+ // price is that every install downloads the other platforms' builds too (about 22 MB compressed).
5
+
6
+ // Node's process.platform/process.arch on the left, Go's GOOS/GOARCH (build-cli.sh's file names)
7
+ // on the right.
8
+ export const PLATFORMS = [
9
+ { platform: 'darwin', arch: 'arm64', goos: 'darwin', goarch: 'arm64' },
10
+ { platform: 'darwin', arch: 'x64', goos: 'darwin', goarch: 'amd64' },
11
+ { platform: 'linux', arch: 'arm64', goos: 'linux', goarch: 'arm64' },
12
+ { platform: 'linux', arch: 'x64', goos: 'linux', goarch: 'amd64' },
13
+ { platform: 'win32', arch: 'arm64', goos: 'windows', goarch: 'arm64' },
14
+ { platform: 'win32', arch: 'x64', goos: 'windows', goarch: 'amd64' },
15
+ ];
16
+
17
+ // What build-cli.sh names the binary for this platform; the same name under cli/.
18
+ export const buildName = (p) => `instabox-${p.goos}-${p.goarch}${p.goos === 'windows' ? '.exe' : ''}`;
19
+
20
+ // The build for a machine, or null when there is none.
21
+ export function platformFor(platform = process.platform, arch = process.arch) {
22
+ return PLATFORMS.find((p) => p.platform === platform && p.arch === arch) || null;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instabox",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "A cloud box for your coding agent, the instabox CLI and its agent skills in one command: npx instabox@latest",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "lib/",
12
- "skills/"
12
+ "skills/",
13
+ "cli/"
13
14
  ],
14
15
  "scripts": {
15
16
  "prepack": "rm -rf skills && cp -R ../skills skills",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: instabox
3
- description: Use when the user wants a cloud Linux box (instabox sandbox) from the terminal with the `instabox` CLI — create one, SSH in, run a command in it, read its files, set its idle auto-pause, or delete it, or to report a problem with instabox itself (`instabox feedback`). For saving or restoring a box's disks, see instabox-snapshots.
3
+ description: Use when the user wants a cloud Linux box (instabox sandbox) from the terminal with the `instabox` CLI — create one, SSH in, run a command in it, read its files, set its idle auto-pause, or delete it, to run a repo's tests in CI (GitHub Actions: a box per job, or a self-hosted runner), or to report a problem with instabox itself (`instabox feedback`). For saving or restoring a box's disks, see instabox-snapshots.
4
4
  metadata:
5
5
  installed-by: instabox
6
6
  ---
@@ -24,14 +24,14 @@ For CI or an agent without a browser, use an API key instead: `export INSTABOX_A
24
24
 
25
25
  ## Templates
26
26
 
27
- `agent-browser` is the default, so `create` needs no `--template` for ordinary work. It is Debian 13 (trixie, amd64, kernel 6.18.54) with git, gh, Node.js 22, Python 3.13 (`python3 -m venv`, pip, uv), Go, Rust, a C toolchain, Docker, Claude Code and Codex, plus a desktop (VNC, Chromium) ready for browsing or driving a GUI from the start, on a 16 GiB root disk and a 20 GiB persistent disk at `/data`. The dashboard shows it as **standard**. Pick another template only for its one purpose:
27
+ `agent-browser` is the default, so `create` needs no `--template` for ordinary work. It is Debian 13 (trixie, amd64, kernel 6.18.54) with git, gh, Node.js 22 (npm, pnpm, Yarn), Bun, Python 3.13 (`python3 -m venv`, pip, uv), Go, Rust, a C toolchain, Docker, Claude Code and Codex, plus a desktop (VNC, Chromium) ready for browsing or driving a GUI from the start, on a 16 GiB root disk and a 20 GiB persistent disk at `/data`. The dashboard shows it as **standard**. Pick another template only for its one purpose:
28
28
 
29
29
  - `universal`: the same tools without the desktop, which installs itself the first time it is used (about half a minute); a smaller root disk. The dashboard shows it as **basic**.
30
30
  - `herdr`: for the herdr client. The dev tools (gh, Node.js 22, Python 3.13 with venv and pip, Claude Code, Codex, the desktop) without Docker, uv, Go, Rust or a C toolchain; an 8 GiB root disk.
31
31
  - `orca`: for Orca desktop and mobile. The same tools as `herdr` plus the Orca runtime instead of herdr; an 8 GiB root disk.
32
32
  - `t3`: for the T3 Code desktop and mobile clients. `universal` plus the T3 Code server (port 3774); a 16 GiB root disk.
33
33
 
34
- `instabox templates` lists the ones this server offers (`--json` also lists retired ones with `"creatable": false`, which no new box can start from). The free plan creates boxes from `agent-browser` only: another template is refused with `403 template_not_allowed` and needs a paid plan (the user upgrades on the dashboard). A box from a snapshot (`--snapshot`) works on every plan, whatever template the snapshot came from. Do not retry another template on the free plan; use `agent-browser`. Over the HTTP API, `POST /v1/machines` with an unknown `templateId` is `400 bad_request` whose message lists the templates your plan can create from; a body field the API does not have is `400 unknown_field` naming the closest real one (`template`: did you mean `templateId`?); `name` may be left out (`box-` and 8 random hex digits).
34
+ `instabox templates` lists the ones this server offers, one row per version with a description; its TAG column (`--json`: `"latest"`, `"default"`) marks the version `create` gets for `--template T` alone (latest) and with no `--template` (default). Pin another version with `--template T@VERSION` (or `--template-version`). `--json` also lists retired ones with `"creatable": false`, which no new box can start from. The free plan creates boxes from `agent-browser` only: another template is refused with `403 template_not_allowed` and needs a paid plan (the user upgrades on the dashboard). A box from a snapshot (`--snapshot`) works on every plan, whatever template the snapshot came from. Do not retry another template on the free plan; use `agent-browser`. Over the HTTP API, `POST /v1/machines` with an unknown `templateId` is `400 bad_request` whose message lists the templates your plan can create from, and a version that template does not have (`agent-browser@nope`, or `templateVersion`) is `400 bad_request` listing its versions newest first, the one you get without a version tagged `(latest)` or `(default)`; a body field the API does not have is `400 unknown_field` naming the closest real one (`template`: did you mean `templateId`?); `name` may be left out (`box-` and 8 random hex digits).
35
35
 
36
36
  ## Sizes
37
37
 
@@ -43,7 +43,7 @@ A box is 4 vCPU / 8 GB of memory unless `create` names another size of the accou
43
43
  | Pro | 2 / 4096, 4 / 8192 (default), 8 / 16384 |
44
44
  | Team | 2 / 4096, 4 / 8192 (default) up to 32 / 65536 |
45
45
 
46
- `instabox whoami --json` lists the sizes this account may create as `allowedSpecs` (the default first, then the plan's others and any an admin granted); check it rather than assuming the plan. Another size is refused with `400 spec_not_allowed`, whose message names the account's sizes. Pick a size for the work: 2 vCPU (paid plans) for light editing or a single agent, the default for most builds, 8 vCPU (paid plans) for heavy builds or many processes. `--snapshot` takes the same flags. The size is fixed once the box exists; for another size, save a snapshot and create a new box from it with the size flags.
46
+ `instabox whoami --json` lists the sizes this account may create as `allowedSpecs` (the default first, then the plan's others and any an admin granted); check it rather than assuming the plan. Another size is refused with `400 spec_not_allowed`, whose message names the account's sizes. Pick a size for the work: 2 vCPU (paid plans) for light editing or a single agent, the default for most builds, 8 vCPU (paid plans) for heavy builds or many processes. `--snapshot` takes the same flags. A paid box moves to another of the plan's sizes while stopped (`instabox resize <box> --vcpus N --mem-mib M [--restart]`, applied at its next start; that start fails with `no_capacity` if its host has no room for the new size, or `host_unavailable` if the host's node-agent cannot resize yet, and the box stays stopped at the new size); a free box's size is fixed.
47
47
 
48
48
  ## Everyday commands
49
49
 
@@ -72,21 +72,24 @@ instabox rm --yes work # delete the box (without --yes
72
72
  ```
73
73
 
74
74
  - Names or IDs (`m_...`) both work wherever `<id-or-name>` is accepted.
75
- - `create` options: `--template T` or `--snapshot S`, `--vcpus N --mem-mib M` (a size of the plan, see Sizes), `--auto-suspend D|0`, `--max-run-minutes N`, `--meta K=V`, `--org O`, `--secrets all|NAME,NAME|none`, `--no-wait`, `--setup-file PATH|-`, `--fail-fast` (when no host has room, fail at once and create nothing instead of failing the create after; with `--snapshot`, instead of creating it stopped).
76
- - `create --setup-file PATH` (`-`: stdin; at most 64 KB) runs that script in the new box once it is running, in HOME as the box's user (by its `#!` line, else with bash; CRLF line endings become LF), and prints its output; it replaces create + push + exec for provisioning. A failing script makes `create` exit with the script's code and keeps the box: debug it with `instabox exec work -- /tmp/instabox-setup.sh`. With `--json` the sandbox object gets a `setup` object (`exitCode`, `stdout`, `stderr`, `signal`, `timedOut`, `truncated`, `durationMs`). The script is killed after `--setup-timeout` (default and at most 10m); for longer installs, use `ssh`. A missing, empty or over-64 KB file is refused before any box is created. `run --setup-file` does the same before its command, with its `-e` variables; when the script fails, run's `--json` `exitCode` is the script's. The script stays at `/tmp/instabox-setup.sh` (and in snapshots), so keep credentials out of it.
77
- - Idle auto-pause defaults to 30 minutes. Paid boxes allow 5, 10, 15, 20, 25 or 30 minutes, or `0` to turn it off. Free boxes keep 30 minutes unless an admin grants an idle override. Idle means no agent working, no terminal use and little CPU.
75
+ - `create` options: `--template T` or `--snapshot S`, `--vcpus N --mem-mib M` (a size of the plan, see Sizes), `--auto-suspend D|0`, `--max-run-minutes N`, `--delete-after D` (delete it D after creation, 1m-24h, whatever its state), `--meta K=V`, `--org O`, `--secrets all|NAME,NAME|none`, `--no-wait`, `--setup-file PATH|-` (`--wait-setup`), `--fail-fast` (when no host has room, fail at once and create nothing instead of failing the create after; with `--snapshot`, instead of creating it stopped).
76
+ - `create --setup-file PATH` (`-`: stdin; at most 64 KB) runs that script in the new box once it is running, in HOME as the box's user (by its `#!` line, else with bash; CRLF line endings become LF), and prints its output; it replaces create + push + exec for provisioning. A failing script makes `create` exit with the script's code and keeps the box: debug it with `instabox exec work -- /tmp/instabox-setup.sh`. With `--json` the sandbox object gets a `setup` object (`exitCode`, `stdout`, `stderr`, `signal`, `timedOut`, `truncated`, `durationMs`). `create` always waits for the script (`--wait-setup` names that; `=false` is refused) and keeps its state in the box's metadata, so `instabox get work --json` shows it later: `metadata.setupStatus` (`pending`, `running`, `succeeded`, `failed`), `setupExitCode` (`124`: timed out) and `setupLog` (the last 512 characters of its stdout then stderr). The script is killed after `--setup-timeout` (default and at most 10m); for longer installs, use `ssh`. A missing, empty or over-64 KB file is refused before any box is created. `run --setup-file` does the same before its command, with its `-e` variables; when the script fails, run's `--json` `exitCode` is the script's. The script stays at `/tmp/instabox-setup.sh` (and in snapshots), so keep credentials out of it.
77
+ - Idle auto-pause defaults to 30 minutes. Paid boxes allow 5, 10, 15, 20, 25 or 30 minutes (a time between rounds up; below 5m or above 30m is refused, nothing created), or `0` to turn it off. Free boxes keep 30 minutes unless an admin grants an idle override; asking another value on a free box fails (`permission_denied`, nothing created), so leave `--auto-suspend` out there. Idle means no agent working, no terminal use and little CPU.
78
78
  - Max run minutes is independent of idle activity: `-1` is unlimited, `0` pauses immediately, and a positive integer forces a pause after that many running minutes. Free boxes have a fixed 480-minute cap that only an admin session can change; paid boxes default to `-1` and can change it. An idle override does not permit changes to a free box's runtime cap. The first limit reached pauses the box; both timers restart after sleep and wake.
79
79
  - A runtime pause blocks every wake path during a server-configured cooldown, 10 minutes by default. Wait until the `cooldownUntil` deadline shown by `instabox get`; edits cannot clear it. Afterward, an automatically paused box wakes on `exec`, `ssh`, `files`, a public port request or `instabox wake <box>`. A zero runtime cap must be changed before waking. A normal pause preserves memory and processes.
80
+ - `sudo reboot` inside a box reboots it: `instabox get` shows `starting`, then `running` (same disks, processes gone); wait for `running` before the next `exec`. `sudo poweroff` inside stops it until `instabox start`. Boxes from older template versions stop on a reboot too: start them.
80
81
  - If `exec`/`ssh` stop answering (hang or time out on a running box), run `instabox restart <box>` (alias `recover`; `--timeout 20s` shortens the wait, `--json` prints the box). It stops the box, killing the VM if it has not shut down by the timeout (60 seconds by default), then starts it and waits until it runs. Files on the root disk and `/data` stay; processes do not, so start your servers again. A paused box is not hung: resume it. A box stuck in `starting` with no start in progress cannot be restarted; restart says so.
81
- - Disk full (`df -h / /data`)? Grow it with `instabox resize <box> --disk N` (`/data`, where HOME is) and/or `--root-disk N` (`/`), in GB. Disks only grow, and only while the box is stopped: on a running or paused box add `--restart` (it stops the box, grows the disks and starts it again; files stay, processes do not), else resize changes nothing and says so. Free-plan boxes keep their size.
82
+ - Disk full (`df -h /data`)? Grow it with `instabox resize <box> --disk N` (`/data`, where HOME is), in GB. A running box grows online, no restart. A shrink, a paused box, or an image that can't grow online needs `--restart` (it stops the box, resizes the disk and starts it again; files stay, processes do not), else resize changes nothing and says so. A shrink must fit your data plus 10% (at least 1 GB), else it's refused (`disk_too_small`) and nothing changes. Free-plan boxes keep their size. The system disk (`/`) is the template's; you can't grow it.
83
+ - Need more CPU or memory? A paid box moves to another of its plan's sizes with `instabox resize <box> --vcpus N --mem-mib M` (Pro: 2/4096, 4/8192, 8/16384), while it is stopped; add `--restart` on a running box, and run that from outside the box (from inside, the stop would end the command before the start, so resize refuses it; the same goes for a shrink with `--restart`). A size the plan does not have is refused before anything stops. The new size applies at the next start; free-plan boxes keep theirs.
82
84
  - Use `exec` to run a command and read its output, and `ssh` for an interactive shell.
83
85
  - Use `cp`, `push` and `pull` to move files, not `echo <base64> | base64 -d` over `exec`. `cp LOCAL work:PATH` and `cp work:PATH LOCAL` copy either way (`-r` for directories). `push work LOCAL [REMOTE]` copies a file or directory to REMOTE (default: HOME); an existing directory or a path ending in `/` receives it under its own name. REMOTE's parent directory must exist (else `missing_parent`); `-p` creates it. A file streams byte-for-byte, up to 5 GiB, and its sha256 is checked; a directory goes as a gzipped tar of at most 1 GiB. From inside a git repo, a directory push sends only tracked files (`git ls-files -c`); `--all` adds untracked files git does not ignore; `node_modules`, build output and `.git` stay behind. `pull work REMOTE [LOCAL]` copies back the same way: a file streams, any size; a directory is read as root, at most 32 MiB compressed. Over HTTP: `PUT /v1/machines/{id}/files?path=P` with the raw body, `GET /v1/machines/{id}/files/content?path=P`, and `POST /v1/machines/{id}/files/upload-url` for a signed URL that takes the PUT without a key.
84
86
  - `exec` with one argument after `--` runs it as a shell string (`instabox exec work -- 'ls | wc -l'`); several arguments run as that exact argv, each quoted for you (`instabox exec work -- bash -c 'echo a b > /tmp/x'`).
85
- - For a one-off job that needs no box afterwards, use `instabox run [--template T | --from SNAPSHOT] [--name N] [--push DIR] [--cwd D] [-e K=V ...] [--timeout 60s] [--keep] [--setup-file PATH|-] [--json] -- <cmd...>` instead of create + exec + rm: it deletes the box even when the command fails or the run is interrupted (Ctrl-C, SIGTERM), and exits with the command's code (124: timed out). Its own failures have their own codes: 2 usage, 75 `--fail-fast` and no host has room (retry later), 125 `run` itself failed (API, network, create, upload or cleanup), 130 interrupted; with `--json` they print the `{"error": {...}}` object. One argument is a shell string, several an exact argv. The same 10-minute limit as `exec` applies. `--push .` first uploads the current directory's files (those git does not ignore) into the working directory (`--cwd`, default HOME), so `instabox run --push . -- 'go test ./...'` tests your checkout. `--name N` is used as is (1-63 lowercase letters, digits or `-`, starting with a letter or digit; default `run-<8 hex digits>`). A box left behind by a killed CLI is deleted by the server at its `deleteAt`, 5 minutes after its timeout (10 with `--push`, plus another timeout with `--setup-file`); a cleanup delete that gets a 5xx, a retryable error or no answer is retried 5 times with backoff. `--setup-file PATH|-` runs a setup script (at most 64 KB) first, in HOME with the same `--timeout` and `-e` variables (after `--push`); if it fails, `run` exits with its code without running the command. `--keep` keeps the box with no deadline and, once it was created, prints `Kept NAME; delete it with: instabox rm --yes NAME` to stderr (stdout stays the command's; `--json` has it as `sandbox`); a failed create operation prints only its error (one whose wait was cut short is kept, with `Kept NAME, whose create had not finished; …`).
87
+ - For a one-off job that needs no box afterwards, use `instabox run [--template T | --from SNAPSHOT] [--name N] [--push DIR] [--cwd D] [-e K=V ...] [--timeout 60s] [--keep] [--setup-file PATH|-] [--json] -- <cmd...>` instead of create + exec + rm: it deletes the box even when the command fails or the run is interrupted (Ctrl-C, SIGTERM), and exits with the command's code (124: timed out). Its own failures have their own codes: 2 usage, 75 `--fail-fast` and no host has room (retry later), 125 `run` itself failed (API, network, create, upload or cleanup), 130 interrupted; with `--json` they print the `{"error": {...}}` object, except a failed cleanup delete: the result then has `deleted: false` and `cleanup: {"error": {...}}` (stdout is always one JSON document). One argument is a shell string, several an exact argv. The same 10-minute limit as `exec` applies. `--push .` first uploads the current directory's files (those git does not ignore) into the working directory (`--cwd`, default HOME), so `instabox run --push . -- 'go test ./...'` tests your checkout. `--name N` is used as is (1-63 lowercase letters, digits or `-`, starting with a letter or digit; default `run-<8 hex digits>`). A box left behind by a killed CLI is deleted by the server at its `deleteAt`, 5 minutes after its timeout (10 with `--push`, plus another timeout with `--setup-file`); a cleanup delete that gets a 5xx, a retryable error or no answer is retried 5 times with backoff. `--setup-file PATH|-` runs a setup script (at most 64 KB) first, in HOME with the same `--timeout` and `-e` variables (after `--push`); if it fails, `run` exits with its code without running the command. `--keep` keeps the box with no deadline and, once it was created, prints `Kept NAME; delete it with: instabox rm --yes NAME` to stderr (stdout stays the command's; `--json` has it as `sandbox`); a failed create operation prints only its error (one whose wait was cut short is kept, with `Kept NAME, whose create had not finished; …`).
86
88
  - `instabox ps work [--all] [--json]` lists the processes running in the box (it runs `ps` through exec, so it wakes a box that paused itself and refuses a stopped or manually paused one). Kernel threads and the listing's own processes (its shell, `ps`, `head` and the sshd sessions exec uses) are left out unless `--all`. `--json` prints an array of `{pid, ppid, user, cpuPercent, memPercent, elapsedSeconds, command}`: `instabox ps work --json | jq '.[] | select(.command | test("node"))'`. It lists only what runs now; there is no history of past commands or their output.
87
89
  - To copy a box (try something risky, or run agents side by side from the same state), use `instabox fork work [--name N] [--count N] [--keep-snapshot] [--json]` (`clone` works too): it saves a snapshot of `work` (live while it runs, where the server allows), creates `--count` boxes (1-5) from it with the same template and size, waits until they run, and deletes the snapshot, also on error or Ctrl-C, unless `--keep-snapshot`. `work` is not changed. Several boxes are named `N-1` to `N-N`. It is snapshot + create, so it takes about as long as those two; each box counts toward the plan's starts a minute, which fork waits out (up to 5 minutes in total). If the source's size is no longer one of the plan's sizes, pass `--vcpus N --mem-mib M`. On a failure part-way it prints which boxes were created; they are kept, so delete those you do not need. `--json` prints `{source, machines, snapshotId, snapshotName, kept, snapshotMs, createMs, totalMs}`.
88
- - `exec` returns the command's exit code and takes `--stdin` to pipe input. `exec --json` prints one object instead of the raw output (`stdout`, `stderr`, `exitCode`, `signal`, `timedOut`, `truncated`, `durationMs`) and still exits with the command's code: `instabox exec work --json -- make test | jq .exitCode`. It stops the command after `--timeout` seconds: 60 by default, 600 at most. Run longer commands (installs, builds, test suites) over SSH, which also returns the exit code: `instabox ssh work -- 'cd /data/workspace/app && npm ci'`. The box's runtime cap still applies during SSH or exec.
89
- - For work past 600 s, or to keep going while you do something else, start a background job: `instabox exec --background work -- 'npm run build'` prints a job ID at once (`--json`: `{jobId, pid}`); then `instabox jobs ls work`, `instabox jobs logs work <job> -f` (streams until it ends, exits with its code; Ctrl-C leaves the job running), `instabox jobs wait work <job> [--timeout 30m]` and `instabox jobs kill work <job> [--signal KILL]` (the whole process group). This is the CLI polling over plain exec calls, not server-side streaming. A job holds no activity lease: only its CPU use, or a running `jobs wait`/`logs -f`, counts as activity, so keep `jobs wait` going for a quiet job you need to finish. A pause freezes the job and it carries on after resume; a stop ends jobs (`exited(143)`, or `lost` after a hard stop).
90
+ - `exec` returns the command's exit code and takes `--stdin` to pipe input (any size up to 5 GiB, binary too: `instabox exec work --stdin -- tar -xz -C /data/workspace < src.tgz`). `exec --json` prints one object instead of the raw output (`stdout`, `stderr`, `exitCode`, `signal`, `timedOut`, `truncated`, `invalidUtf8`, `durationMs`) and still exits with the command's code: `instabox exec work --json -- make test | jq .exitCode`. exec output is UTF-8 text: each stream is cut at 1 MiB (`truncated`; `--stream` has no cap) and bytes that are not UTF-8 become U+FFFD (`invalidUtf8`), so never copy a file out with `exec -- cat` (an APK or tarball comes back corrupt); use `pull`/`cp`. A cut is noted on stderr and keeps the exit code; when the command exits 0 but its output was binary, exec says so on stderr and exits 65 (its own code, like 75 and 125, so it never looks like the command's). `exec --stream` prints the output as the command writes it instead of at the end (same timeout and exit code; with `--json`, one `{"type":"stdout"|"stderr","data"}` object per line, then `{"type":"exit","exitCode",...}`), so you can watch a build or test run as it goes. It stops the command after `--timeout` seconds: 60 by default, 600 at most (whole seconds; `run`'s also takes `90s` or `5m`). A larger or unparsable value is a one-line usage error, exit 2. It returns when the command exits, even if it left a process running in the background (`instabox exec work -- 'nohup ./server >server.log 2>&1 &'` returns at once and the server keeps running); background output written later than about a second after the exit is not returned, and a background process that still writes to exec's stdout or stderr after that gets a broken pipe (it may die, or a server may drop requests), so send both to a file (`>log 2>&1`) or use `exec --background`. Run longer commands (installs, builds, test suites) over SSH, which also returns the exit code: `instabox ssh work -- 'cd /data/workspace/app && npm ci'`. The box's runtime cap still applies during SSH or exec.
91
+ - For work past 600 s, or to keep going while you do something else, start a background job: `instabox exec --background work -- 'npm run build'` prints a job ID at once (`--json`: `{jobId, pid}`); then `instabox jobs ls work`, `instabox jobs logs work <job> -f` (streams until it ends, exits with its code; Ctrl-C leaves the job running), `instabox jobs wait work <job> [--timeout 30m]` and `instabox jobs kill work <job> [--signal KILL]` (the whole process group). This is the CLI polling over plain exec calls. A job holds no activity lease: only its CPU use, or a running `jobs wait`/`logs -f`, counts as activity, so keep `jobs wait` going for a quiet job you need to finish. A pause freezes the job and it carries on after resume; a stop ends jobs (`exited(143)`, or `lost` after a hard stop).
92
+ - A box that does not boot, is unreachable, or failed to start or resume: `instabox logs work` prints its console log (kernel and systemd boot messages) and its last 20 operations with their errors, in any state, without waking it. Add `-f` to follow it or `--json` for `GET /v1/machines/{id}/logs`.
90
93
  - Output: `--json` (or `-o json`) works on every command (each command's `-h` lists it); `ssh`, `mosh` and `port-forward` are interactive and refuse it with the JSON error code `json_unsupported` (use `exec --json`). With JSON output a failure prints `{"error":{"code","message",...}}` on stdout instead of text on stderr and still exits non-zero (2 for usage errors, code `usage`); API errors keep the server's `code`, `status` and `requestId`. Piped, the list and status commands (`ls`, `get`, `templates`, `usage`, `snapshot ls`, `port ls`, `keys ls`, `secrets ls`, `vault ls`, `workspace ls|get|members|invites`) print JSON on their own, so `instabox ls | jq` works; `--output table` or `INSTABOX_NO_AUTO_JSON=1` keeps the table. `exec`, `ssh`, `files` and the other commands print the same output piped or not.
91
94
  - The box's HOME has `MACHINE.md` (which box it is, its size, what survives a stop, the installed tools) and `AGENTS.md`, a moment after it starts. They are reference for when a task needs a fact about the box, not steps to run first.
92
95
  - A public URL for a web server in the box: `instabox port publish work 3000` prints it (`instabox port ls`, `instabox port unpublish work 3000`). The URL works while the box runs.
@@ -113,7 +116,7 @@ instabox create --name tenant --no-vault --secrets none
113
116
 
114
117
  - Values for `set` come from stdin or a hidden prompt. Never print a credential or put its value into agent output, chat, command arguments, or logs. Before asking for a credential, check metadata with `secrets ls --org <org>` and `secrets show <box>`. A silent check such as `test "${NAME+x}" = x` tests that the placeholder is present without revealing it; it does not prove the upstream accepts the credential.
115
118
  - `--host` is optional and repeatable. Give each exact hostname the credential may be sent to; without `--host` (or with `--host '*'`) every public HTTPS destination is allowed. Schemes, ports, paths, IP addresses, and partial wildcards such as `*.example.com` are rejected. Use the service's API host, such as `api.openai.com`, never a guess from the credential's value.
116
- - Org commands default to the signed-in user's personal org. Use `--org` for a team, and select only Secrets from the box's org. Value, host and deletion changes require an org owner/admin sign-in. API keys with the appropriate machine scopes can inspect metadata and manage sandbox selections, but cannot retrieve or change Secret values.
119
+ - Org commands default to the signed-in user's personal org. Use `--org` for a team, and select only Secrets from the box's org. Value, host and deletion changes require the org owner/admin: a sign-in, or headless the workspace's own API key with `workspace:admin` in `INSTABOX_API_KEY` (org commands then default to the key's org; `secrets rm` needs `--yes` without a terminal). A personal org's key and keys with only machine scopes can inspect metadata and manage sandbox selections, but cannot change Secret values (`403 missing_scope`). No key or session can retrieve a value.
117
120
  - `secrets select BOX --secrets` replaces the box's selection: `all`, `none`, comma-separated names, or a JSON array (which allows a Secret literally named `all` or `none`). Keep existing names when adding one.
118
121
  - Placeholders work only in HTTPS request headers on port 443 to the allowed hosts. Apart from Basic auth in `Authorization`, which the proxy decodes and re-encodes, do not hash, encode, or sign with a placeholder; other traffic carries the placeholder unchanged.
119
122
  - Rotating a value keeps its placeholder, so running clients need no change. Newly selected names appear in new shells that load the managed environment; restart existing applications to pick them up.
@@ -121,6 +124,33 @@ instabox create --name tenant --no-vault --secrets none
121
124
  - For an existing box, disable Vault with `start BOX --no-vault` or `resume BOX --no-vault` and clear the Secret selection with `secrets select BOX --secrets none`. Refresh a running guest with both `vault push BOX` and `secrets push BOX`. Vault values already copied into processes or files remain there; disabling Vault does not recover them.
122
125
  - The Vault is legacy and will be retired; every `vault` command prints a warning. Its commands remain only for items a user already saved: `vault ls`, `show`, `push` and `rm`, and `vault export` to recover a retained item. A box may use Vault and Secrets together; Secrets wins same-named environment variables. Neither store copies or deletes the other. To move a Vault env item that is an HTTP API credential, the user enters its value again as a Secret with its hosts (`secrets set NAME --host HOST` reads it from a hidden prompt), you select it on the box, then `vault rm --yes` it with the user's go-ahead. Never read a Vault value to copy it. Database credentials and other non-HTTP secrets are not supported by Secrets and need their own setup on the box; Vault file items likewise.
123
126
 
127
+ ## Webhooks (lifecycle events instead of polling)
128
+
129
+ To be told when a box is ready, pauses, stops, fails or is deleted, register an HTTPS endpoint instead of polling `instabox get`. Each event is a signed POST (Standard Webhooks).
130
+
131
+ ```sh
132
+ instabox webhooks create https://example.com/hook --event sandbox.started --event sandbox.error # prints the signing secret once
133
+ instabox webhooks ls
134
+ instabox webhooks test wh_... # send a webhook.test event now
135
+ instabox webhooks deliveries wh_... # each attempt's status and error
136
+ instabox webhooks events # the event types
137
+ ```
138
+
139
+ - Events: `sandbox.created`, `sandbox.started` (running: ready for exec/SSH, after create, start, resume or wake), `sandbox.stopped`, `sandbox.suspended` (paused, `data.reason` says why: `user`, `idle`, ...), `sandbox.error`, `sandbox.deleted`, `snapshot.created`, `snapshot.failed`, `snapshot.deleted`. No `--event` subscribes to all.
140
+ - Verify each request: `webhook-signature` is `v1,<base64 HMAC-SHA256>` of `<webhook-id>.<webhook-timestamp>.<raw body>` keyed with the base64 part of the `whsec_...` secret; reject timestamps over 5 minutes old and dedupe on `webhook-id` (deliveries retry for about a day). Store the secret like any credential; `rotate` gives a new one (the old one signs for 24 hours).
141
+ - The URL must be public `https`. The org's owner or admin manages them: signed in, or headless with an API key of that org in `INSTABOX_API_KEY` (`machines:read` lists, `machines:write` changes, `machines:delete` removes; `--org` defaults to the key's org).
142
+
143
+ ## CI (GitHub Actions)
144
+
145
+ - A clean box for each run, or you are setting CI up for the user: a GitHub-hosted job runs the tests with `instabox run`, which creates a box, uploads the checkout, runs the command, deletes the box and exits with the command's code.
146
+ - Out of GitHub Actions minutes, or moving existing jobs over: keep one box as a self-hosted GitHub runner, and the workflows use `runs-on: [self-hosted, instabox]`.
147
+
148
+ ```sh
149
+ instabox run --push . --timeout 10m -e CI=1 -- 'npm ci && npm test' # in the job, with INSTABOX_API_KEY from a repository secret
150
+ ```
151
+
152
+ The workflow, the CI key, a saved snapshot to start from, the runner setup and the limits: https://instabox.sh/docs/ci.md
153
+
124
154
  ## Moving work onto a box
125
155
 
126
156
  To move the repo the user is working in (with its worktrees, uncommitted work and agent conversations) onto a new box and install its dependencies there, follow the `instabox-move-repo` skill.
@@ -44,7 +44,7 @@ instabox secrets ls --org "$ORG_ID" --json > "$W/secrets.json"
44
44
  instabox usage --json | jq '.limits.vault // {}' > "$W/limits.json"
45
45
  ```
46
46
 
47
- Saving Secret values requires an org owner/admin sign-in. An API key can read metadata but cannot import values. Do not change the user's authentication silently: if value management is rejected, explain that they need an owner/admin session. A 404 for Secret routes means Secret management is not configured on that server.
47
+ Saving Secret values requires the org owner/admin: a sign-in, or the workspace's own `workspace:admin` API key in `INSTABOX_API_KEY`. A personal org's key and narrower keys can read metadata but cannot import values (`403 missing_scope`). Without a terminal, `secrets rm` needs `--yes`. Do not change the user's authentication silently: if value management is rejected, explain that they need an owner/admin session or a workspace key. A 404 for Secret routes means Secret management is not configured on that server.
48
48
 
49
49
  Use `instabox get <box> --json` to resolve each intended sandbox to its stable `m_...` ID and confirm its `orgId`. No sandbox is required when the user only wants to save credentials. Config copying requires explicitly named running sandboxes; do not create a box merely to verify setup unless creation is authorized.
50
50
 
@@ -72,7 +72,7 @@ BOX=<box>
72
72
  instabox create --name "$BOX" --meta repo="$(basename "$DEST")"
73
73
  ```
74
74
 
75
- Box names are lowercase letters, digits and `-`. The box gets the default size, 4 vCPU / 8 GB (the instabox skill's Sizes lists the others a plan offers). The default template (`agent-browser`, shown as standard in the dashboard) has git, Node.js 22, Python (uv), Go, Rust, a C toolchain, Docker and a desktop with Chromium. This box is meant to stay; it pauses itself after 30 idle minutes. The `repo=` label can match a user's existing Vault selectors (the Vault is legacy and will be retired; never save credentials there); it does not select org Secrets. Without `--secrets` the box still gets such existing Vault items. Opt into org Secrets explicitly with `--secrets NAME,NAME`. Use `--no-vault --secrets none` when neither store should deliver credentials.
75
+ Box names are lowercase letters, digits and `-`. The box gets the default size, 4 vCPU / 8 GB (the instabox skill's Sizes lists the others a plan offers). The default template (`agent-browser`, shown as standard in the dashboard) has git, Node.js 22 (npm, pnpm, Yarn), Bun, Python (uv), Go, Rust, a C toolchain, Docker and a desktop with Chromium. This box is meant to stay; it pauses itself after 30 idle minutes. The `repo=` label can match a user's existing Vault selectors (the Vault is legacy and will be retired; never save credentials there); it does not select org Secrets. Without `--secrets` the box still gets such existing Vault items. Opt into org Secrets explicitly with `--secrets NAME,NAME`. Use `--no-vault --secrets none` when neither store should deliver credentials.
76
76
 
77
77
  ## 3. Copy the repo and its worktrees
78
78