anon-pi 0.14.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/cli.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // anon-pi CLI: the THIN impure launch path. Parses grammar A (pure
3
3
  // parseLaunchArgs), reads config.json / machine.json + resolves the machine,
4
- // composes the LaunchIntent, resolves the RunPlan (pure resolveRunPlan), decides
5
- // run-vs-start against real netcage for `--keep`, and spawns netcage with
6
- // inherited stdio (so -it is a real interactive TTY), propagating the exit code.
4
+ // composes the LaunchIntent, resolves the RunPlan (pure resolveRunPlan; every
5
+ // launch is throwaway), and spawns netcage with inherited stdio (so -it is a
6
+ // real interactive TTY), propagating the exit code.
7
7
  //
8
8
  // All the DECISIONS live in the pure module (anon-pi.ts); this file only does
9
9
  // I/O: fs reads/mkdirs, the netcage query, the spawn, and the TTY discipline.
@@ -12,6 +12,7 @@
12
12
  // egress.
13
13
 
14
14
  import {
15
+ cpSync,
15
16
  existsSync,
16
17
  mkdirSync,
17
18
  readdirSync,
@@ -73,16 +74,25 @@ import {
73
74
  parseMachineJson,
74
75
  projectHostDir,
75
76
  resolveAnonPiHome,
77
+ resolveLaunchImage,
76
78
  resolveLlm,
77
79
  resolveProjectsRoot,
78
80
  resolveProxy,
79
81
  resolveRunPlan,
80
- resolveRunVsStart,
81
82
  serializeMachineJson,
82
- snapshotImageRef,
83
+ parseImageArgs,
84
+ snapshotImageTag,
85
+ snapshotProvenanceLabels,
86
+ parseImageProvenance,
87
+ PROVENANCE_LABEL_SOURCE_MACHINE,
88
+ snapshotSessionGroups,
89
+ copyIncludesForHomeMinusSessions,
90
+ type SnapshotSessionGroup,
91
+ type ImageCommand,
92
+ type ImageProvenance,
83
93
  serializeConfigJson,
84
94
  setImageWarning,
85
- keptContainerKey,
95
+ launchIdentityKey,
86
96
  DEFAULT_SOCKS_PROBE_PORTS,
87
97
  SOCKS5_METHOD_SELECTOR,
88
98
  formatProxyFindings,
@@ -112,7 +122,6 @@ import {
112
122
  type GeneratedModel,
113
123
  type ModelCandidate,
114
124
  type ModelSelection,
115
- type KeptContainer,
116
125
  type ManagedContainer,
117
126
  type NetcageListener,
118
127
  type LaunchIntent,
@@ -123,10 +132,11 @@ import {
123
132
  type PiModelsFile,
124
133
  } from './anon-pi.js';
125
134
 
126
- // The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
127
- // so a `--keep` re-entry can find and `netcage start` the same kept container.
128
- // netcage's `netcage.managed` label marks it a managed container; this adds the
129
- // anon-pi identity ON TOP (netcage's label IS the registry; anon-pi adds no file).
135
+ // The netcage label anon-pi stamps its launch-identity key onto (launchIdentityKey)
136
+ // so `forward`/`ports`/`snapshot` can find the RUNNING container by machine +
137
+ // project while it is up. netcage's `netcage.managed` label marks it a managed
138
+ // container; this adds the anon-pi identity ON TOP (netcage's label IS the
139
+ // registry; anon-pi adds no file).
130
140
  const ANON_PI_KEY_LABEL = 'anon-pi.key';
131
141
 
132
142
  function main(argv: string[]): number {
@@ -145,7 +155,13 @@ function main(argv: string[]): number {
145
155
  // and `anon-pi machine --help` show THEIR help, not the global one). Those
146
156
  // subcommands route to runInit / runMachine, which print INIT_HELP /
147
157
  // MACHINE_HELP respectively.
148
- const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine', 'forward', 'ports']);
158
+ const OWN_HELP_SUBCOMMANDS = new Set([
159
+ 'init',
160
+ 'machine',
161
+ 'image',
162
+ 'forward',
163
+ 'ports',
164
+ ]);
149
165
  if (
150
166
  (args.includes('--help') || args.includes('-h')) &&
151
167
  !OWN_HELP_SUBCOMMANDS.has(args[0] ?? '')
@@ -161,6 +177,13 @@ function main(argv: string[]): number {
161
177
  return runMachine(args.slice(1));
162
178
  }
163
179
 
180
+ // `image …` is the image-management surface (snapshot/list), dispatched BEFORE
181
+ // the launch grammar so a bare `image` is never parsed as a project named
182
+ // "image" (ADR-0003 §1: snapshot moved off `machine` onto this new noun).
183
+ if (args[0] === 'image') {
184
+ return runImage(args.slice(1));
185
+ }
186
+
164
187
  // The destructive cleanup verbs (replacing the old `--fresh`). Dispatched
165
188
  // BEFORE the launch grammar: they are top-level data verbs, not launch flags,
166
189
  // each with the confirm/`--yes`/non-TTY discipline. `--delete-home` takes an
@@ -278,8 +301,35 @@ function runLaunch(parsed: ParsedLaunch): number {
278
301
  );
279
302
  }
280
303
 
281
- // The machine's image: machine.json wins, ANON_PI_IMAGE is the fallback.
282
- const image = machineConf.image ?? env.image ?? '';
304
+ // The machine's image, highest-priority first: the EPHEMERAL per-launch
305
+ // `-i`/`--image` override > machine.json.image > ANON_PI_IMAGE. `-i` is
306
+ // strictly ephemeral (it is NEVER written back to machine.json; that pin is
307
+ // `machine set-image` / `machine create --image`) and no mismatch warning is
308
+ // printed (ADR-0003 section 3). `-i` picks the IMAGE; `-m` picks the HOME;
309
+ // they compose.
310
+ const iSet = (parsed.image ?? '').trim().length > 0;
311
+ const home = machineHomeDir(env, machineName);
312
+ // A fresh (unseeded) home has no established image/home baseline yet.
313
+ // Seeding it from the ephemeral `-i` image would poison the home with the
314
+ // wrong-image seed; skipping the seed would run pi unconfigured. So refuse
315
+ // and channel "make this the machine's image" to the explicit machine verb.
316
+ // (An ALREADY-SEEDED home just runs the override image against it; the
317
+ // runtime extension-compat risk is accepted silently, ADR-0003.)
318
+ if (iSet && homeFresh(home)) {
319
+ throw new AnonPiError(
320
+ `anon-pi: machine ${JSON.stringify(machineName)} has no home yet; \`-i\` is ` +
321
+ `ephemeral (it never seeds the home).\n` +
322
+ `Establish its image first with \`anon-pi machine create ${machineName} ` +
323
+ `--image ${parsed.image}\` (or launch once normally to seed), then use ` +
324
+ `\`-i\` to override per-launch.`,
325
+ );
326
+ }
327
+ const image =
328
+ resolveLaunchImage({
329
+ override: parsed.image,
330
+ machineImage: machineConf.image,
331
+ envImage: env.image,
332
+ }) ?? '';
283
333
 
284
334
  // --mount re-roots at a HOST parent; otherwise the resolved projects root.
285
335
  // Expand a leading `~` + absolutize the mount path so it is a real host dir
@@ -296,7 +346,6 @@ function runLaunch(parsed: ParsedLaunch): number {
296
346
  mountParent,
297
347
  });
298
348
 
299
- const home = machineHomeDir(env, machineName);
300
349
  const machine: Machine = {name: machineName, home, image};
301
350
 
302
351
  // The local-model models.json + settings seed for this machine's FRESH-home
@@ -313,7 +362,6 @@ function runLaunch(parsed: ParsedLaunch): number {
313
362
  project: parsed.project,
314
363
  mountParent,
315
364
  piArgs: parsed.piArgs,
316
- keep: parsed.keep,
317
365
  proxy,
318
366
  llmDirect: llm,
319
367
  modelsSeed,
@@ -380,10 +428,10 @@ function runLaunch(parsed: ParsedLaunch): number {
380
428
 
381
429
  /**
382
430
  * Execute a RESOLVED non-menu LaunchPlan: create the host dirs the mounts need,
383
- * then run netcage (or `netcage start` a matching kept container under --keep).
384
- * Shared by the direct launch path (runLaunch) and the menu dispatch (runMenu),
385
- * so a menu-picked project/here/shell launches BYTE-FOR-BYTE identically to the
386
- * same command typed directly.
431
+ * then run netcage (always a fresh throwaway `run`; ADR-0004). Shared by the
432
+ * direct launch path (runLaunch) and the menu dispatch (runMenu), so a
433
+ * menu-picked project/here/shell launches BYTE-FOR-BYTE identically to the same
434
+ * command typed directly.
387
435
  */
388
436
  function executeLaunchPlan(
389
437
  intent: LaunchIntent,
@@ -406,28 +454,13 @@ function executeLaunchPlan(
406
454
  mkdirSync(intent.mountParent ?? intent.projectsRoot, {recursive: true});
407
455
  }
408
456
 
409
- // The anon-pi identity key, stamped on EVERY launch (not just --keep) as an
410
- // additive netcage label. Under --keep it lets a re-entry find + `netcage
411
- // start` the kept container; on a throwaway --rm run it lets `anon-pi forward`
457
+ // The anon-pi identity key, stamped on EVERY launch as an additive netcage
458
+ // label. On a throwaway `--rm` run it lets `anon-pi forward`/`ports`/`snapshot`
412
459
  // find the RUNNING container while it is up (the label goes away with the
413
460
  // container on exit). It touches NO egress flag (the RunPlan owns those).
414
- const keyed = withKeyLabel(plan.netcageArgs, keptContainerKey(intent));
415
-
416
- // Run-vs-start: under --keep, ask netcage for its kept managed containers and
417
- // resume a matching one via `netcage start`; else run the composed argv. A
418
- // throwaway (`--rm`) launch is always a fresh run (the pure rule never
419
- // consults the listing for it).
420
- if (intent.keep) {
421
- const decision = resolveRunVsStart(intent, queryKeptContainers());
422
- if (decision.action === 'start') {
423
- return spawnNetcage(['start', '-a', '-i', decision.ref], {
424
- enteringJail: true,
425
- });
426
- }
427
- // A fresh `--keep` run: the RunPlan already omits --rm so it is left kept.
428
- return spawnNetcage(keyed, {enteringJail: true});
429
- }
461
+ const keyed = withKeyLabel(plan.netcageArgs, launchIdentityKey(intent));
430
462
 
463
+ // Every launch is a fresh throwaway `run` (the RunPlan always carries --rm).
431
464
  return spawnNetcage(keyed, {enteringJail: true});
432
465
  }
433
466
 
@@ -681,8 +714,6 @@ function runMachine(machineArgs: string[]): number {
681
714
  return machineSetImage(env, cmd.name, cmd.image);
682
715
  case 'rm':
683
716
  return machineRm(env, cmd.name, cmd.yes);
684
- case 'snapshot':
685
- return machineSnapshot(env, cmd.name, cmd.machine, cmd.imageTag);
686
717
  }
687
718
  } catch (e) {
688
719
  return reportAnonPiError(e);
@@ -738,6 +769,32 @@ function machineCreate(
738
769
  `anon-pi: created machine ${JSON.stringify(name)} (image ${pinned.trim()}) at ${dir}.\n` +
739
770
  `Its home is seeded on first launch, e.g. \`anon-pi -m ${name} --shell\`.\n`,
740
771
  );
772
+
773
+ // PROVENANCE-AWARE (ADR-0003 §5): if the pinned image was produced by
774
+ // `image snapshot` (it carries `anon-pi.source-machine=<M>`) AND that source
775
+ // machine's home still exists on disk, OFFER the home-copy (minus sessions) +
776
+ // per-project session carry-over from it, so a machine built from a snapshot
777
+ // inherits the source's config + conversations (opt-in). Absent provenance /
778
+ // source home gone => a plain fresh create (today's behaviour) with a quiet
779
+ // note. Guarded no-TTY inside carryOverHomeFromMachine (copy nothing). This
780
+ // reads the image via netcage inspect; when netcage is absent it is skipped
781
+ // (a create must not require netcage), so provenance carry-over is best-effort.
782
+ if (hasNetcage()) {
783
+ const prov = inspectImageProvenance(pinned.trim());
784
+ const source = prov.sourceMachine;
785
+ if (source !== undefined && existsSync(machineHomeDir(env, source))) {
786
+ process.stderr.write(
787
+ `anon-pi: image ${pinned.trim()} was snapshotted from machine ${JSON.stringify(source)} ` +
788
+ '(whose home is present); offering to carry its home + conversations over.\n',
789
+ );
790
+ carryOverHomeFromMachine(env, source, name);
791
+ } else if (source !== undefined) {
792
+ process.stderr.write(
793
+ `anon-pi: image ${pinned.trim()} names source machine ${JSON.stringify(source)}, ` +
794
+ 'but its home is gone; created a fresh home.\n',
795
+ );
796
+ }
797
+ }
741
798
  return 0;
742
799
  }
743
800
 
@@ -830,36 +887,72 @@ function machineRm(env: AnonPiEnv, name: string, yes: boolean): number {
830
887
  return 0;
831
888
  }
832
889
 
890
+ // --- the `image` verbs (ADR-0003): snapshot a running container into a clean
891
+ // image tag with provenance labels, and a read-only list. Thin I/O over the
892
+ // pure parts (parseImageArgs / snapshotImageTag / snapshotProvenanceLabels /
893
+ // parseImageProvenance); netcage does the commit / images / inspect.
894
+
895
+ /**
896
+ * Parse `image <verb> …` (pure parseImageArgs) and dispatch to the snapshot /
897
+ * list I/O. Prints IMAGE_HELP on `--help`/`-h`.
898
+ */
899
+ function runImage(imageArgs: string[]): number {
900
+ if (imageArgs.includes('--help') || imageArgs.includes('-h')) {
901
+ process.stdout.write(IMAGE_HELP);
902
+ return 0;
903
+ }
904
+
905
+ const env = envFromProcess(process.env);
906
+ let cmd: ImageCommand;
907
+ try {
908
+ cmd = parseImageArgs(imageArgs);
909
+ } catch (e) {
910
+ return reportAnonPiError(e);
911
+ }
912
+
913
+ try {
914
+ switch (cmd.verb) {
915
+ case 'snapshot':
916
+ return imageSnapshot(env, cmd.name, cmd.machine, cmd.createMachine);
917
+ case 'list':
918
+ return imageList();
919
+ }
920
+ } catch (e) {
921
+ return reportAnonPiError(e);
922
+ }
923
+ }
924
+
833
925
  /**
834
- * `machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: commit a
835
- * RUNNING jailed container into a new image and create <new-name> pinned to it.
836
- * The user does interactive system work in a session (e.g. `sudo apt install`),
837
- * then, WITHOUT having exited (the default `--rm` would have destroyed the
838
- * container), preserves that exact environment as a new machine. The container
839
- * to commit is AUTO-DETECTED from the running anon-pi containers (a picker when
840
- * several are up); `-m <machine>` is an OPTIONAL filter, not a required source.
841
- * podman pauses the container during commit and unpauses, so the live session
842
- * survives. The new machine gets a FRESH home (image and home are orthogonal;
843
- * snapshot keeps the SOFTWARE, not the conversations). Forced egress is
844
- * untouched: commit is a local podman op, and the snapshot machine relaunches
845
- * through the same forced-egress jail.
926
+ * `image snapshot <name> [-m <machine>] [--create-machine <m>]`: commit the
927
+ * RUNNING jailed container into the clean tag `anon-pi/<name>:latest`, baking
928
+ * provenance as podman LABELS via `netcage commit -c 'LABEL …'` (ADR-0003 §1+2).
929
+ * The container to commit is AUTO-DETECTED from the running anon-pi containers
930
+ * (a picker when several are up); `-m <machine>` is an OPTIONAL filter, not a
931
+ * required source. podman pauses the container during commit and unpauses, so
932
+ * the live session survives. A same-name re-snapshot OVERWRITES the `:latest`
933
+ * tag (the previous image becomes dangling but keeps its provenance label).
934
+ * `--create-machine <m>` ALSO creates machine <m> from the fresh snapshot,
935
+ * running the home-copy + per-project session carry-over. Forced egress is
936
+ * untouched (commit is a local podman op).
846
937
  */
847
- function machineSnapshot(
938
+ function imageSnapshot(
848
939
  env: AnonPiEnv,
849
940
  name: string,
850
941
  machine: string | undefined,
851
- imageTag: string | undefined,
942
+ createMachine: string | undefined,
852
943
  ): number {
853
- // Refuse to clobber an existing target machine FIRST (before netcage / any
854
- // commit), so a name clash fails fast and never leaves an orphan image.
855
- // Mirrors machine create.
856
- const targetDir = machineDir(env, name);
857
- if (existsSync(targetDir)) {
858
- process.stderr.write(
859
- `anon-pi: machine ${JSON.stringify(name)} already exists (${targetDir}). ` +
860
- 'Pick a different <new-name> or `anon-pi machine rm` it first.\n',
861
- );
862
- return 1;
944
+ // If --create-machine names an EXISTING machine, refuse FIRST (before netcage /
945
+ // any commit), so a name clash fails fast (mirrors machine create). The
946
+ // snapshot itself has no such clash: it overwrites its `:latest` tag by design.
947
+ if (createMachine !== undefined) {
948
+ const targetDir = machineDir(env, createMachine);
949
+ if (existsSync(targetDir)) {
950
+ process.stderr.write(
951
+ `anon-pi: machine ${JSON.stringify(createMachine)} already exists (${targetDir}). ` +
952
+ 'Pick a different --create-machine name or `anon-pi machine rm` it first.\n',
953
+ );
954
+ return 1;
955
+ }
863
956
  }
864
957
 
865
958
  if (!hasNetcage()) return netcageMissing();
@@ -869,33 +962,222 @@ function machineSnapshot(
869
962
  const target = resolveRunningContainer(machine, 'snapshot');
870
963
  if (target === undefined) return 1;
871
964
 
872
- const imageRef = imageTag ?? snapshotImageRef(name, new Date());
965
+ const tag = snapshotImageTag(name);
966
+
967
+ // Provenance (ADR-0003 §2), all best-effort HISTORY:
968
+ // - source-machine: the committed container's machine, from its stamped key
969
+ // (parseKeptKey.machine, authoritative).
970
+ // - source-image: what the snapshot is ACTUALLY built on, read from the
971
+ // RUNNING CONTAINER via inspect (NOT machine.json: `-i` makes the container's
972
+ // image diverge from the machine's pin). Fall back to machine.json.image if
973
+ // the inspect misses; OMIT the label if neither is known.
974
+ // - snapshot-at: now, ISO 8601.
975
+ const sourceMachine = parseKeptKey(target.key).machine;
976
+ const sourceImage =
977
+ inspectContainerImage(target.ref) ??
978
+ (sourceMachine !== undefined
979
+ ? readMachineJson(env, sourceMachine).image
980
+ : undefined);
981
+ const labels = snapshotProvenanceLabels({
982
+ sourceMachine,
983
+ sourceImage,
984
+ at: new Date().toISOString(),
985
+ });
986
+
873
987
  process.stderr.write(
874
- `anon-pi: committing ${target.name} -> image ${imageRef} (pausing the container briefly)\u2026\n`,
988
+ `anon-pi: committing ${target.name} -> image ${tag} (pausing the container briefly)\u2026\n`,
875
989
  );
876
- const committed = spawnNetcage(['commit', target.ref, imageRef]);
990
+ // One `-c 'LABEL k=v'` per provenance label (each is one argv element; podman
991
+ // round-trips `/` and `:` in the value un-quoted, verified).
992
+ const commitArgs = ['commit'];
993
+ for (const label of labels) commitArgs.push('-c', label);
994
+ commitArgs.push(target.ref, tag);
995
+ const committed = spawnNetcage(commitArgs);
877
996
  if (committed !== 0) {
878
997
  process.stderr.write(
879
- `anon-pi: netcage commit failed; machine ${JSON.stringify(name)} NOT created.\n`,
998
+ `anon-pi: netcage commit failed; image ${tag} NOT written.\n`,
880
999
  );
881
1000
  return committed;
882
1001
  }
883
1002
 
884
- // Create the machine pinned to the committed image (its home seeds on first
885
- // launch, exactly like `machine create`).
886
- mkdirSync(machineHomeDir(env, name), {recursive: true});
887
- writeFileSync(
888
- machineJsonPath(env, name),
889
- serializeMachineJson({image: imageRef}),
890
- );
891
1003
  process.stdout.write(
892
- `anon-pi: snapshotted ${target.name} into machine ${JSON.stringify(name)} ` +
893
- `(image ${imageRef}) at ${targetDir}.\n` +
894
- `Its home seeds fresh on first launch, e.g. \`anon-pi -m ${name} --shell\`.\n`,
1004
+ `anon-pi: snapshotted ${target.name} into image ${tag}` +
1005
+ (sourceMachine !== undefined
1006
+ ? ` (from machine ${JSON.stringify(sourceMachine)}).\n`
1007
+ : '.\n'),
895
1008
  );
1009
+
1010
+ // --create-machine: create the machine from the fresh snapshot, running the
1011
+ // same home-copy + per-project session carry-over the 0.15 snapshot did. The
1012
+ // source machine is directly known (we just committed its container), so the
1013
+ // shared helper is called with it.
1014
+ if (createMachine !== undefined) {
1015
+ mkdirSync(machineHomeDir(env, createMachine), {recursive: true});
1016
+ writeFileSync(
1017
+ machineJsonPath(env, createMachine),
1018
+ serializeMachineJson({image: tag}),
1019
+ );
1020
+ process.stdout.write(
1021
+ `anon-pi: created machine ${JSON.stringify(createMachine)} pinned to ${tag}.\n`,
1022
+ );
1023
+ if (sourceMachine !== undefined) {
1024
+ carryOverHomeFromMachine(env, sourceMachine, createMachine);
1025
+ } else {
1026
+ process.stderr.write(
1027
+ 'anon-pi: the committed container has no source machine; the new home seeds fresh on first launch.\n',
1028
+ );
1029
+ }
1030
+ }
896
1031
  return 0;
897
1032
  }
898
1033
 
1034
+ /**
1035
+ * `image list`: read-only; list anon-pi images with their provenance. ZERO
1036
+ * stored state. Includes an image if it is `anon-pi/*`-tagged OR (even when
1037
+ * DANGLING/untagged) it carries an `anon-pi.source-machine` label, so an
1038
+ * ORPHANED snapshot (its `:latest` tag overwritten by a re-snapshot) is still
1039
+ * shown by its ID. Prints `<name-or-<none>> from machine <M> <when> id:<short>`.
1040
+ */
1041
+ function imageList(): number {
1042
+ if (!hasNetcage()) return netcageMissing();
1043
+ const images = queryAnonPiImages();
1044
+ if (images.length === 0) {
1045
+ process.stdout.write(
1046
+ 'anon-pi: no anon-pi images yet. Create one with `anon-pi image snapshot <name>`.\n',
1047
+ );
1048
+ return 0;
1049
+ }
1050
+ for (const img of images) {
1051
+ const prov = parseImageProvenance(img.labels);
1052
+ const nameCol = img.tag ?? '<none>';
1053
+ const fromCol =
1054
+ prov.sourceMachine !== undefined
1055
+ ? `from machine ${prov.sourceMachine}`
1056
+ : 'from machine <unknown>';
1057
+ const whenCol = prov.snapshotAt ?? '<unknown>';
1058
+ const idCol = `id:${img.id.slice(0, 12)}`;
1059
+ process.stdout.write(`${nameCol} ${fromCol} ${whenCol} ${idCol}\n`);
1060
+ }
1061
+ return 0;
1062
+ }
1063
+
1064
+ /**
1065
+ * Shared home carry-over from a source machine to a dest machine (ADR-0003): the
1066
+ * home-minus-sessions copy (copyHomeMinusSessions) + the interactive per-project
1067
+ * session picker (carryOverSessions). Both the `image snapshot --create-machine`
1068
+ * path and the provenance-aware `machine create --image` path call this; they
1069
+ * differ ONLY in how they learn `sourceMachine`. Honors the no-TTY "copy
1070
+ * nothing" rule already in carryOverSessions (a scripted create stays
1071
+ * non-blocking). A no-op message-wise when the source home is absent.
1072
+ */
1073
+ function carryOverHomeFromMachine(
1074
+ env: AnonPiEnv,
1075
+ sourceMachine: string,
1076
+ destMachine: string,
1077
+ ): void {
1078
+ if (existsSync(machineHomeDir(env, sourceMachine))) {
1079
+ copyHomeMinusSessions(env, sourceMachine, destMachine);
1080
+ process.stderr.write(
1081
+ `anon-pi: copied ${JSON.stringify(sourceMachine)}'s home (config + extensions) into ` +
1082
+ `${JSON.stringify(destMachine)} (minus conversations).\n`,
1083
+ );
1084
+ }
1085
+ carryOverSessions(env, sourceMachine, destMachine);
1086
+ }
1087
+
1088
+ /**
1089
+ * Recursively copy machine <source>'s home into machine <dest>'s home, EXCLUDING
1090
+ * the `.pi/agent/sessions/` subtree (conversations are carried over separately,
1091
+ * per-project, opt-in). Best-effort: an absent source home is a no-op (the dest
1092
+ * just stays fresh). Uses cpSync with a filter that rejects the sessions dir and
1093
+ * anything under it.
1094
+ */
1095
+ function copyHomeMinusSessions(
1096
+ env: AnonPiEnv,
1097
+ source: string,
1098
+ dest: string,
1099
+ ): void {
1100
+ const srcHome = machineHomeDir(env, source);
1101
+ if (!existsSync(srcHome)) return;
1102
+ const destHome = machineHomeDir(env, dest);
1103
+ const sessionsPath = machineSessionsDir(env, source);
1104
+ cpSync(srcHome, destHome, {
1105
+ recursive: true,
1106
+ // Exclude the sessions dir itself and everything beneath it (pure predicate).
1107
+ filter: (src) => copyIncludesForHomeMinusSessions(src, sessionsPath),
1108
+ });
1109
+ }
1110
+
1111
+ /**
1112
+ * Offer the source machine's pi conversation history (grouped BY PROJECT) as an
1113
+ * opt-in carry-over into the new machine. Each present `sessions/<slug>/` group
1114
+ * is a project row (or an orphan-slug row); DEFAULT all UNSELECTED, per-project
1115
+ * COPY or SKIP. Copy duplicates that session dir into the new home. There is NO
1116
+ * per-row move; after the copies, ONE confirmed (default No) step can delete the
1117
+ * copied groups from the SOURCE home (the only "move"). No-TTY: copy nothing.
1118
+ */
1119
+ function carryOverSessions(env: AnonPiEnv, source: string, dest: string): void {
1120
+ const presentSlugs = readDirNames(machineSessionsDir(env, source));
1121
+ if (presentSlugs.length === 0) return;
1122
+
1123
+ if (!process.stdin.isTTY) {
1124
+ process.stderr.write(
1125
+ `anon-pi: ${presentSlugs.length} conversation group(s) on ${JSON.stringify(source)} ` +
1126
+ 'were NOT copied (no TTY to choose). The new machine starts with no history.\n',
1127
+ );
1128
+ return;
1129
+ }
1130
+
1131
+ // Label rows by project name (matching the machine-invariant slug); an orphan
1132
+ // slug with no current project folder is still offered by its raw slug.
1133
+ const config = readJsonConfig(env);
1134
+ const projectsRoot = resolveProjectsRoot({env, config});
1135
+ const groups = snapshotSessionGroups({
1136
+ presentSlugs,
1137
+ projects: readDirNames(projectsRoot),
1138
+ });
1139
+
1140
+ process.stderr.write(
1141
+ `anon-pi: ${JSON.stringify(source)} has ${groups.length} conversation group(s) ` +
1142
+ '(by project). Choose COPY or SKIP for each (default SKIP):\n',
1143
+ );
1144
+ const copied: SnapshotSessionGroup[] = [];
1145
+ for (const g of groups) {
1146
+ const ans = promptLine(` ${g.label} [copy/SKIP]: `);
1147
+ if (ans !== undefined && /^c(opy)?$/i.test(ans.trim())) {
1148
+ const from = join(machineSessionsDir(env, source), g.slug);
1149
+ const to = join(machineSessionsDir(env, dest), g.slug);
1150
+ mkdirSync(machineSessionsDir(env, dest), {recursive: true});
1151
+ cpSync(from, to, {recursive: true});
1152
+ copied.push(g);
1153
+ }
1154
+ }
1155
+
1156
+ if (copied.length === 0) {
1157
+ process.stderr.write('anon-pi: no conversation groups copied.\n');
1158
+ return;
1159
+ }
1160
+ process.stderr.write(
1161
+ `anon-pi: copied ${copied.length} conversation group(s) into ${JSON.stringify(dest)}.\n`,
1162
+ );
1163
+
1164
+ // The ONLY "move": an explicit, confirmed, default-No delete from the SOURCE.
1165
+ const ans = promptLine(
1166
+ `Also DELETE the ${copied.length} copied group(s) from source machine ${JSON.stringify(source)}? [y/N] `,
1167
+ );
1168
+ if (ans !== undefined && /^y(es)?$/i.test(ans.trim())) {
1169
+ for (const g of copied) {
1170
+ rmSync(join(machineSessionsDir(env, source), g.slug), {
1171
+ recursive: true,
1172
+ force: true,
1173
+ });
1174
+ }
1175
+ process.stderr.write(
1176
+ `anon-pi: removed ${copied.length} group(s) from ${JSON.stringify(source)}.\n`,
1177
+ );
1178
+ }
1179
+ }
1180
+
899
1181
  // --- the destructive cleanup verbs (thin I/O over the pure resolvers) --------
900
1182
  //
901
1183
  // `--delete-home [<machine>]` and `--delete-project <project>` REPLACE the old
@@ -2116,24 +2398,51 @@ USAGE
2116
2398
  anon-pi machine list list machines and their images
2117
2399
  anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
2118
2400
  anon-pi machine rm <name> [--yes] delete the machine + its home
2119
- anon-pi machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]
2120
- commit a RUNNING container into a
2121
- new image + create <new-name>
2122
2401
 
2123
2402
  A machine is an image + a persistent host home (machines/<name>/{machine.json,home/}).
2124
2403
  The home is seeded on FIRST LAUNCH, not at create. \`set-image\` re-pins only and
2125
2404
  warns (the home was built for the old image); \`rm\` confirms on a TTY, skips with
2126
2405
  \`--yes\`, and aborts non-interactively without it.
2127
2406
 
2407
+ \`create --image <ref>\` is PROVENANCE-AWARE: if <ref> was produced by
2408
+ \`anon-pi image snapshot\` (it carries an \`anon-pi.source-machine\` label) AND
2409
+ that machine's home still exists, you are OFFERED its home + conversations to
2410
+ carry over (opt-in, no TTY => nothing copied). Otherwise a plain fresh create.
2411
+
2412
+ To SNAPSHOT a running container into an image, use \`anon-pi image snapshot\`
2413
+ (the verb moved off \`machine\` onto the \`image\` noun).
2414
+ `;
2415
+
2416
+ /** The `image` subcommand help. */
2417
+ const IMAGE_HELP = `anon-pi image - snapshot a running container into an image, and list anon-pi images
2418
+
2419
+ USAGE
2420
+ anon-pi image snapshot <name> [-m <machine>] [--create-machine <m>]
2421
+ commit the RUNNING container into anon-pi/<name>:latest
2422
+ anon-pi image list list anon-pi images with their provenance (read-only)
2423
+
2128
2424
  \`snapshot\` captures the CURRENT filesystem of a RUNNING jailed container (e.g.
2129
- after \`sudo apt install\`) into a new image and creates <new-name> pinned to it,
2130
- so you can preserve an environment you built interactively WITHOUT having
2131
- pre-decided \`--keep\`. The container is auto-detected from the running anon-pi
2132
- containers (a picker when several are up); \`-m <machine>\` is an OPTIONAL filter,
2133
- not a required source. The container must still be RUNNING (do not exit the
2134
- session); podman pauses it briefly during the commit. The new machine gets a
2135
- FRESH home (the image is the software, the home is separate). Same forced-egress
2136
- jail on relaunch.
2425
+ after \`sudo apt install\`) into the clean tag \`anon-pi/<name>:latest\`, baking
2426
+ provenance as podman labels (source machine, source image, snapshot time). This
2427
+ is how you keep container-level system changes (every launch is throwaway):
2428
+ freeze the running box into a named image, then pin a machine to it. The
2429
+ container is auto-detected from the running anon-pi containers (a picker when
2430
+ several are up); \`-m <machine>\` is an OPTIONAL filter, not a required source.
2431
+ The container must still be RUNNING (do not exit the session); podman pauses it
2432
+ briefly during the commit. A same-name re-snapshot OVERWRITES the \`:latest\` tag
2433
+ (the previous image becomes dangling but keeps its provenance, so \`image list\`
2434
+ still shows it by ID). To preserve a specific snapshot, snapshot it under a
2435
+ different name.
2436
+
2437
+ \`--create-machine <m>\` ALSO creates machine <m> pinned to the fresh snapshot,
2438
+ copying the source machine's HOME (config + extensions + dotfiles) MINUS its
2439
+ conversations, then offering the conversations separately (grouped BY PROJECT,
2440
+ opt-in per project, default SKIP; no TTY => none copied). This is equivalent to
2441
+ \`image snapshot\` followed by a provenance-aware \`machine create --image\`.
2442
+
2443
+ \`list\` reads the provenance labels straight off the images (ZERO stored state):
2444
+ it shows every \`anon-pi/*\` image plus any dangling image still carrying an
2445
+ \`anon-pi.source-machine\` label (an orphaned snapshot), by its ID.
2137
2446
  `;
2138
2447
 
2139
2448
  // --- impure helpers ---------------------------------------------------------
@@ -2169,24 +2478,9 @@ function homeFresh(machineHome: string): boolean {
2169
2478
  return !existsSync(marker);
2170
2479
  }
2171
2480
 
2172
- /**
2173
- * Query netcage for its KEPT managed containers, surfacing each one's stamped
2174
- * anon-pi identity key so the pure run-vs-start decision can match it. Thin,
2175
- * best-effort I/O: on any failure (netcage missing the query, no containers, a
2176
- * parse error) it returns an EMPTY listing, so the decision falls back to a
2177
- * fresh `run` (safe: it never wrongly resumes, it just creates a new container).
2178
- */
2179
- function queryKeptContainers(): KeptContainer[] {
2180
- // Ask netcage for ALL its managed containers as JSON (netcage >= 0.10.0
2181
- // forwards podman's --format json over its managed scope), then keep the ones
2182
- // carrying an anon-pi.key label (a sidecar has none) and decode it. -a so a
2183
- // STOPPED kept container is included (run-vs-start resumes it).
2184
- return queryManagedContainers({all: true}).map(({key, ref}) => ({key, ref}));
2185
- }
2186
-
2187
2481
  /**
2188
2482
  * Decode a base64 anon-pi.key label back to its identity key (the reverse of
2189
- * withKeyLabel's encode; keptContainerKey embeds newlines, so it is base64'd to
2483
+ * withKeyLabel's encode; launchIdentityKey embeds newlines, so it is base64'd to
2190
2484
  * stay a single safe label value). undefined on a decode error.
2191
2485
  */
2192
2486
  function decodeKeyLabel(raw: string): string | undefined {
@@ -2245,6 +2539,152 @@ function queryNetcagePorts(ref: string): NetcageListener[] {
2245
2539
  return parseNetcagePortsJson(res.stdout);
2246
2540
  }
2247
2541
 
2542
+ // --- `image`: read a container/image's provenance from netcage inspect --------
2543
+
2544
+ /**
2545
+ * Best-effort: the image ref a RUNNING container is ACTUALLY built on, via
2546
+ * `netcage inspect <ref> --format '{{.ImageName}}'`. Used to bake the
2547
+ * `anon-pi.source-image` label (the container's image can diverge from the
2548
+ * machine's pin when `-i` was passed). undefined on any miss (older netcage, a
2549
+ * parse/format hiccup): the caller falls back to machine.json.image, then omits
2550
+ * the label. NEVER throws.
2551
+ */
2552
+ function inspectContainerImage(ref: string): string | undefined {
2553
+ const res = spawnSync(
2554
+ 'netcage',
2555
+ ['inspect', ref, '--format', '{{.ImageName}}'],
2556
+ {encoding: 'utf8'},
2557
+ );
2558
+ if (res.error || res.status !== 0 || !res.stdout) return undefined;
2559
+ const out = res.stdout.trim();
2560
+ return out === '' || out === '<no value>' ? undefined : out;
2561
+ }
2562
+
2563
+ /**
2564
+ * Best-effort: the anon-pi provenance an IMAGE ref carries, via `netcage inspect
2565
+ * <ref> --format '{{json .Config.Labels}}'` parsed through the pure
2566
+ * parseImageProvenance. Used by provenance-aware `machine create`. Empty
2567
+ * provenance (all fields undefined) on any miss (older netcage, no labels, a
2568
+ * parse hiccup). NEVER throws.
2569
+ */
2570
+ function inspectImageProvenance(ref: string): ImageProvenance {
2571
+ const labels = inspectLabels(ref);
2572
+ return parseImageProvenance(labels);
2573
+ }
2574
+
2575
+ /**
2576
+ * Best-effort: an image/container's label map via `netcage inspect <ref>
2577
+ * --format '{{json .Config.Labels}}'`. null on any miss / unparseable output, so
2578
+ * the pure parseImageProvenance sees an absent map (all fields undefined).
2579
+ */
2580
+ function inspectLabels(ref: string): Record<string, unknown> | null {
2581
+ const res = spawnSync(
2582
+ 'netcage',
2583
+ ['inspect', ref, '--format', '{{json .Config.Labels}}'],
2584
+ {encoding: 'utf8'},
2585
+ );
2586
+ if (res.error || res.status !== 0 || !res.stdout) return null;
2587
+ const text = res.stdout.trim();
2588
+ if (text === '' || text === 'null' || text === '<no value>') return null;
2589
+ try {
2590
+ const parsed = JSON.parse(text);
2591
+ return parsed !== null && typeof parsed === 'object'
2592
+ ? (parsed as Record<string, unknown>)
2593
+ : null;
2594
+ } catch {
2595
+ return null;
2596
+ }
2597
+ }
2598
+
2599
+ /** One anon-pi image `image list` surfaces: its id, its `anon-pi/*` tag (if any), its labels. */
2600
+ interface AnonPiImage {
2601
+ id: string;
2602
+ tag?: string;
2603
+ labels: Record<string, unknown> | null;
2604
+ }
2605
+
2606
+ /**
2607
+ * Best-effort: the anon-pi images in netcage's store for `image list`. Reads
2608
+ * `netcage images --format json`, keeps an image if it is `anon-pi/*`-tagged OR
2609
+ * (even dangling/untagged) it carries an `anon-pi.source-machine` label (so an
2610
+ * orphaned snapshot is still shown by its ID), reading each candidate's labels
2611
+ * via inspect. ZERO stored state. [] on any failure (older netcage, a parse
2612
+ * miss), so `image list` reports "no images" cleanly rather than crashing.
2613
+ */
2614
+ function queryAnonPiImages(): AnonPiImage[] {
2615
+ const res = spawnSync('netcage', ['images', '--format', 'json'], {
2616
+ encoding: 'utf8',
2617
+ });
2618
+ if (res.error || res.status !== 0 || !res.stdout) return [];
2619
+ let parsed: unknown;
2620
+ try {
2621
+ parsed = JSON.parse(res.stdout);
2622
+ } catch {
2623
+ return [];
2624
+ }
2625
+ if (!Array.isArray(parsed)) return [];
2626
+
2627
+ const out: AnonPiImage[] = [];
2628
+ const seen = new Set<string>();
2629
+ for (const raw of parsed) {
2630
+ if (raw === null || typeof raw !== 'object') continue;
2631
+ const rec = raw as Record<string, unknown>;
2632
+ const id = firstString(rec['Id'], rec['ID'], rec['id']);
2633
+ if (id === undefined || seen.has(id)) continue;
2634
+ const tags = imageTags(rec);
2635
+ const anonTag = tags.find((t) => t.startsWith('anon-pi/'));
2636
+ // An anon-pi/*-tagged image always qualifies; else inspect it for the
2637
+ // source-machine label (an orphaned/dangling snapshot still qualifies).
2638
+ if (anonTag === undefined) {
2639
+ if (tags.length > 0) continue; // a non-anon-pi tagged image is never ours.
2640
+ const labels = inspectLabels(id);
2641
+ if (
2642
+ labels === null ||
2643
+ typeof labels[PROVENANCE_LABEL_SOURCE_MACHINE] !== 'string'
2644
+ )
2645
+ continue;
2646
+ seen.add(id);
2647
+ out.push({id, labels});
2648
+ continue;
2649
+ }
2650
+ seen.add(id);
2651
+ out.push({id, tag: anonTag, labels: inspectLabels(id)});
2652
+ }
2653
+ return out;
2654
+ }
2655
+
2656
+ /** First defined string among the candidates (tolerant field-name reader). */
2657
+ function firstString(...vals: unknown[]): string | undefined {
2658
+ for (const v of vals) {
2659
+ if (typeof v === 'string' && v.trim() !== '') return v;
2660
+ }
2661
+ return undefined;
2662
+ }
2663
+
2664
+ /**
2665
+ * The repository tags on an image record, tolerant of netcage/podman's field
2666
+ * shapes: `Names`/`RepoTags`/`Tags` (an array of `repo:tag`), or a single
2667
+ * `Repository`+`Tag` pair. `<none>:<none>` entries (dangling) are dropped.
2668
+ */
2669
+ function imageTags(rec: Record<string, unknown>): string[] {
2670
+ const tags: string[] = [];
2671
+ for (const key of ['Names', 'RepoTags', 'Tags']) {
2672
+ const v = rec[key];
2673
+ if (Array.isArray(v)) {
2674
+ for (const t of v) {
2675
+ if (typeof t === 'string' && t !== '' && !t.startsWith('<none>'))
2676
+ tags.push(t);
2677
+ }
2678
+ }
2679
+ }
2680
+ const repo = firstString(rec['Repository']);
2681
+ const tag = firstString(rec['Tag']);
2682
+ if (repo !== undefined && repo !== '<none>' && tag !== undefined) {
2683
+ tags.push(`${repo}:${tag}`);
2684
+ }
2685
+ return tags;
2686
+ }
2687
+
2248
2688
  /**
2249
2689
  * Resolve the ONE running anon-pi container a forward/ports should act on:
2250
2690
  * filter the running managed containers by machine (+ project if given), then
@@ -2506,9 +2946,10 @@ function netcageMissing(): number {
2506
2946
 
2507
2947
  /**
2508
2948
  * Insert the anon-pi identity label into a `netcage run` argv (right after
2509
- * `run`), so a kept container can be found on re-entry. The key is base64'd
2510
- * (keptContainerKey embeds newlines) to keep it a single safe label value. This
2511
- * is ADDITIVE and touches NO egress flag (the RunPlan owns --proxy/--allow-direct).
2949
+ * `run`), so `forward`/`ports`/`snapshot` can find the RUNNING container by
2950
+ * machine + project. The key is base64'd (launchIdentityKey embeds newlines) to
2951
+ * keep it a single safe label value. This is ADDITIVE and touches NO egress flag
2952
+ * (the RunPlan owns --proxy/--allow-direct).
2512
2953
  */
2513
2954
  function withKeyLabel(netcageArgs: string[], key: string): string[] {
2514
2955
  const enc = Buffer.from(key, 'utf8').toString('base64');