instabox 0.5.2 → 0.5.3

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
@@ -5,7 +5,7 @@ npx instabox@latest # sign in, install the CLI and the agent skills
5
5
  npx instabox@latest sh # the same, then start a new box and SSH into it
6
6
  ```
7
7
 
8
- `npx instabox@latest` signs you in to instabox in your browser (first run only; confirm its current Instabox account or choose another), installs the `instabox` CLI already signed in or updates an out-of-date one (macOS/Linux; skip with `--no-cli`), installs or updates the agent skills from [`../skills`](../skills) in `~/.claude/skills` and `~/.agents/skills` (skip with `--no-skills`), and prints the next steps. It does not touch your boxes.
8
+ `npx instabox@latest` signs you in to instabox in your browser (first run only; confirm its current Instabox account or choose another), installs the `instabox` CLI already signed in or updates an out-of-date one (skip with `--no-cli`), installs or updates the agent skills from [`../skills`](../skills) in `~/.claude/skills` and `~/.agents/skills` (skip with `--no-skills`), and prints the next steps. It does not touch your boxes.
9
9
 
10
10
  `npx instabox@latest sh` does all that, then starts a new box (`box-<hex>`) and drops you into it over SSH, no questions. Run by an agent (no terminal), it starts the box and prints `instabox ssh <box> -- <command>` instead of opening a shell. At the box limit it starts nothing and lists your boxes, to remove one (`instabox rm <box>`) or open one (`instabox ssh <box>`).
11
11
 
@@ -19,6 +19,7 @@ Any other command line is the instabox CLI's ([`cmd/instabox`](../cmd/instabox))
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
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.
21
21
  - 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 `SHA256SUMS` 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.
22
23
  - 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.
23
24
  - `prepack` copies `../skills` into the package; every skill there ships on the next publish.
24
25
 
package/bin/instabox.mjs CHANGED
@@ -20,8 +20,10 @@ import { join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { createClient } from '../lib/api.mjs';
22
22
  import { orderBoxes } from '../lib/boxes.mjs';
23
+ import { guestUserNote, localGuestUser } from '../lib/guestuser.mjs';
23
24
  import { checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
24
- import { callbackHandler } from '../lib/login.mjs';
25
+ import { browserCommand, callbackHandler } from '../lib/login.mjs';
26
+ import { sessionArgs } from '../lib/ssh.mjs';
25
27
  import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
26
28
 
27
29
  // Same variables, same order as the instabox CLI, so both talk to one server.
@@ -66,12 +68,7 @@ function usage() {
66
68
  const client = createClient(API);
67
69
 
68
70
  function openBrowser(url) {
69
- const cmd =
70
- process.platform === 'darwin'
71
- ? ['open', [url]]
72
- : process.platform === 'win32'
73
- ? ['cmd', ['/c', 'start', '', url]]
74
- : ['xdg-open', [url]];
71
+ const cmd = browserCommand(url);
75
72
  try {
76
73
  spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true })
77
74
  .on('error', () => {})
@@ -253,16 +250,18 @@ async function newBox() {
253
250
  };
254
251
  if (img) body.imageId = img.id;
255
252
  else body.templateId = IMAGE;
253
+ // The box's Linux user is named after yours, so `ssh <box>` over your own network works. A
254
+ // box from one of your own snapshots keeps the snapshot's user.
255
+ const user = localGuestUser();
256
+ if (user.name && (!img || img.official)) body.guestUser = user.name;
256
257
  const op = await waitOp(await client.call('POST', '/v1/machines', body));
257
- return { id: op.machineId, name: body.name };
258
+ return { id: op.machineId, name: body.name, user };
258
259
  }
259
260
 
260
261
  // Runs while the box starts; never fatal. Returns a line to print, or null.
261
262
  async function ensureCli() {
262
263
  if (process.argv.includes('--no-cli')) return null;
263
264
  const found = findCli();
264
- // The installer is a sh script: on Windows an existing CLI is only reported, never checked.
265
- if (found && process.platform === 'win32') return `instabox CLI already installed ${dim('· try')} instabox ls`;
266
265
  if (found) {
267
266
  const { state, version } = await checkCli(found, API);
268
267
  const v = version ? ` ${version}` : '';
@@ -285,7 +284,6 @@ async function ensureCli() {
285
284
  return `instabox CLI${v} is out of date and could not be updated ${dim(`(${r.error}) · to update:`)} ${installCommand(API)}`;
286
285
  return `instabox CLI updated${v ? ` from${v}` : ''}${r.version ? ` to ${r.version}` : ''} at ${found} ${dim('· try')} instabox ls`;
287
286
  }
288
- if (process.platform === 'win32') return null;
289
287
  const r = await installCli(API);
290
288
  if (r.error) return `instabox CLI not installed ${dim(`(${r.error})`)}`;
291
289
  return (
@@ -433,6 +431,9 @@ async function onboard(wantBox) {
433
431
  const ready = `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
434
432
  if (!tty) {
435
433
  console.log(`\n ${green('✓')} box ${bold(box.name)} ${ready}`);
434
+ const got = await client.call('GET', '/v1/machines/' + enc(box.id)).catch(() => null);
435
+ const note = guestUserNote(box.user, box.name, got?.guestUser);
436
+ if (note) console.log(dim(` ${note}`));
436
437
  await reportInstalls(cli);
437
438
  return nextSteps([
438
439
  [`instabox ssh ${box.name} -- <command>`, 'run a command in it'],
@@ -466,6 +467,8 @@ async function onboard(wantBox) {
466
467
 
467
468
  const pauses = m.timeoutAt ? dim(` · pauses itself in ${Math.max(1, Math.round(ttlSeconds / 60))} min`) : '';
468
469
  process.stdout.write(`\r ${green('✓')} box ${bold(m.name)} ${ready}${pauses}\n`);
470
+ const note = guestUserNote(box.user, m.name, m.guestUser);
471
+ if (note) console.log(dim(` ${note}`));
469
472
  await reportInstalls(cli);
470
473
  console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
471
474
  // The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
@@ -473,24 +476,7 @@ async function onboard(wantBox) {
473
476
  console.log(dim(` your terminal is ${process.stdout.columns} columns wide; widen it for a readable prompt`));
474
477
  console.log();
475
478
 
476
- const args = [
477
- '-i',
478
- key,
479
- '-o',
480
- `CertificateFile=${key}-cert.pub`,
481
- '-o',
482
- `UserKnownHostsFile=${join(dir, 'known_hosts')}`,
483
- '-o',
484
- 'IdentitiesOnly=yes',
485
- '-o',
486
- 'StrictHostKeyChecking=yes',
487
- '-o',
488
- 'LogLevel=ERROR',
489
- '-p',
490
- String(c.port),
491
- '-t',
492
- `${c.username}@${c.host}`,
493
- ];
479
+ const args = sessionArgs({ dir, key, port: c.port, username: c.username, host: c.host });
494
480
  const child = spawn('ssh', args, { stdio: 'inherit' });
495
481
  child.on('exit', (code) => {
496
482
  console.log(`\n ${dim(`left ${m.name}. Back in:`)} instabox ssh ${m.name}\n`);
@@ -505,7 +491,6 @@ else if (isOnboarding(args)) await onboard(args.includes('sh'));
505
491
  else {
506
492
  let path = findCli();
507
493
  if (!path) {
508
- if (process.platform === 'win32') die('the instabox CLI runs on macOS and Linux only');
509
494
  console.error(dim(' installing the instabox CLI…'));
510
495
  const r = await installCli(API);
511
496
  if (r.error) die(`could not install the instabox CLI: ${r.error}`);
package/lib/cli.mjs CHANGED
@@ -1,9 +1,9 @@
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, realpathSync, statSync } from 'node:fs';
4
+ import { createReadStream, mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
- import { delimiter, join, sep } from 'node:path';
6
+ import { join, sep } from 'node:path';
7
7
 
8
8
  const OUR_FLAGS = new Set(['--github', '--google', '--no-cli', '--no-skills']);
9
9
 
@@ -20,29 +20,36 @@ export const real = (p) => {
20
20
  }
21
21
  };
22
22
 
23
- function runnable(p) {
23
+ // Windows has no execute bit: any instabox.exe file runs.
24
+ function runnable(p, platform) {
24
25
  try {
25
26
  const s = statSync(p);
26
- return s.isFile() && (s.mode & 0o111) !== 0;
27
+ return s.isFile() && (platform === 'win32' || (s.mode & 0o111) !== 0);
27
28
  } catch {
28
29
  return false;
29
30
  }
30
31
  }
31
32
 
33
+ // Where installCli puts the CLI on Windows, which has no /install.sh.
34
+ export function windowsInstallDir(env = process.env) {
35
+ const local = env.LOCALAPPDATA || join(env.USERPROFILE || homedir(), 'AppData', 'Local');
36
+ return join(local, 'Programs', 'instabox');
37
+ }
38
+
32
39
  // Skips node_modules dirs and links back to us, like insta's findDurableOnPath.
33
- export function findCli({ env = process.env, self = process.argv[1] } = {}) {
40
+ export function findCli({ env = process.env, self = process.argv[1], platform = process.platform } = {}) {
34
41
  const me = real(self);
42
+ const win = platform === 'win32';
35
43
  const home = env.HOME || homedir();
36
44
  const dirs = [
37
- ...(env.PATH || '').split(delimiter),
45
+ ...(env.PATH || '').split(win ? ';' : ':'),
38
46
  env.INSTABOX_INSTALL_DIR,
39
- '/usr/local/bin',
40
- join(home, '.local', 'bin'),
47
+ ...(win ? [windowsInstallDir(env)] : ['/usr/local/bin', join(home, '.local', 'bin')]),
41
48
  ];
42
49
  for (const dir of dirs) {
43
50
  if (!dir || dir.toLowerCase().includes('node_modules')) continue;
44
- const p = join(dir, 'instabox');
45
- if (!runnable(p)) continue;
51
+ const p = join(dir, win ? 'instabox.exe' : 'instabox');
52
+ if (!runnable(p, platform)) continue;
46
53
  const r = real(p);
47
54
  if (r === me || r.includes(`${sep}node_modules${sep}`)) continue;
48
55
  return p;
@@ -55,8 +62,12 @@ const INSTALL = 's=$(curl -fsSL "$1/install.sh") && printf \'%s\\n\' "$s" | sh';
55
62
 
56
63
  // The command a person runs to install or update the CLI themselves; with dir, one that puts it
57
64
  // in that directory (for a directory they cannot write to without sudo).
58
- export const installCommand = (api, dir) =>
59
- dir ? `curl -fsSL ${api}/install.sh | sudo env INSTABOX_INSTALL_DIR=${dir} sh` : `curl -fsSL ${api}/install.sh | sh`;
65
+ export const installCommand = (api, dir, platform = process.platform) =>
66
+ platform === 'win32'
67
+ ? 'npx instabox@latest'
68
+ : dir
69
+ ? `curl -fsSL ${api}/install.sh | sudo env INSTABOX_INSTALL_DIR=${dir} sh`
70
+ : `curl -fsSL ${api}/install.sh | sh`;
60
71
 
61
72
  // What `instabox --version` prints after "instabox ", or null when it does not run. Async, so a
62
73
  // slow or hung binary does not hold up the rest of onboarding.
@@ -115,7 +126,8 @@ export async function checkCli(path, api, { fetch = globalThis.fetch } = {}) {
115
126
 
116
127
  // path is null, never a bare `instabox` that could resolve back to this script. dir replaces the
117
128
  // CLI in that directory (INSTABOX_INSTALL_DIR), so an update lands where the old one is found.
118
- export function installCli(api, { dir } = {}) {
129
+ export function installCli(api, { dir, platform = process.platform, ...win } = {}) {
130
+ if (platform === 'win32') return installWindows(api, { dir, ...win });
119
131
  return new Promise((resolve) => {
120
132
  let out = '';
121
133
  const env = dir ? { ...process.env, INSTABOX_INSTALL_DIR: dir } : process.env;
@@ -148,3 +160,51 @@ export function runCli(path, args) {
148
160
  }
149
161
  process.exit(r.status ?? 1);
150
162
  }
163
+
164
+ const WINDOWS_ARCH = { x64: 'amd64', arm64: 'arm64' };
165
+
166
+ // Keep its checks in step with /install.sh's, which Windows cannot run.
167
+ async function installWindows(
168
+ api,
169
+ { dir, env = process.env, arch = process.arch, fetch = globalThis.fetch, version = cliVersion } = {},
170
+ ) {
171
+ if (!WINDOWS_ARCH[arch]) return { error: `no instabox CLI for Windows on ${arch} (x64 and arm64 only)` };
172
+ const name = `instabox-windows-${WINDOWS_ARCH[arch]}.exe`;
173
+ const to = dir || env.INSTABOX_INSTALL_DIR || windowsInstallDir(env);
174
+ const tmp = join(to, `.instabox.install.${process.pid}.exe`);
175
+ try {
176
+ const get = async (file) => {
177
+ const res = await fetch(`${api}/dl/${file}`, { signal: AbortSignal.timeout(300_000) });
178
+ if (!res.ok) throw new Error(`download ${file}: HTTP ${res.status}`);
179
+ return Buffer.from(await res.arrayBuffer());
180
+ };
181
+ const [body, sums] = await Promise.all([get(name), get('SHA256SUMS')]);
182
+ const want = String(sums)
183
+ .split('\n')
184
+ .map((l) => l.trim().split(/\s+/))
185
+ .find(([, n]) => n === name || n === `*${name}`)?.[0];
186
+ if (!/^[0-9a-f]{64}$/.test(want || '')) return { error: `no valid checksum for ${name} in SHA256SUMS` };
187
+ const got = createHash('sha256').update(body).digest('hex');
188
+ if (got !== want) return { error: `checksum mismatch for ${name}; run it again if a deploy was in progress` };
189
+ mkdirSync(to, { recursive: true });
190
+ writeFileSync(tmp, body, { mode: 0o755 });
191
+ const v = await version(tmp);
192
+ if (!v) {
193
+ rmSync(tmp, { force: true });
194
+ return { error: 'the downloaded instabox does not run (instabox --version failed)' };
195
+ }
196
+ const path = join(to, 'instabox.exe');
197
+ renameSync(tmp, path);
198
+ const onPath = (env.PATH || '').split(';').some((d) => d.replace(/[\\/]+$/, '').toLowerCase() === to.toLowerCase());
199
+ return {
200
+ path,
201
+ version: v,
202
+ pathHint: onPath
203
+ ? null
204
+ : `[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path', 'User') + ';${to.replaceAll("'", "''")}', 'User')`,
205
+ };
206
+ } catch (e) {
207
+ rmSync(tmp, { force: true });
208
+ return { error: e.message };
209
+ }
210
+ }
@@ -0,0 +1,59 @@
1
+ // The box's Linux user (docs/guest-user-plan.md): the person's local username, normalized the
2
+ // way the server and the instabox CLI do (pkg/instaboxapi/guestuser.go; the shared test vectors
3
+ // keep them equal). A name that is not valid after normalization is not sent: the box gets `user`.
4
+ import { userInfo } from 'node:os';
5
+
6
+ export const DEFAULT_GUEST_USER = 'user';
7
+
8
+ const PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
9
+
10
+ // root, instabox and the users and groups of the Debian base rootfs; names starting with
11
+ // `systemd-` too. Nothing else (pkg/instaboxapi/guestuser.go): `ubuntu`, `admin`, `docker`, … are
12
+ // allowed, and the guest falls back to `user` when its rootfs already has the name.
13
+ const RESERVED = new Set(
14
+ `root daemon bin sys sync games man lp mail news uucp proxy www-data backup list irc
15
+ _gnats gnats nobody nogroup _apt messagebus sshd polkitd tss
16
+ sudo adm staff users tty disk shadow utmp operator dialout audio video plugdev netdev
17
+ input render kvm instabox`.split(/\s+/),
18
+ );
19
+
20
+ export function validGuestUser(name) {
21
+ return PATTERN.test(name) && !RESERVED.has(name) && !name.startsWith('systemd-');
22
+ }
23
+
24
+ // The guest user for a local username: trimmed, without a `DOMAIN\` prefix or an `@domain`
25
+ // suffix, lowercased, spaces made `_`. null when that is not a valid guest user.
26
+ export function normalizeGuestUser(local) {
27
+ let s = String(local ?? '').trim();
28
+ const b = s.lastIndexOf('\\');
29
+ if (b >= 0) s = s.slice(b + 1);
30
+ const a = s.lastIndexOf('@');
31
+ if (a >= 0) s = s.slice(0, a);
32
+ s = s.trim().toLowerCase().replaceAll(' ', '_');
33
+ return validGuestUser(s) ? s : null;
34
+ }
35
+
36
+ // This computer's username and its guest user: { local, name } (name null when it cannot be
37
+ // one); local is '' when the OS does not say.
38
+ export function localGuestUser() {
39
+ let local = '';
40
+ try {
41
+ local = userInfo().username || '';
42
+ } catch {}
43
+ if (!local) local = process.env.USER || process.env.USERNAME || '';
44
+ return { local, name: local ? normalizeGuestUser(local) : null };
45
+ }
46
+
47
+ // The user of boxes from templates that predate per-box users; nothing to say about it.
48
+ export const LEGACY_GUEST_USER = 'dev';
49
+
50
+ // The one line to print about the box's user after a create, or null when there is nothing to
51
+ // say: plain `ssh <box>` over your own network sends the local name, which matches only when
52
+ // normalization left it as it was. actual is the box's guestUser as the server reports it.
53
+ export function guestUserNote({ local, name }, box, actual) {
54
+ if (!local || actual === LEGACY_GUEST_USER) return null;
55
+ const user = actual || name || DEFAULT_GUEST_USER;
56
+ if (!name) return `your username ${local} cannot be a Linux user name; the box's user is ${user}`;
57
+ if (name === local && (!actual || actual === name)) return null;
58
+ return `user ${user} (ssh ${user}@${box} over your own network; instabox ssh ${box} as usual)`;
59
+ }
package/lib/login.mjs CHANGED
@@ -36,3 +36,10 @@ export function callbackHandler({ state, exchange, finish, page }) {
36
36
  finish(result);
37
37
  };
38
38
  }
39
+
40
+ // The command that opens url in the browser. On Windows never `cmd /c start`: cmd ends the URL at its first &.
41
+ export function browserCommand(url, platform = process.platform) {
42
+ if (platform === 'darwin') return ['open', [url]];
43
+ if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]];
44
+ return ['xdg-open', [url]];
45
+ }
package/lib/ssh.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // ssh's argv for a session with this run's throwaway key, certificate and known_hosts.
2
+ import { join } from 'node:path';
3
+
4
+ // ssh splits an -o value at whitespace and reads ' as an open quote; Windows profile paths often have either.
5
+ export const sshConfigArg = (path) => (/[ \t']/.test(path) ? `"${path}"` : path);
6
+
7
+ export function sessionArgs({ dir, key, port, username, host }) {
8
+ return [
9
+ '-i',
10
+ key,
11
+ '-o',
12
+ `CertificateFile=${sshConfigArg(`${key}-cert.pub`)}`,
13
+ '-o',
14
+ `UserKnownHostsFile=${sshConfigArg(join(dir, 'known_hosts'))}`,
15
+ '-o',
16
+ 'IdentitiesOnly=yes',
17
+ '-o',
18
+ 'StrictHostKeyChecking=yes',
19
+ '-o',
20
+ 'LogLevel=ERROR',
21
+ '-p',
22
+ String(port),
23
+ '-t',
24
+ `${username}@${host}`,
25
+ ];
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instabox",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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": {
@@ -7,7 +7,7 @@ metadata:
7
7
 
8
8
  # instabox CLI
9
9
 
10
- An instabox sandbox is a Linux VM (Debian, user `dev`, sudo). It has a root disk and a persistent `/data` disk; HOME is `/data/home`. Boxes are private to the signed-in account.
10
+ An instabox sandbox is a Linux VM (Debian, one user with passwordless sudo: named after your local username when created with `instabox create`, `user` from the dashboard, `dev` on older boxes; `instabox get <box>` shows it). It has a root disk and a persistent `/data` disk; HOME is `/data/home`. Boxes are private to the signed-in account.
11
11
 
12
12
  ## Sign in
13
13
 
@@ -38,7 +38,7 @@ instabox create --name work # new box; waits until it is run
38
38
  instabox ls # your boxes and their states
39
39
  instabox ssh work # interactive shell (leave with: exit)
40
40
  instabox exec work -- uname -a # one command, no SSH client needed
41
- instabox exec work --cwd /data/home -- ls -la # exec runs `bash -lc` as dev
41
+ instabox exec work --cwd /data/home -- ls -la # exec runs `bash -lc` as the box's user
42
42
  instabox files work /data/home # list a directory (or print a text file)
43
43
  instabox auto-suspend work 10m # pause after 10 idle minutes (default 30m; 0: never)
44
44
  instabox timeout work 2h --on-timeout pause # pause (keeps memory) or kill at the deadline
@@ -49,7 +49,7 @@ instabox rm work # delete the box
49
49
 
50
50
  - Names or IDs (`m_...`) both work wherever `<id-or-name>` is accepted.
51
51
  - `create` options: `--template T` or `--snapshot S`, `--auto-suspend D|0`, `--timeout D [--on-timeout pause|kill]`, `--meta K=V`, `--no-vault`, `--no-wait`. Every box is the same size (`instabox usage` shows it); there are no size flags.
52
- - A new box pauses itself after 30 idle minutes (no agent working, no terminal use, little CPU) and wakes on the next `exec`, `ssh` or `files`; a paused box keeps its memory and processes. `--auto-suspend 0` keeps a box always on (a server that must keep answering); `--auto-suspend 2h` changes the idle time.
52
+ - A new box pauses itself after 30 idle minutes (no agent working, no terminal use, little CPU) and wakes on the next `exec`, `ssh`, `files`, public port request or `instabox wake <box>`; a paused box keeps its memory and processes. `--auto-suspend 0` keeps a box always on (a server that must keep answering); `--auto-suspend 2h` changes the idle time.
53
53
  - Use `exec` to run a command and read its output, and `ssh` for an interactive shell.
54
54
  - `exec` returns the command's exit code and takes `--stdin` to pipe input. It stops the command after `--timeout` seconds: 60 by default, 600 at most. Run anything longer (installs, builds, test suites) over SSH, which has no time limit and also returns the exit code: `instabox ssh work -- 'cd /data/workspace/app && npm ci'`.
55
55
  - The box's HOME has `MACHINE.md` (which box it is, its size, what survives a stop, the installed tools) and `AGENTS.md`. Both are worth reading before working in the box; they appear a moment after it starts.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: instabox-snapshots
3
- description: Use when the user wants to save an instabox box's state and load it later — "save a snapshot", "checkpoint this box", "restore/load from a snapshot", "start a new box from my setup", "clone this box". Covers instabox snapshot save/ls/files/rm, create --snapshot and fork.
3
+ description: Use when the user wants to save an instabox box's state and load it later — "save a snapshot", "checkpoint this box", "restore/load from a snapshot", "start a new box from my setup", "clone this box". Covers instabox snapshot save/ls/files/rm and create --snapshot.
4
4
  metadata:
5
5
  installed-by: instabox
6
6
  ---
@@ -40,13 +40,17 @@ instabox snapshot files work-ready /etc/os-release # print a text file
40
40
 
41
41
  This reads the snapshot's root disk only; `/data` (and HOME) are not browsable here. To check files under `/data`, load the snapshot into a box and use `instabox exec`.
42
42
 
43
- ## Copy a box directly
43
+ ## Copy a box
44
+
45
+ There is no direct copy: save a snapshot of it, create the new box from that, and delete the snapshot if you will not use it again.
44
46
 
45
47
  ```sh
46
- instabox fork work work-copy # copies a stopped box's root and /data; no snapshot kept
48
+ instabox snapshot save work --name work-copy
49
+ instabox create --name work2 --snapshot work-copy
50
+ instabox snapshot rm work-copy # optional; work2 is not affected
47
51
  ```
48
52
 
49
- Use `fork` for a one-off copy and `snapshot save` when you will start from the same state again.
53
+ The copy does not inherit `--no-vault`. If the box was created with `--no-vault`, pass it again: `instabox create --name work2 --snapshot work-copy --no-vault`.
50
54
 
51
55
  ## Clean up
52
56