anon-pi 0.15.0 → 0.16.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/src/anon-pi.ts CHANGED
@@ -14,9 +14,10 @@
14
14
  // /projects (the projects root). `--mount <parent>` adds EXACTLY one more
15
15
  // mount at the DISTINCT /work and re-roots cwd there; nothing else changes,
16
16
  // so we never remount a running container.
17
- // - Throwaway (`--rm`) is the DEFAULT; `--keep` leaves the container kept so
18
- // its filesystem survives (found + resumed by netcage's `netcage.managed`
19
- // label via `netcage start`). The machine home persists either way.
17
+ // - Every launch is THROWAWAY (the container is always `--rm`): it is removed
18
+ // on exit. Durable state is EXPLICIT and image-based (snapshot a running
19
+ // container into a named image, then a machine pinned to it); the machine
20
+ // home persists regardless (it is a host mount). See docs/adr/0004.
20
21
  // - Open exactly ONE direct hole (--allow-direct <llm>) so pi can reach a
21
22
  // local model while ALL other egress stays forced through the socks5h proxy
22
23
  // (fail-closed; the proxy is REQUIRED and never guessed).
@@ -26,9 +27,9 @@
26
27
  //
27
28
  // This module holds every DECISION as a pure function (config load + precedence,
28
29
  // machine/project resolvers, name validation, the RunPlan argv, the menu
29
- // choice-list, project usage, the run-vs-start rule, models.json generation,
30
- // init's proxy detect/verify decisions). cli.ts owns only the impure edges (fs,
31
- // the interactive TUI, the netcage query, the spawn).
30
+ // choice-list, project usage, models.json generation, init's proxy detect/verify
31
+ // decisions). cli.ts owns only the impure edges (fs, the interactive TUI, the
32
+ // netcage query, the spawn).
32
33
 
33
34
  import {existsSync, readFileSync} from 'node:fs';
34
35
  import {homedir} from 'node:os';
@@ -427,16 +428,33 @@ export function resolveDeleteProject(args: {
427
428
  export const ROOT_TOKEN = '.';
428
429
 
429
430
  /**
430
- * Reserved names that a machine/project may NOT take (case-sensitive). Kept
431
- * DELIBERATELY minimal: only the two structural path tokens. `.` is the root
432
- * token (see ROOT_TOKEN); `..` is parent-traversal. Both are also rejected by
433
- * the leading-dot / `..` structural checks below, but are listed here so the
434
- * reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
435
- * CONTAINER path, not a name in this namespace, so it needs no reservation.
436
- */
437
- export const RESERVED_NAMES: readonly string[] = ['.', '..', 'pi'];
431
+ * Reserved names that a machine/project/image may NOT take (case-sensitive).
432
+ * `.` is the root token (see ROOT_TOKEN); `..` is parent-traversal (both are
433
+ * also rejected by the structural checks below, but listed here so the
434
+ * reserved-name concept is explicit). `pi` is the passthrough token. The
435
+ * SUBCOMMAND NOUN words (`machine`, `image`, `init`, `forward`, `ports`) are
436
+ * reserved too: each is dispatched BEFORE the launch grammar, so a folder so
437
+ * named would be UNREACHABLE by bare name (a latent trap). Reserving them makes
438
+ * validateName refuse such a name up front with a clear error, closing the
439
+ * trap. `--mount`'s `/work` is a CONTAINER path, not a name here, so it needs no
440
+ * reservation. The reservation is GLOBAL (validateName is the one validator);
441
+ * the menu tolerates a pre-existing folder now reserved by FILTERING it out via
442
+ * the try/catch isProjectName, so a now-reserved folder is skipped, not a crash.
443
+ */
444
+ export const RESERVED_NAMES: readonly string[] = [
445
+ '.',
446
+ '..',
447
+ 'pi',
448
+ 'machine',
449
+ 'image',
450
+ 'init',
451
+ 'forward',
452
+ 'ports',
453
+ ];
438
454
  // NOTE: `pi` is reserved so the `anon-pi pi <args…>` passthrough token
439
- // (PI_PASSTHROUGH_TOKEN) can never be shadowed by a project named `pi`.
455
+ // (PI_PASSTHROUGH_TOKEN) can never be shadowed by a project named `pi`; the
456
+ // subcommand nouns are reserved so a same-named folder is never an unreachable
457
+ // shadow of a dispatched verb.
440
458
 
441
459
  /** What a name names, for a clear validation error. */
442
460
  export type NameKind = 'machine' | 'project';
@@ -535,7 +553,7 @@ export function resolveCwd(kind: RootKind, token: string): string {
535
553
  * and files written under the home land in the machine's config home on the
536
554
  * host; a shell is the project-hopper, so `/projects` is the natural landing.
537
555
  * The machine home is one `cd ~` away for the rare case. `menu` never reaches
538
- * here (it is argv-less). Shared by resolveRunPlan + keptContainerKey so the run
556
+ * here (it is argv-less). Shared by resolveRunPlan + launchIdentityKey so the run
539
557
  * cwd and the container-identity key always agree.
540
558
  */
541
559
  export function launchCwd(
@@ -703,6 +721,32 @@ export function resolveLlm(args: {
703
721
  return nonEmpty(args.env.llmDirect) ?? nonEmpty(args.config?.llm);
704
722
  }
705
723
 
724
+ /**
725
+ * PURE: resolve the IMAGE a launch runs against, highest-priority first:
726
+ * a per-launch `-i`/`--image` override > the machine's pinned image
727
+ * (machine.json) > `ANON_PI_IMAGE` (the env fallback) > undefined (the CLL then
728
+ * errors). The `-i` override is STRICTLY EPHEMERAL: it selects the image for
729
+ * THIS launch only and is NEVER written back to machine.json (that persistent
730
+ * pin is `machine set-image` / `machine create --image`). No mismatch warning is
731
+ * ever emitted (ADR-0003 section 3: `-i` is explicit + ephemeral, so a warning
732
+ * carries no information the user lacks). Empty strings are treated as unset at
733
+ * every tier (nonEmpty), so a blank env/pin falls through cleanly.
734
+ */
735
+ export function resolveLaunchImage(args: {
736
+ /** The per-launch `-i`/`--image` override (ParsedLaunch.image), if given. */
737
+ override?: string;
738
+ /** The machine's pinned image (machine.json.image), if set. */
739
+ machineImage?: string;
740
+ /** The `ANON_PI_IMAGE` env fallback, if set. */
741
+ envImage?: string;
742
+ }): string | undefined {
743
+ return (
744
+ nonEmpty(args.override) ??
745
+ nonEmpty(args.machineImage) ??
746
+ nonEmpty(args.envImage)
747
+ );
748
+ }
749
+
706
750
  // --- The per-machine RunPlan resolver ----------------------------------------
707
751
  //
708
752
  // The heart of the machines+projects rework: given a resolved launch intent
@@ -771,12 +815,6 @@ export interface LaunchIntent {
771
815
  mountParent?: string;
772
816
  /** Extra args forwarded to `pi` (headless/one-shot). Ignored for shell. */
773
817
  piArgs?: string[];
774
- /**
775
- * `--keep`: omit `--rm` so the container is left KEPT (its filesystem
776
- * survives the apt-install/re-enter flow). Default (false) => `--rm`
777
- * (throwaway); the machine home persists regardless (it is a host mount).
778
- */
779
- keep?: boolean;
780
818
  /** The resolved socks5h proxy (REQUIRED; the resolver fails closed without it). */
781
819
  proxy: string;
782
820
  /** The resolved local-model direct target (REQUIRED: the one --allow-direct hole). */
@@ -837,9 +875,10 @@ export const DEFAULT_MACHINE = 'default';
837
875
  * project): the CLI runs the host-side menu. `pi`/`shell` carry the chosen
838
876
  * target. `project` is a validated project name, the `.` root token, or
839
877
  * undefined (menu / bare shell, which lands at the active root). `mountParent` is the `--mount` HOST parent
840
- * (a path, NOT a name-namespaced token). `keep` is `--keep` (default false =>
841
- * throwaway `--rm`). `piArgs` are the trailing tokens forwarded to pi (pi mode
842
- * only; undefined otherwise).
878
+ * (a path, NOT a name-namespaced token). `image` is the ephemeral per-launch
879
+ * `-i`/`--image` override (undefined when not given). Every launch is throwaway
880
+ * (`--rm` always; the retired `--keep`/`--rm` flags now error). `piArgs` are the
881
+ * trailing tokens forwarded to pi (pi mode only; undefined otherwise).
843
882
  */
844
883
  export interface ParsedLaunch {
845
884
  mode: LaunchMode;
@@ -852,7 +891,16 @@ export interface ParsedLaunch {
852
891
  machineExplicit: boolean;
853
892
  project?: string;
854
893
  mountParent?: string;
855
- keep: boolean;
894
+ /**
895
+ * The EPHEMERAL per-launch image override (`-i <ref>` / `--image <ref>`), or
896
+ * undefined. Highest priority in the image-resolution chain (see
897
+ * resolveLaunchImage): `-i` > machine.json.image > ANON_PI_IMAGE. It NEVER
898
+ * mutates machine.json (that persistent pin is `machine set-image` /
899
+ * `machine create --image`); `-i` picks the IMAGE for this launch while `-m`
900
+ * picks the HOME, and they compose. The ref is passed straight through to
901
+ * netcage's private image store: anon-pi does NOT pre-check it or auto-pull.
902
+ */
903
+ image?: string;
856
904
  piArgs?: string[];
857
905
  }
858
906
 
@@ -1004,6 +1052,28 @@ export function sessionHeaderCwd(headerLine: string): string | undefined {
1004
1052
  */
1005
1053
  export const PI_PASSTHROUGH_TOKEN = 'pi';
1006
1054
 
1055
+ /**
1056
+ * The retired launch flags. `--keep`/`--rm` are GONE (ADR-0004): every launch is
1057
+ * throwaway now, so there is no flag to toggle. `--keep`'s exploratory
1058
+ * "apt install, quit, re-enter" use case is served, better, by snapshotting a
1059
+ * running container into a named image and pinning a machine to it (explicit +
1060
+ * named, no inference). The label a launch is passed one of these RETIRED flags
1061
+ * gets a clear error pointing there.
1062
+ */
1063
+ export const RETIRED_LAUNCH_FLAGS = ['--keep', '--rm'] as const;
1064
+
1065
+ /** PURE: the error message for a retired `--keep`/`--rm` flag, pointing at the image-based replacement. */
1066
+ export function retiredKeepRmMessage(flag: string): string {
1067
+ return (
1068
+ `${flag} is gone: every launch is throwaway now (the container is always ` +
1069
+ `removed on exit). To persist system state you set up in a session (e.g. after ` +
1070
+ `\`apt install\`), snapshot the RUNNING container into a named image and use it:\n` +
1071
+ ` anon-pi image snapshot <name> (freeze the running container -> anon-pi/<name>:latest)\n` +
1072
+ ` anon-pi machine create <m> --image anon-pi/<name>:latest (a durable machine pinned to it)\n` +
1073
+ `Your pi config + conversations live in the machine home (a host mount) and persist regardless.`
1074
+ );
1075
+ }
1076
+
1007
1077
  /**
1008
1078
  * PURE: whether forwarded pi args request pi's NON-INTERACTIVE (print) mode,
1009
1079
  * i.e. contain `-p`/`--print`. This is the ONLY headless shape (it needs no
@@ -1027,17 +1097,11 @@ function finishPiNoProjectLaunch(args: {
1027
1097
  machine: string;
1028
1098
  machineExplicit: boolean;
1029
1099
  mountParent?: string;
1030
- keep: boolean;
1031
- rm: boolean;
1100
+ image?: string;
1032
1101
  shell: boolean;
1033
1102
  piArgs: string[];
1034
1103
  fail: (msg: string) => never;
1035
1104
  }): ParsedLaunch {
1036
- if (args.keep && args.rm) {
1037
- args.fail(
1038
- '--keep and --rm are contradictory (pick one; --rm is the default)',
1039
- );
1040
- }
1041
1105
  if (args.shell) {
1042
1106
  args.fail(
1043
1107
  '--shell forwards no pi args (a shell has no session/query). Drop --shell.',
@@ -1049,15 +1113,15 @@ function finishPiNoProjectLaunch(args: {
1049
1113
  machineExplicit: args.machineExplicit,
1050
1114
  project: undefined,
1051
1115
  mountParent: args.mountParent,
1052
- keep: args.keep,
1116
+ image: args.image,
1053
1117
  piArgs: args.piArgs,
1054
1118
  };
1055
1119
  }
1056
1120
 
1057
1121
  /**
1058
1122
  * PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
1059
- * (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
1060
- * project positional; the FIRST bare positional is the project (`.` allowed as
1123
+ * (`-m <machine>`, `--shell`, `--mount <parent>`, `-i`/`--image <ref>`) LEFT of
1124
+ * the project positional; the FIRST bare positional is the project (`.` allowed as
1061
1125
  * the root token). In pi mode every token AFTER the project is forwarded to pi
1062
1126
  * verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
1063
1127
  * the project. A pi session-resume flag (`--session <id>`, `--continue`,
@@ -1070,15 +1134,14 @@ function finishPiNoProjectLaunch(args: {
1070
1134
  * reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
1071
1135
  * distinct from the project-name namespace (NAME vs `--mount` exclusivity), so
1072
1136
  * it is NOT name-validated here. Throws AnonPiError for an unknown option, a
1073
- * missing `-m`/`--mount` argument, a contradictory `--keep --rm`, or a bad name.
1137
+ * missing `-m`/`--mount` argument, a RETIRED `--keep`/`--rm` flag, or a bad name.
1074
1138
  */
1075
1139
  export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1076
1140
  let machine = DEFAULT_MACHINE;
1077
1141
  let machineSet = false;
1078
1142
  let shell = false;
1079
1143
  let mountParent: string | undefined;
1080
- let keepSeen = false;
1081
- let rmSeen = false;
1144
+ let image: string | undefined;
1082
1145
  let project: string | undefined;
1083
1146
  let piArgs: string[] | undefined;
1084
1147
 
@@ -1106,13 +1169,20 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1106
1169
  mountParent = v as string;
1107
1170
  continue;
1108
1171
  }
1109
- if (a === '--keep') {
1110
- keepSeen = true;
1172
+ if (a === '-i' || a === '--image') {
1173
+ // The EPHEMERAL per-launch image override. A raw ref, NOT a
1174
+ // name-namespaced anon-pi token (it resolves in netcage's private
1175
+ // image store, so any podman ref / `anon-pi/<name>:latest` snapshot tag
1176
+ // is valid): not name-validated here. It never mutates machine.json.
1177
+ const v = args[++i];
1178
+ if (v === undefined) fail(`${a} needs an image ref`);
1179
+ image = v as string;
1111
1180
  continue;
1112
1181
  }
1113
- if (a === '--rm') {
1114
- rmSeen = true;
1115
- continue;
1182
+ if ((RETIRED_LAUNCH_FLAGS as readonly string[]).includes(a)) {
1183
+ // `--keep`/`--rm` are retired (ADR-0004): throwaway is the only
1184
+ // behaviour now. Point at the image-based replacement.
1185
+ fail(retiredKeepRmMessage(a));
1116
1186
  }
1117
1187
  if (a === '.') {
1118
1188
  // the root token is a valid project positional (not a name).
@@ -1129,8 +1199,7 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1129
1199
  machine,
1130
1200
  machineExplicit: machineSet,
1131
1201
  mountParent,
1132
- keep: keepSeen,
1133
- rm: rmSeen,
1202
+ image,
1134
1203
  shell,
1135
1204
  piArgs: args.slice(i + 1),
1136
1205
  fail,
@@ -1172,8 +1241,7 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1172
1241
  machine,
1173
1242
  machineExplicit: machineSet,
1174
1243
  mountParent,
1175
- keep: keepSeen,
1176
- rm: rmSeen,
1244
+ image,
1177
1245
  shell,
1178
1246
  piArgs,
1179
1247
  fail,
@@ -1188,10 +1256,6 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1188
1256
  break;
1189
1257
  }
1190
1258
 
1191
- if (keepSeen && rmSeen) {
1192
- fail('--keep and --rm are contradictory (pick one; --rm is the default)');
1193
- }
1194
-
1195
1259
  // tokens remaining after the project.
1196
1260
  const rest = args.slice(i);
1197
1261
  if (shell) {
@@ -1207,7 +1271,7 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1207
1271
  machineExplicit: machineSet,
1208
1272
  project,
1209
1273
  mountParent,
1210
- keep: keepSeen,
1274
+ image,
1211
1275
  };
1212
1276
  }
1213
1277
 
@@ -1220,7 +1284,7 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1220
1284
  machineExplicit: machineSet,
1221
1285
  project: undefined,
1222
1286
  mountParent,
1223
- keep: keepSeen,
1287
+ image,
1224
1288
  };
1225
1289
  }
1226
1290
 
@@ -1232,7 +1296,7 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1232
1296
  machineExplicit: machineSet,
1233
1297
  project,
1234
1298
  mountParent,
1235
- keep: keepSeen,
1299
+ image,
1236
1300
  piArgs,
1237
1301
  };
1238
1302
  }
@@ -1247,7 +1311,7 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1247
1311
  * - the two mounts <home>:/root and <projectsRoot>:/projects, always;
1248
1312
  * - --mount adds EXACTLY <parent>:/work and re-roots cwd, nothing else;
1249
1313
  * - --proxy <p> + exactly one --allow-direct <llm> (forced egress, fail-closed);
1250
- * - --rm by default, omitted only under --keep.
1314
+ * - --rm on EVERY launch (throwaway always; ADR-0004).
1251
1315
  *
1252
1316
  * Throws AnonPiError (a plan is NEVER produced) when the image, the machine
1253
1317
  * home, the proxy, or the direct-hole llm is missing.
@@ -1316,8 +1380,9 @@ export function resolveRunPlan(
1316
1380
  const headless = mode === 'pi' && isHeadlessPiArgs(intent.piArgs);
1317
1381
 
1318
1382
  const netcageArgs: string[] = ['run'];
1319
- // --rm by DEFAULT (throwaway); --keep leaves the container kept.
1320
- if (intent.keep !== true) netcageArgs.push('--rm');
1383
+ // Throwaway ALWAYS: every launch is `--rm` (ADR-0004). Durable state is
1384
+ // image-based (snapshot + a pinned machine), never an accreting container.
1385
+ netcageArgs.push('--rm');
1321
1386
  // Forced egress: the proxy + the ONE direct hole. Never omitted.
1322
1387
  netcageArgs.push('--proxy', proxy, '--allow-direct', directTarget);
1323
1388
  if (!headless) netcageArgs.push('-it');
@@ -1404,81 +1469,46 @@ function containerSeedThen(seedVersion: string, exec: string): string {
1404
1469
  );
1405
1470
  }
1406
1471
 
1407
- // --- The run-vs-start decision for kept (netcage.managed) containers ---------
1408
- //
1409
- // The exploratory `--keep` flow: run a container, tweak the system (apt install
1410
- // ...), quit, then re-enter with the SAME launch and RESUME it via `netcage
1411
- // start` (the container filesystem survives). Throwaway (`--rm`) is the default
1412
- // and is ALWAYS a fresh `run`.
1472
+ // --- The per-launch identity label (for `forward`/`ports`/`snapshot`) --------
1413
1473
  //
1414
- // This module owns only the PURE decision: given a resolved LaunchIntent and a
1415
- // SUPPLIED listing of kept containers, decide `start` (a matching kept container
1416
- // is present) vs `run` without `--rm` (absent). The netcage QUERY (how to ask
1417
- // netcage for its labelled containers, e.g. `netcage ps` filtered by the
1418
- // `netcage.managed` label) is the CLI's impure job; the pure rule receives its
1419
- // RESULT (the listing) so the decision stays unit-testable. anon-pi invents NO
1420
- // registry file: netcage's `netcage.managed` label IS the record.
1421
-
1422
- /**
1423
- * A kept `netcage.managed` container, as the CLI's netcage query surfaces it to
1424
- * the pure decision. Only the two fields the DECISION needs are typed:
1425
- * - `key`: the anon-pi launch-identity key (keptContainerKey) the CLI stamped
1426
- * onto the container at `run` time (a netcage label / container name) and
1427
- * reads back from the label; this is what a launch matches against.
1428
- * - `ref`: how to address the container for `netcage start` (its id or name).
1429
- * The CLI is free to carry more; the pure rule reads only these.
1430
- */
1431
- export interface KeptContainer {
1432
- /** The anon-pi launch-identity key stamped on the container (keptContainerKey). */
1433
- key: string;
1434
- /** The container ref (id or name) to pass to `netcage start`. */
1435
- ref: string;
1436
- }
1437
-
1438
- /**
1439
- * The run-vs-start decision. `run` = `netcage run` a fresh container (WITHOUT
1440
- * `--rm` under `--keep`, so it is left kept; the run argv itself is
1441
- * resolveRunPlan's job). `start` = `netcage start <ref>` an existing kept
1442
- * container whose identity matches this launch.
1443
- */
1444
- export type RunVsStart = {action: 'run'} | {action: 'start'; ref: string};
1445
-
1446
- /**
1447
- * PURE: the launch-identity match key for a kept container, derived ENTIRELY
1448
- * from the (machine, projects-root, project) identity (ADR-0002). It is what
1449
- * decides whether an existing kept `netcage.managed` container IS the one a
1450
- * `--keep` launch should resume.
1451
- *
1452
- * The fields, and why each is load-bearing:
1453
- * - `machine.name`: a kept container mounts THIS machine's home at /root; a
1454
- * same-project container on another machine is a different environment.
1455
- * - `projectsRoot`: the host dir mounted at /projects; two launches with the
1456
- * same project name but different roots are different working trees.
1457
- * - `mountParent` (or '' when absent): `--mount` re-roots into a DIFFERENT
1458
- * host parent at /work, so a `--mount` launch is a distinct identity from
1459
- * the projects-root launch of the same name.
1460
- * - the resolved container `cwd`: this already encodes the project token
1461
- * (`/projects/<p>`, `/work/<p>`, or a root `/projects`/`/work`; legacy kept
1462
- * containers may still carry /root from the pre-0.12 bare-shell-at-home)
1463
- * AND which root it sits under, so it is pi's conversation key too. Using
1464
- * the cwd keeps the container identity aligned with the conversation the
1465
- * kept container hosts.
1474
+ // anon-pi stamps an identity key onto EVERY launch's container (an additive
1475
+ // netcage label; see withKeyLabel in cli.ts). It is NOT a kept-container match
1476
+ // key (there are no kept containers: every launch is throwaway, ADR-0004). Its
1477
+ // sole job is to let `forward`/`ports`/`snapshot` find a RUNNING container and
1478
+ // read back which machine + project it hosts (parseKeptKey -> keyProject),
1479
+ // while the container is up (the label goes away with the `--rm` container on
1480
+ // exit). netcage's `netcage.managed` label marks the container managed; this
1481
+ // adds anon-pi's own identity on top. anon-pi invents NO registry file.
1482
+
1483
+ /**
1484
+ * PURE: the anon-pi launch-identity key stamped on EVERY (throwaway) launch,
1485
+ * derived from the (machine, projects-root, project) identity (ADR-0002's
1486
+ * cwd/project reasoning still underpins it). It is NOT a kept-container MATCH
1487
+ * key (every launch is throwaway; nothing is ever resumed). It exists ONLY so
1488
+ * `forward`/`ports`/`snapshot` can resolve a RUNNING container by machine +
1489
+ * project: the CLI stamps it onto a netcage label and reads it back with
1490
+ * parseKeptKey -> keyProject.
1466
1491
  *
1467
- * DELIBERATELY EXCLUDED (not part of identity): `--keep`/`--rm` (the throwaway
1468
- * choice for THIS run), the proxy + the direct-hole llm (forced-egress inputs),
1469
- * forwarded pi args, and the seed. Two launches that differ only in those must
1470
- * resolve to the SAME kept container.
1492
+ * The fields, and why each is retained:
1493
+ * - `machine.name`: the forward/ports filter scopes by machine.
1494
+ * - `cwd` (the resolved container cwd, via launchCwd): encodes the project
1495
+ * token (`/projects/<p>`, `/work/<p>`, or a root), so keyProject can name
1496
+ * the project a running container hosts.
1497
+ * - `projectsRoot` + `mountParent`: kept in the record for stability of the
1498
+ * decode shape (parseKeptKey reads them best-effort); no consumer filters on
1499
+ * them today, but they cost nothing and keep the label self-describing.
1471
1500
  *
1472
- * The key is a single opaque string (a `\n`-joined, field-tagged record) so the
1473
- * CLI can stamp it verbatim onto a netcage label and match on string equality;
1474
- * its internal shape is not a contract (compare only keys this function makes).
1501
+ * Independent of the forced-egress inputs and forwarded pi args (identity only).
1502
+ * The key is a single opaque string (a `\n`-joined, field-tagged record) the CLI
1503
+ * stamps verbatim onto a netcage label; its internal shape is not a contract
1504
+ * (decode only with parseKeptKey).
1475
1505
  */
1476
- export function keptContainerKey(intent: LaunchIntent): string {
1506
+ export function launchIdentityKey(intent: LaunchIntent): string {
1477
1507
  const {machine, mode, projectsRoot, project, mountParent} = intent;
1478
1508
  const mounted = nonEmpty(mountParent) !== undefined;
1479
1509
  const rootKind: RootKind = mounted ? 'mount' : 'projects';
1480
- // The same cwd resolution resolveRunPlan uses, so the key names the exact
1481
- // container a matching launch would run in (its conversation key).
1510
+ // The same cwd resolution resolveRunPlan uses, so keyProject names the exact
1511
+ // project the running container hosts (its conversation key).
1482
1512
  const cwd = launchCwd(mode, rootKind, project);
1483
1513
  return [
1484
1514
  `machine=${machine.name}`,
@@ -1488,32 +1518,6 @@ export function keptContainerKey(intent: LaunchIntent): string {
1488
1518
  ].join('\n');
1489
1519
  }
1490
1520
 
1491
- /**
1492
- * PURE: decide run-vs-start for a launch given a SUPPLIED listing of kept
1493
- * `netcage.managed` containers (the CLI's netcage query result).
1494
- *
1495
- * - `--rm` (throwaway, `intent.keep !== true`): ALWAYS a fresh `run`. The
1496
- * listing is NOT consulted (a throwaway launch never resumes a kept box).
1497
- * - `--keep`: a kept container whose `key` equals this launch's
1498
- * keptContainerKey is present -> `start` it (by its `ref`); else -> `run`
1499
- * (resolveRunPlan leaves it kept because `--keep` omits `--rm`).
1500
- *
1501
- * Never spawns, never queries netcage: the listing is injected, so the whole
1502
- * decision is a pure function of (intent, listing).
1503
- */
1504
- export function resolveRunVsStart(
1505
- intent: LaunchIntent,
1506
- kept: readonly KeptContainer[],
1507
- ): RunVsStart {
1508
- // Throwaway short-circuit: a `--rm` launch is always a fresh run and never
1509
- // consults the listing (it must not resume a kept container).
1510
- if (intent.keep !== true) return {action: 'run'};
1511
-
1512
- const want = keptContainerKey(intent);
1513
- const match = kept.find((c) => c.key === want);
1514
- return match ? {action: 'start', ref: match.ref} : {action: 'run'};
1515
- }
1516
-
1517
1521
  // --- `forward` / `ports`: reach an in-jail server from the host --------------
1518
1522
  //
1519
1523
  // netcage owns two host-access verbs (>= 0.9.0): `netcage forward <container>
@@ -1522,14 +1526,14 @@ export function resolveRunVsStart(
1522
1526
  // (it reads the sidecar's /proc/net/tcp*, so a minimal image with no ss/netstat/nc
1523
1527
  // still works). anon-pi wraps them so the user never handles the raw netcage
1524
1528
  // container name: it resolves the RUNNING anon-pi container(s) by the identity key
1525
- // it now stamps on EVERY launch (withKeyLabel, not just --keep), disambiguates with
1529
+ // it stamps on EVERY launch (withKeyLabel + launchIdentityKey), disambiguates with
1526
1530
  // a picker annotated by the open listeners, and shells out to `netcage forward`.
1527
1531
  // The forced-egress invariant is untouched: `forward` adds no OUTPUT rule (ADR-0014)
1528
1532
  // and `ports` only reads /proc; anon-pi composes neither egress flag here.
1529
1533
 
1530
1534
  /**
1531
- * PURE: the decoded fields of a stamped keptContainerKey (the reverse of
1532
- * keptContainerKey's `k=v\n` record). Used by `forward`/`ports` to filter the
1535
+ * PURE: the decoded fields of a stamped launchIdentityKey (the reverse of
1536
+ * launchIdentityKey's `k=v\n` record). Used by `forward`/`ports` to filter the
1533
1537
  * running managed containers by machine + project WITHOUT reconstructing the
1534
1538
  * exact key (which would couple to launchCwd). Unknown/missing fields are ''.
1535
1539
  */
@@ -1540,7 +1544,7 @@ export interface KeptKeyFields {
1540
1544
  cwd: string;
1541
1545
  }
1542
1546
 
1543
- /** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
1547
+ /** PURE: parse a stamped launchIdentityKey back into its fields (best-effort). */
1544
1548
  export function parseKeptKey(key: string): KeptKeyFields {
1545
1549
  const out: KeptKeyFields = {
1546
1550
  machine: '',
@@ -1604,7 +1608,7 @@ export function resolveManagedMatches(args: {
1604
1608
  * A RUNNING netcage-managed container the CLI surfaces to the pure forward/ports
1605
1609
  * resolution: its anon-pi identity `key` (stamped label, decoded), the `ref` to
1606
1610
  * pass to `netcage forward`/`ports` (id or name), and a human `name` for the
1607
- * picker. Mirrors KeptContainer with the display name added.
1611
+ * picker.
1608
1612
  */
1609
1613
  export interface ManagedContainer {
1610
1614
  key: string;
@@ -1821,7 +1825,7 @@ export interface NetcagePsEntry {
1821
1825
  * ref: <Id>, name: <first Names entry or Id>}. When `runningOnly`, entries whose
1822
1826
  * State is not "running" are dropped (forward/ports can only reach a live jail).
1823
1827
  * The base64 DECODE of `key` is the CLI's job (Buffer), so this stays pure; the
1824
- * caller decodes before matching against a keptContainerKey. [] on bad JSON.
1828
+ * caller decodes before matching against a launchIdentityKey. [] on bad JSON.
1825
1829
  */
1826
1830
  export function parseNetcagePsJson(
1827
1831
  stdout: string,
@@ -2034,7 +2038,7 @@ export function deriveProjectUsage(args: {
2034
2038
  }
2035
2039
 
2036
2040
  /**
2037
- * ONE session group a `machine snapshot` can carry over: a `sessions/<slug>/`
2041
+ * ONE session group a snapshot's `--create-machine` carry-over can offer: a `sessions/<slug>/`
2038
2042
  * dir in the source home. `project` is the project name when the slug matches a
2039
2043
  * known project's `projectSessionSlug` (else undefined: an ORPHAN slug with no
2040
2044
  * matching project, still offered, labelled by its raw slug so nothing hides).
@@ -2973,18 +2977,15 @@ export function anonPiVersion(): string | undefined {
2973
2977
  * - `set-image <name> <ref>`: name validated; the new image ref (non-empty).
2974
2978
  * - `rm <name> [--yes]`: name validated; `yes` skips the confirm (the CLI
2975
2979
  * still enforces the non-TTY abort when `yes` is false).
2976
- * - `snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: the sole
2977
- * positional is the NEW machine name (validated); `-m <machine>` is an
2978
- * OPTIONAL filter (which running container to commit when several are up),
2979
- * NOT a required source. The CLI auto-detects the running container (picker
2980
- * when several match), commits it, and creates <new-name> pinned to it.
2980
+ *
2981
+ * Snapshot moved OFF the `machine` noun to the `image` noun (ADR-0003): see
2982
+ * `parseImageArgs` / ImageCommand.
2981
2983
  */
2982
2984
  export type MachineCommand =
2983
2985
  | {verb: 'create'; name: string; image?: string}
2984
2986
  | {verb: 'list'}
2985
2987
  | {verb: 'set-image'; name: string; image: string}
2986
- | {verb: 'rm'; name: string; yes: boolean}
2987
- | {verb: 'snapshot'; name: string; machine?: string; imageTag?: string};
2988
+ | {verb: 'rm'; name: string; yes: boolean};
2988
2989
 
2989
2990
  /**
2990
2991
  * PURE: parse the tokens AFTER `machine` into a MachineCommand. Validates the
@@ -3006,9 +3007,7 @@ export function parseMachineArgs(args: readonly string[]): MachineCommand {
3006
3007
 
3007
3008
  const verb = args[0];
3008
3009
  if (verb === undefined) {
3009
- fail(
3010
- '`machine` needs a subcommand: create | list | set-image | rm | snapshot',
3011
- );
3010
+ fail('`machine` needs a subcommand: create | list | set-image | rm');
3012
3011
  }
3013
3012
 
3014
3013
  const rest = args.slice(1);
@@ -3080,15 +3079,67 @@ export function parseMachineArgs(args: readonly string[]): MachineCommand {
3080
3079
  return {verb: 'rm', name: name as string, yes};
3081
3080
  }
3082
3081
 
3082
+ return fail(
3083
+ `unknown machine subcommand: ${verb} (create | list | set-image | rm)`,
3084
+ );
3085
+ }
3086
+
3087
+ // --- the `image` noun (ADR-0003): snapshot a running container into a clean
3088
+ // image tag with provenance labels, and a read-only list of anon-pi images. The
3089
+ // grammar parse, the clean tag derivation, the provenance-label build, and the
3090
+ // label read-back parse are all PURE here; the CLI does the netcage commit /
3091
+ // images / inspect I/O.
3092
+
3093
+ /**
3094
+ * A parsed `image <verb> …` command (ADR-0003 §1). A discriminated union so the
3095
+ * CLI dispatches on `verb` with already-validated fields:
3096
+ * - `snapshot <name> [-m <machine>] [--create-machine <m>]`: commit the
3097
+ * RUNNING container into `anon-pi/<name>:latest`. `name` is a validated
3098
+ * image name (a safe tag segment). `-m <machine>` is an OPTIONAL filter
3099
+ * (which running container to commit when several are up), NOT a required
3100
+ * source. `--create-machine <m>` ALSO creates machine <m> from the fresh
3101
+ * snapshot (running the home-copy + session carry-over).
3102
+ * - `list`: no args (read-only; zero stored state).
3103
+ */
3104
+ export type ImageCommand =
3105
+ | {verb: 'snapshot'; name: string; machine?: string; createMachine?: string}
3106
+ | {verb: 'list'};
3107
+
3108
+ /**
3109
+ * PURE: parse the tokens AFTER `image` into an ImageCommand. Validates the image
3110
+ * name + the `-m` / `--create-machine` machine names via validateName (the
3111
+ * reserved-name / traversal guard), so the CLI only ever joins safe segments.
3112
+ * Throws AnonPiError (printed verbatim, exit 1) for an unknown/missing verb, a
3113
+ * missing or extra positional, an unknown flag, or a bad name.
3114
+ *
3115
+ * `<name>` is validated with the `machine` kind: it shares the same
3116
+ * folder-safe / reserved-name rules, and a snapshot name is an image-tag
3117
+ * segment (`anon-pi/<name>:latest`), so the same guard applies.
3118
+ */
3119
+ export function parseImageArgs(args: readonly string[]): ImageCommand {
3120
+ const fail = (msg: string): never => {
3121
+ throw new AnonPiError(
3122
+ `anon-pi: ${msg}\nRun \`anon-pi image --help\` or \`anon-pi --help\`.`,
3123
+ );
3124
+ };
3125
+
3126
+ const verb = args[0];
3127
+ if (verb === undefined) {
3128
+ fail('`image` needs a subcommand: snapshot | list');
3129
+ }
3130
+
3131
+ const rest = args.slice(1);
3132
+
3133
+ if (verb === 'list') {
3134
+ if (rest.length > 0)
3135
+ fail(`image list takes no arguments, got: ${rest.join(' ')}`);
3136
+ return {verb: 'list'};
3137
+ }
3138
+
3083
3139
  if (verb === 'snapshot') {
3084
- // snapshot <new-name> [-m <machine>] [--image-tag <ref>]: commit a RUNNING
3085
- // container into a new image and create <new-name> pinned to it. The sole
3086
- // positional is the new machine name; `-m` is an OPTIONAL filter (which
3087
- // container when several are up), not a required source. The CLI auto-detects
3088
- // the container (picker when several match).
3089
3140
  let name: string | undefined;
3090
3141
  let machine: string | undefined;
3091
- let imageTag: string | undefined;
3142
+ let createMachine: string | undefined;
3092
3143
  for (let i = 0; i < rest.length; i++) {
3093
3144
  const a = rest[i];
3094
3145
  if (a === '-m' || a === '--machine') {
@@ -3097,43 +3148,95 @@ export function parseMachineArgs(args: readonly string[]): MachineCommand {
3097
3148
  machine = validateName(v as string, 'machine');
3098
3149
  continue;
3099
3150
  }
3100
- if (a === '--image-tag') {
3151
+ if (a === '--create-machine') {
3101
3152
  const v = rest[++i];
3102
- if (v === undefined) fail('--image-tag needs an image ref');
3103
- imageTag = v as string;
3153
+ if (v === undefined) fail('--create-machine needs a machine name');
3154
+ createMachine = validateName(v as string, 'machine');
3104
3155
  continue;
3105
3156
  }
3106
3157
  if (a.startsWith('-')) fail(`unknown option: ${a}`);
3107
3158
  if (name !== undefined)
3108
- fail(`machine snapshot takes one <new-name>, got extra: ${a}`);
3159
+ fail(`image snapshot takes one <name>, got extra: ${a}`);
3109
3160
  name = validateName(a, 'machine');
3110
3161
  }
3111
- if (name === undefined) fail('machine snapshot needs a <new-name>');
3162
+ if (name === undefined) fail('image snapshot needs a <name>');
3112
3163
  return {
3113
3164
  verb: 'snapshot',
3114
3165
  name: name as string,
3115
3166
  machine: nonEmpty(machine),
3116
- imageTag: nonEmpty(imageTag),
3167
+ createMachine: nonEmpty(createMachine),
3117
3168
  };
3118
3169
  }
3119
3170
 
3120
- return fail(
3121
- `unknown machine subcommand: ${verb} (create | list | set-image | rm | snapshot)`,
3122
- );
3171
+ return fail(`unknown image subcommand: ${verb} (snapshot | list)`);
3123
3172
  }
3124
3173
 
3125
3174
  /**
3126
- * PURE: the default image ref a `machine snapshot` writes when `--image-tag` is
3127
- * not given: `anon-pi/<name>:snapshot-<ts>`, where <ts> is a compact UTC stamp
3128
- * (YYYYMMDDHHMMSS) derived from `now`. Deterministic in `now` so it is unit
3129
- * testable. The name is a validated machine name (a safe image-path segment).
3175
+ * PURE: the clean image tag a `image snapshot <name>` writes:
3176
+ * `anon-pi/<name>:latest`. A same-name re-snapshot OVERWRITES this tag (that is
3177
+ * what `:latest` means); the previous image becomes dangling but keeps its
3178
+ * provenance label. The name is a validated image/machine name (a safe
3179
+ * image-path segment).
3130
3180
  */
3131
- export function snapshotImageRef(name: string, now: Date): string {
3132
- const p = (n: number, w = 2): string => String(n).padStart(w, '0');
3133
- const ts =
3134
- `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` +
3135
- `${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}`;
3136
- return `anon-pi/${name}:snapshot-${ts}`;
3181
+ export function snapshotImageTag(name: string): string {
3182
+ return `anon-pi/${validateName(name, 'machine')}:latest`;
3183
+ }
3184
+
3185
+ /** The podman/anon-pi provenance label keys baked into a snapshot image. */
3186
+ export const PROVENANCE_LABEL_SOURCE_MACHINE = 'anon-pi.source-machine';
3187
+ export const PROVENANCE_LABEL_SOURCE_IMAGE = 'anon-pi.source-image';
3188
+ export const PROVENANCE_LABEL_SNAPSHOT_AT = 'anon-pi.snapshot-at';
3189
+
3190
+ /**
3191
+ * PURE: build the `LABEL k=v` change instructions a `netcage commit -c '…'`
3192
+ * bakes into a snapshot image (ADR-0003 §2). Provenance is best-effort HISTORY:
3193
+ * a label whose value is undefined/empty is OMITTED (a missing label beats a
3194
+ * wrong one). `at` is required (the snapshot time is always known). Each string
3195
+ * is ONE `LABEL key=value` instruction (the CLI passes each as a `-c` argv
3196
+ * element; podman round-trips `/` and `:` in the value un-quoted, verified).
3197
+ */
3198
+ export function snapshotProvenanceLabels(args: {
3199
+ sourceMachine?: string;
3200
+ sourceImage?: string;
3201
+ at: string;
3202
+ }): string[] {
3203
+ const labels: string[] = [];
3204
+ const push = (key: string, value: string | undefined): void => {
3205
+ const v = nonEmpty(value);
3206
+ if (v !== undefined) labels.push(`LABEL ${key}=${v}`);
3207
+ };
3208
+ push(PROVENANCE_LABEL_SOURCE_MACHINE, args.sourceMachine);
3209
+ push(PROVENANCE_LABEL_SOURCE_IMAGE, args.sourceImage);
3210
+ push(PROVENANCE_LABEL_SNAPSHOT_AT, args.at);
3211
+ return labels;
3212
+ }
3213
+
3214
+ /** Provenance read back from a snapshot image's labels (any field may be absent). */
3215
+ export interface ImageProvenance {
3216
+ sourceMachine?: string;
3217
+ sourceImage?: string;
3218
+ snapshotAt?: string;
3219
+ }
3220
+
3221
+ /**
3222
+ * PURE: parse the anon-pi provenance labels read back off an image (the CLI
3223
+ * supplies the label map from `inspect --format '{{json .Config.Labels}}'`).
3224
+ * Returns only the anon-pi provenance fields (a missing/empty label => an
3225
+ * undefined field). Tolerant: any non-string / absent value is dropped, so a
3226
+ * hand-edited or partial label set never throws.
3227
+ */
3228
+ export function parseImageProvenance(
3229
+ labels: Record<string, unknown> | null | undefined,
3230
+ ): ImageProvenance {
3231
+ const get = (key: string): string | undefined => {
3232
+ const v = labels?.[key];
3233
+ return typeof v === 'string' ? nonEmpty(v) : undefined;
3234
+ };
3235
+ return {
3236
+ sourceMachine: get(PROVENANCE_LABEL_SOURCE_MACHINE),
3237
+ sourceImage: get(PROVENANCE_LABEL_SOURCE_IMAGE),
3238
+ snapshotAt: get(PROVENANCE_LABEL_SNAPSHOT_AT),
3239
+ };
3137
3240
  }
3138
3241
 
3139
3242
  /**
@@ -3209,26 +3312,44 @@ USAGE
3209
3312
  anon-pi forward [<p>] [--port …] open a host port onto a running container's in-jail server
3210
3313
  anon-pi ports [<project>] list a running container's open in-jail TCP listeners
3211
3314
  anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
3315
+ anon-pi -i <ref> [<p>] run against <ref> for THIS launch only (also --image; ephemeral)
3212
3316
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
3213
3317
  anon-pi init onboard: verify your proxy, capture your local model, pick an image
3214
- anon-pi machine … manage machines (create / list / set-image / rm / snapshot)
3318
+ anon-pi machine … manage machines (create / list / set-image / rm)
3319
+ anon-pi image … snapshot a running container into an image; list anon-pi images
3215
3320
  anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files
3216
3321
  anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes
3217
3322
 
3218
3323
  <project> a folder under the projects root (mounted at ${CONTAINER_PROJECTS_ROOT}; pi's cwd). \`.\` means
3219
3324
  the root itself (a scratch pi at ${CONTAINER_PROJECTS_ROOT}, ${CONTAINER_MOUNT_ROOT} for --mount, or ~).
3220
3325
 
3221
- [--rm] throwaway container this run (the DEFAULT; deleted on exit).
3222
- [--keep] leave the container KEPT so its filesystem survives (apt install,
3223
- quit, re-enter). anon-pi finds it by netcage's managed label and
3224
- \`netcage start\`s it on re-entry.
3326
+ -i <ref>, --image <ref> EPHEMERAL per-launch image override, highest priority
3327
+ (\`-i\` > the machine's machine.json image > ANON_PI_IMAGE). It picks the
3328
+ IMAGE for this launch only; \`-m\` picks the HOME, and the two compose.
3329
+ It NEVER changes machine.json (to re-pin a machine's image, use
3330
+ \`anon-pi machine set-image\` / \`machine create --image\`). No mismatch
3331
+ warning is printed. <ref> resolves in NETCAGE'S private image store
3332
+ (\`anon-pi/<name>:latest\` snapshots + \`init\`-built images live there),
3333
+ NOT your default podman store; anon-pi does NOT pre-check it and does
3334
+ NOT auto-pull (an anonymity tool must not silently fetch a remote
3335
+ image). A "not found" means the ref is not in netcage's store: snapshot
3336
+ it (\`anon-pi image snapshot <name>\`) or build it into that store.
3337
+ On a FRESH machine home \`-i\` is REFUSED (it would seed the home from
3338
+ the wrong image); establish the machine's image with
3339
+ \`anon-pi machine create <m> --image <ref>\` first.
3340
+
3341
+ Every launch is THROWAWAY: the container is removed on exit. To persist system
3342
+ state you built in a session, snapshot the running container into a named image
3343
+ (\`anon-pi image snapshot <name>\`) and pin a machine to it (\`anon-pi machine
3344
+ create <m> --image anon-pi/<name>:latest\`). Your pi config + conversations live
3345
+ in the machine home (a host mount) and persist regardless.
3225
3346
 
3226
3347
  WHAT IT DOES
3227
3348
  Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy
3228
3349
  (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE
3229
3350
  is an image + a persistent HOST home (bind-mounted at ${CONTAINER_HOME_ROOT}) holding your pi
3230
- config, extensions, and conversations; the container is disposable, so \`--rm\`
3231
- loses nothing. Files (projects) are global by default; conversations are
3351
+ config, extensions, and conversations; the container is disposable (throwaway),
3352
+ so it loses nothing. Files (projects) are global by default; conversations are
3232
3353
  per-machine. On a FRESH machine home the image's staged defaults + your
3233
3354
  models.json are seeded in once; after that pi owns the home. Requires \`netcage\`.
3234
3355