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/dist/cli.js 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.
@@ -14,11 +14,12 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, write
14
14
  import { readSync } from 'node:fs';
15
15
  import { spawnSync, execFileSync } from 'node:child_process';
16
16
  import { join, dirname, resolve } from 'node:path';
17
- import { AnonPiError, HELP, MODELS_FILE, SETTINGS_FILE, SETTINGS_SEED_FILE, SEED_MARKER, DEFAULT_MACHINE, envFromProcess, buildMenuChoiceList, buildMenuEntries, builtinProjectsRoot, deriveProjectUsage, expandTilde, findingsFromNetcageDetect, processNoteFromNetcageDetect, resolveNetcageGraphroot, globalModelsSeedPath, globalSettingsSeedPath, machineAgentDir, machineDir, machineHomeDir, machineJsonPath, machineModelsSeedPath, machineSessionsDir, mergeModelSelection, resolveModelsSeedPath, resolveSettingsSeedPath, validateName, resolveDeleteHome, resolveDeleteProject, parseConfigJson, parseLaunchArgs, parseForwardArgs, parsePortsArgs, parsePortArg, parseKeptKey, keyProject, resolveManagedMatches, parseNetcagePsJson, parseNetcagePortsJson, forwardablePorts, formatPortsHint, isHeadlessPiArgs, resumeSessionId, sessionHeaderCwd, anonPiVersion, parseMachineArgs, parseMachineJson, projectHostDir, resolveAnonPiHome, resolveLlm, resolveProjectsRoot, resolveProxy, resolveRunPlan, resolveRunVsStart, serializeMachineJson, snapshotImageRef, snapshotSessionGroups, copyIncludesForHomeMinusSessions, serializeConfigJson, setImageWarning, keptContainerKey, DEFAULT_SOCKS_PROBE_PORTS, SOCKS5_METHOD_SELECTOR, formatProxyFindings, interpretSocks5Handshake, initImageMenu, generateModelsJson, generateModelSelection, pickLocalProviderModels, parseModelsListing, mergeModelSources, resolveHostModelsPath, LOCAL_PROVIDER_API_KEY, parseVerifyExitIp, processHint, socks5hUrl, hostPortKey, shippedDockerfilePath, shippedWebveilDockerfilePath, } from './anon-pi.js';
18
- // The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
19
- // so a `--keep` re-entry can find and `netcage start` the same kept container.
20
- // netcage's `netcage.managed` label marks it a managed container; this adds the
21
- // anon-pi identity ON TOP (netcage's label IS the registry; anon-pi adds no file).
17
+ import { AnonPiError, HELP, MODELS_FILE, SETTINGS_FILE, SETTINGS_SEED_FILE, SEED_MARKER, DEFAULT_MACHINE, envFromProcess, buildMenuChoiceList, buildMenuEntries, builtinProjectsRoot, deriveProjectUsage, expandTilde, findingsFromNetcageDetect, processNoteFromNetcageDetect, resolveNetcageGraphroot, globalModelsSeedPath, globalSettingsSeedPath, machineAgentDir, machineDir, machineHomeDir, machineJsonPath, machineModelsSeedPath, machineSessionsDir, mergeModelSelection, resolveModelsSeedPath, resolveSettingsSeedPath, validateName, resolveDeleteHome, resolveDeleteProject, parseConfigJson, parseLaunchArgs, parseForwardArgs, parsePortsArgs, parsePortArg, parseKeptKey, keyProject, resolveManagedMatches, parseNetcagePsJson, parseNetcagePortsJson, forwardablePorts, formatPortsHint, isHeadlessPiArgs, resumeSessionId, sessionHeaderCwd, anonPiVersion, parseMachineArgs, parseMachineJson, projectHostDir, resolveAnonPiHome, resolveLaunchImage, resolveLlm, resolveProjectsRoot, resolveProxy, resolveRunPlan, serializeMachineJson, parseImageArgs, snapshotImageTag, snapshotProvenanceLabels, parseImageProvenance, PROVENANCE_LABEL_SOURCE_MACHINE, snapshotSessionGroups, copyIncludesForHomeMinusSessions, serializeConfigJson, setImageWarning, launchIdentityKey, DEFAULT_SOCKS_PROBE_PORTS, SOCKS5_METHOD_SELECTOR, formatProxyFindings, interpretSocks5Handshake, initImageMenu, generateModelsJson, generateModelSelection, pickLocalProviderModels, parseModelsListing, mergeModelSources, resolveHostModelsPath, LOCAL_PROVIDER_API_KEY, parseVerifyExitIp, processHint, socks5hUrl, hostPortKey, shippedDockerfilePath, shippedWebveilDockerfilePath, } from './anon-pi.js';
18
+ // The netcage label anon-pi stamps its launch-identity key onto (launchIdentityKey)
19
+ // so `forward`/`ports`/`snapshot` can find the RUNNING container by machine +
20
+ // project while it is up. netcage's `netcage.managed` label marks it a managed
21
+ // container; this adds the anon-pi identity ON TOP (netcage's label IS the
22
+ // registry; anon-pi adds no file).
22
23
  const ANON_PI_KEY_LABEL = 'anon-pi.key';
23
24
  function main(argv) {
24
25
  const args = argv.slice(2);
@@ -34,7 +35,13 @@ function main(argv) {
34
35
  // and `anon-pi machine --help` show THEIR help, not the global one). Those
35
36
  // subcommands route to runInit / runMachine, which print INIT_HELP /
36
37
  // MACHINE_HELP respectively.
37
- const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine', 'forward', 'ports']);
38
+ const OWN_HELP_SUBCOMMANDS = new Set([
39
+ 'init',
40
+ 'machine',
41
+ 'image',
42
+ 'forward',
43
+ 'ports',
44
+ ]);
38
45
  if ((args.includes('--help') || args.includes('-h')) &&
39
46
  !OWN_HELP_SUBCOMMANDS.has(args[0] ?? '')) {
40
47
  process.stdout.write(HELP);
@@ -46,6 +53,12 @@ function main(argv) {
46
53
  if (args[0] === 'machine') {
47
54
  return runMachine(args.slice(1));
48
55
  }
56
+ // `image …` is the image-management surface (snapshot/list), dispatched BEFORE
57
+ // the launch grammar so a bare `image` is never parsed as a project named
58
+ // "image" (ADR-0003 §1: snapshot moved off `machine` onto this new noun).
59
+ if (args[0] === 'image') {
60
+ return runImage(args.slice(1));
61
+ }
49
62
  // The destructive cleanup verbs (replacing the old `--fresh`). Dispatched
50
63
  // BEFORE the launch grammar: they are top-level data verbs, not launch flags,
51
64
  // each with the confirm/`--yes`/non-TTY discipline. `--delete-home` takes an
@@ -149,8 +162,32 @@ function runLaunch(parsed) {
149
162
  'of your local model. It is the ONE direct hole; all other egress stays\n' +
150
163
  'forced through the proxy.');
151
164
  }
152
- // The machine's image: machine.json wins, ANON_PI_IMAGE is the fallback.
153
- const image = machineConf.image ?? env.image ?? '';
165
+ // The machine's image, highest-priority first: the EPHEMERAL per-launch
166
+ // `-i`/`--image` override > machine.json.image > ANON_PI_IMAGE. `-i` is
167
+ // strictly ephemeral (it is NEVER written back to machine.json; that pin is
168
+ // `machine set-image` / `machine create --image`) and no mismatch warning is
169
+ // printed (ADR-0003 section 3). `-i` picks the IMAGE; `-m` picks the HOME;
170
+ // they compose.
171
+ const iSet = (parsed.image ?? '').trim().length > 0;
172
+ const home = machineHomeDir(env, machineName);
173
+ // A fresh (unseeded) home has no established image/home baseline yet.
174
+ // Seeding it from the ephemeral `-i` image would poison the home with the
175
+ // wrong-image seed; skipping the seed would run pi unconfigured. So refuse
176
+ // and channel "make this the machine's image" to the explicit machine verb.
177
+ // (An ALREADY-SEEDED home just runs the override image against it; the
178
+ // runtime extension-compat risk is accepted silently, ADR-0003.)
179
+ if (iSet && homeFresh(home)) {
180
+ throw new AnonPiError(`anon-pi: machine ${JSON.stringify(machineName)} has no home yet; \`-i\` is ` +
181
+ `ephemeral (it never seeds the home).\n` +
182
+ `Establish its image first with \`anon-pi machine create ${machineName} ` +
183
+ `--image ${parsed.image}\` (or launch once normally to seed), then use ` +
184
+ `\`-i\` to override per-launch.`);
185
+ }
186
+ const image = resolveLaunchImage({
187
+ override: parsed.image,
188
+ machineImage: machineConf.image,
189
+ envImage: env.image,
190
+ }) ?? '';
154
191
  // --mount re-roots at a HOST parent; otherwise the resolved projects root.
155
192
  // Expand a leading `~` + absolutize the mount path so it is a real host dir
156
193
  // everywhere it is used (the mount, the mkdir, the intent). path.resolve
@@ -164,7 +201,6 @@ function runLaunch(parsed) {
164
201
  machine: machineConf,
165
202
  mountParent,
166
203
  });
167
- const home = machineHomeDir(env, machineName);
168
204
  const machine = { name: machineName, home, image };
169
205
  // The local-model models.json + settings seed for this machine's FRESH-home
170
206
  // promotion. GLOBAL by default (<home>/models.json, shared across every
@@ -179,7 +215,6 @@ function runLaunch(parsed) {
179
215
  project: parsed.project,
180
216
  mountParent,
181
217
  piArgs: parsed.piArgs,
182
- keep: parsed.keep,
183
218
  proxy,
184
219
  llmDirect: llm,
185
220
  modelsSeed,
@@ -237,10 +272,10 @@ function runLaunch(parsed) {
237
272
  }
238
273
  /**
239
274
  * Execute a RESOLVED non-menu LaunchPlan: create the host dirs the mounts need,
240
- * then run netcage (or `netcage start` a matching kept container under --keep).
241
- * Shared by the direct launch path (runLaunch) and the menu dispatch (runMenu),
242
- * so a menu-picked project/here/shell launches BYTE-FOR-BYTE identically to the
243
- * same command typed directly.
275
+ * then run netcage (always a fresh throwaway `run`; ADR-0004). Shared by the
276
+ * direct launch path (runLaunch) and the menu dispatch (runMenu), so a
277
+ * menu-picked project/here/shell launches BYTE-FOR-BYTE identically to the same
278
+ * command typed directly.
244
279
  */
245
280
  function executeLaunchPlan(intent, plan) {
246
281
  // Create the host dirs the mounts need BEFORE spawn: the machine home and,
@@ -258,26 +293,12 @@ function executeLaunchPlan(intent, plan) {
258
293
  // has a real dir to cwd into).
259
294
  mkdirSync(intent.mountParent ?? intent.projectsRoot, { recursive: true });
260
295
  }
261
- // The anon-pi identity key, stamped on EVERY launch (not just --keep) as an
262
- // additive netcage label. Under --keep it lets a re-entry find + `netcage
263
- // start` the kept container; on a throwaway --rm run it lets `anon-pi forward`
296
+ // The anon-pi identity key, stamped on EVERY launch as an additive netcage
297
+ // label. On a throwaway `--rm` run it lets `anon-pi forward`/`ports`/`snapshot`
264
298
  // find the RUNNING container while it is up (the label goes away with the
265
299
  // container on exit). It touches NO egress flag (the RunPlan owns those).
266
- const keyed = withKeyLabel(plan.netcageArgs, keptContainerKey(intent));
267
- // Run-vs-start: under --keep, ask netcage for its kept managed containers and
268
- // resume a matching one via `netcage start`; else run the composed argv. A
269
- // throwaway (`--rm`) launch is always a fresh run (the pure rule never
270
- // consults the listing for it).
271
- if (intent.keep) {
272
- const decision = resolveRunVsStart(intent, queryKeptContainers());
273
- if (decision.action === 'start') {
274
- return spawnNetcage(['start', '-a', '-i', decision.ref], {
275
- enteringJail: true,
276
- });
277
- }
278
- // A fresh `--keep` run: the RunPlan already omits --rm so it is left kept.
279
- return spawnNetcage(keyed, { enteringJail: true });
280
- }
300
+ const keyed = withKeyLabel(plan.netcageArgs, launchIdentityKey(intent));
301
+ // Every launch is a fresh throwaway `run` (the RunPlan always carries --rm).
281
302
  return spawnNetcage(keyed, { enteringJail: true });
282
303
  }
283
304
  // --- the interactive host-side menu (the ONLY untested I/O) -------------------
@@ -524,8 +545,6 @@ function runMachine(machineArgs) {
524
545
  return machineSetImage(env, cmd.name, cmd.image);
525
546
  case 'rm':
526
547
  return machineRm(env, cmd.name, cmd.yes);
527
- case 'snapshot':
528
- return machineSnapshot(env, cmd.name, cmd.machine, cmd.imageTag);
529
548
  }
530
549
  }
531
550
  catch (e) {
@@ -564,6 +583,28 @@ function machineCreate(env, name, image) {
564
583
  writeFileSync(machineJsonPath(env, name), serializeMachineJson({ image: pinned }));
565
584
  process.stdout.write(`anon-pi: created machine ${JSON.stringify(name)} (image ${pinned.trim()}) at ${dir}.\n` +
566
585
  `Its home is seeded on first launch, e.g. \`anon-pi -m ${name} --shell\`.\n`);
586
+ // PROVENANCE-AWARE (ADR-0003 §5): if the pinned image was produced by
587
+ // `image snapshot` (it carries `anon-pi.source-machine=<M>`) AND that source
588
+ // machine's home still exists on disk, OFFER the home-copy (minus sessions) +
589
+ // per-project session carry-over from it, so a machine built from a snapshot
590
+ // inherits the source's config + conversations (opt-in). Absent provenance /
591
+ // source home gone => a plain fresh create (today's behaviour) with a quiet
592
+ // note. Guarded no-TTY inside carryOverHomeFromMachine (copy nothing). This
593
+ // reads the image via netcage inspect; when netcage is absent it is skipped
594
+ // (a create must not require netcage), so provenance carry-over is best-effort.
595
+ if (hasNetcage()) {
596
+ const prov = inspectImageProvenance(pinned.trim());
597
+ const source = prov.sourceMachine;
598
+ if (source !== undefined && existsSync(machineHomeDir(env, source))) {
599
+ process.stderr.write(`anon-pi: image ${pinned.trim()} was snapshotted from machine ${JSON.stringify(source)} ` +
600
+ '(whose home is present); offering to carry its home + conversations over.\n');
601
+ carryOverHomeFromMachine(env, source, name);
602
+ }
603
+ else if (source !== undefined) {
604
+ process.stderr.write(`anon-pi: image ${pinned.trim()} names source machine ${JSON.stringify(source)}, ` +
605
+ 'but its home is gone; created a fresh home.\n');
606
+ }
607
+ }
567
608
  return 0;
568
609
  }
569
610
  /**
@@ -635,29 +676,63 @@ function machineRm(env, name, yes) {
635
676
  process.stdout.write(`anon-pi: removed machine ${JSON.stringify(name)} (${dir}).\n`);
636
677
  return 0;
637
678
  }
679
+ // --- the `image` verbs (ADR-0003): snapshot a running container into a clean
680
+ // image tag with provenance labels, and a read-only list. Thin I/O over the
681
+ // pure parts (parseImageArgs / snapshotImageTag / snapshotProvenanceLabels /
682
+ // parseImageProvenance); netcage does the commit / images / inspect.
638
683
  /**
639
- * `machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: commit a
640
- * RUNNING jailed container into a new image and create <new-name> pinned to it.
641
- * The user does interactive system work in a session (e.g. `sudo apt install`),
642
- * then, WITHOUT having exited (the default `--rm` would have destroyed the
643
- * container), preserves that exact environment as a new machine. The container
644
- * to commit is AUTO-DETECTED from the running anon-pi containers (a picker when
645
- * several are up); `-m <machine>` is an OPTIONAL filter, not a required source.
646
- * podman pauses the container during commit and unpauses, so the live session
647
- * survives. The new machine gets a FRESH home (image and home are orthogonal;
648
- * snapshot keeps the SOFTWARE, not the conversations). Forced egress is
649
- * untouched: commit is a local podman op, and the snapshot machine relaunches
650
- * through the same forced-egress jail.
684
+ * Parse `image <verb> …` (pure parseImageArgs) and dispatch to the snapshot /
685
+ * list I/O. Prints IMAGE_HELP on `--help`/`-h`.
651
686
  */
652
- function machineSnapshot(env, name, machine, imageTag) {
653
- // Refuse to clobber an existing target machine FIRST (before netcage / any
654
- // commit), so a name clash fails fast and never leaves an orphan image.
655
- // Mirrors machine create.
656
- const targetDir = machineDir(env, name);
657
- if (existsSync(targetDir)) {
658
- process.stderr.write(`anon-pi: machine ${JSON.stringify(name)} already exists (${targetDir}). ` +
659
- 'Pick a different <new-name> or `anon-pi machine rm` it first.\n');
660
- return 1;
687
+ function runImage(imageArgs) {
688
+ if (imageArgs.includes('--help') || imageArgs.includes('-h')) {
689
+ process.stdout.write(IMAGE_HELP);
690
+ return 0;
691
+ }
692
+ const env = envFromProcess(process.env);
693
+ let cmd;
694
+ try {
695
+ cmd = parseImageArgs(imageArgs);
696
+ }
697
+ catch (e) {
698
+ return reportAnonPiError(e);
699
+ }
700
+ try {
701
+ switch (cmd.verb) {
702
+ case 'snapshot':
703
+ return imageSnapshot(env, cmd.name, cmd.machine, cmd.createMachine);
704
+ case 'list':
705
+ return imageList();
706
+ }
707
+ }
708
+ catch (e) {
709
+ return reportAnonPiError(e);
710
+ }
711
+ }
712
+ /**
713
+ * `image snapshot <name> [-m <machine>] [--create-machine <m>]`: commit the
714
+ * RUNNING jailed container into the clean tag `anon-pi/<name>:latest`, baking
715
+ * provenance as podman LABELS via `netcage commit -c 'LABEL …'` (ADR-0003 §1+2).
716
+ * The container to commit is AUTO-DETECTED from the running anon-pi containers
717
+ * (a picker when several are up); `-m <machine>` is an OPTIONAL filter, not a
718
+ * required source. podman pauses the container during commit and unpauses, so
719
+ * the live session survives. A same-name re-snapshot OVERWRITES the `:latest`
720
+ * tag (the previous image becomes dangling but keeps its provenance label).
721
+ * `--create-machine <m>` ALSO creates machine <m> from the fresh snapshot,
722
+ * running the home-copy + per-project session carry-over. Forced egress is
723
+ * untouched (commit is a local podman op).
724
+ */
725
+ function imageSnapshot(env, name, machine, createMachine) {
726
+ // If --create-machine names an EXISTING machine, refuse FIRST (before netcage /
727
+ // any commit), so a name clash fails fast (mirrors machine create). The
728
+ // snapshot itself has no such clash: it overwrites its `:latest` tag by design.
729
+ if (createMachine !== undefined) {
730
+ const targetDir = machineDir(env, createMachine);
731
+ if (existsSync(targetDir)) {
732
+ process.stderr.write(`anon-pi: machine ${JSON.stringify(createMachine)} already exists (${targetDir}). ` +
733
+ 'Pick a different --create-machine name or `anon-pi machine rm` it first.\n');
734
+ return 1;
735
+ }
661
736
  }
662
737
  if (!hasNetcage())
663
738
  return netcageMissing();
@@ -666,38 +741,102 @@ function machineSnapshot(env, name, machine, imageTag) {
666
741
  const target = resolveRunningContainer(machine, 'snapshot');
667
742
  if (target === undefined)
668
743
  return 1;
669
- const imageRef = imageTag ?? snapshotImageRef(name, new Date());
670
- process.stderr.write(`anon-pi: committing ${target.name} -> image ${imageRef} (pausing the container briefly)\u2026\n`);
671
- const committed = spawnNetcage(['commit', target.ref, imageRef]);
744
+ const tag = snapshotImageTag(name);
745
+ // Provenance (ADR-0003 §2), all best-effort HISTORY:
746
+ // - source-machine: the committed container's machine, from its stamped key
747
+ // (parseKeptKey.machine, authoritative).
748
+ // - source-image: what the snapshot is ACTUALLY built on, read from the
749
+ // RUNNING CONTAINER via inspect (NOT machine.json: `-i` makes the container's
750
+ // image diverge from the machine's pin). Fall back to machine.json.image if
751
+ // the inspect misses; OMIT the label if neither is known.
752
+ // - snapshot-at: now, ISO 8601.
753
+ const sourceMachine = parseKeptKey(target.key).machine;
754
+ const sourceImage = inspectContainerImage(target.ref) ??
755
+ (sourceMachine !== undefined
756
+ ? readMachineJson(env, sourceMachine).image
757
+ : undefined);
758
+ const labels = snapshotProvenanceLabels({
759
+ sourceMachine,
760
+ sourceImage,
761
+ at: new Date().toISOString(),
762
+ });
763
+ process.stderr.write(`anon-pi: committing ${target.name} -> image ${tag} (pausing the container briefly)\u2026\n`);
764
+ // One `-c 'LABEL k=v'` per provenance label (each is one argv element; podman
765
+ // round-trips `/` and `:` in the value un-quoted, verified).
766
+ const commitArgs = ['commit'];
767
+ for (const label of labels)
768
+ commitArgs.push('-c', label);
769
+ commitArgs.push(target.ref, tag);
770
+ const committed = spawnNetcage(commitArgs);
672
771
  if (committed !== 0) {
673
- process.stderr.write(`anon-pi: netcage commit failed; machine ${JSON.stringify(name)} NOT created.\n`);
772
+ process.stderr.write(`anon-pi: netcage commit failed; image ${tag} NOT written.\n`);
674
773
  return committed;
675
774
  }
676
- // Create the machine pinned to the committed image.
677
- mkdirSync(machineHomeDir(env, name), { recursive: true });
678
- writeFileSync(machineJsonPath(env, name), serializeMachineJson({ image: imageRef }));
679
- // The source machine is the machine of the container we committed (its stamped
680
- // key carries it). Copy its home into the new machine's home, EXCEPT the
681
- // sessions subtree (conversations are handled separately below). Copying the
682
- // config/extensions is safe + preferable to a fresh seed here: the new image IS
683
- // the committed source filesystem, so the home's extensions/binaries are
684
- // correct-for-the-new-image (and the copied seed marker means no reseed).
685
- const sourceMachine = parseKeptKey(target.key).machine;
686
- if (sourceMachine !== undefined) {
687
- copyHomeMinusSessions(env, sourceMachine, name);
688
- }
689
- process.stdout.write(`anon-pi: snapshotted ${target.name} into machine ${JSON.stringify(name)} ` +
690
- `(image ${imageRef}) at ${targetDir}.\n` +
775
+ process.stdout.write(`anon-pi: snapshotted ${target.name} into image ${tag}` +
691
776
  (sourceMachine !== undefined
692
- ? `Copied ${JSON.stringify(sourceMachine)}'s home (config + extensions) into it.\n`
693
- : 'Its home seeds fresh on first launch.\n'));
694
- // Offer the source's conversation history, grouped by project, opt-in per
695
- // project (default none). TTY only; a non-TTY snapshot carries no sessions.
696
- if (sourceMachine !== undefined) {
697
- carryOverSessions(env, sourceMachine, name);
777
+ ? ` (from machine ${JSON.stringify(sourceMachine)}).\n`
778
+ : '.\n'));
779
+ // --create-machine: create the machine from the fresh snapshot, running the
780
+ // same home-copy + per-project session carry-over the 0.15 snapshot did. The
781
+ // source machine is directly known (we just committed its container), so the
782
+ // shared helper is called with it.
783
+ if (createMachine !== undefined) {
784
+ mkdirSync(machineHomeDir(env, createMachine), { recursive: true });
785
+ writeFileSync(machineJsonPath(env, createMachine), serializeMachineJson({ image: tag }));
786
+ process.stdout.write(`anon-pi: created machine ${JSON.stringify(createMachine)} pinned to ${tag}.\n`);
787
+ if (sourceMachine !== undefined) {
788
+ carryOverHomeFromMachine(env, sourceMachine, createMachine);
789
+ }
790
+ else {
791
+ process.stderr.write('anon-pi: the committed container has no source machine; the new home seeds fresh on first launch.\n');
792
+ }
793
+ }
794
+ return 0;
795
+ }
796
+ /**
797
+ * `image list`: read-only; list anon-pi images with their provenance. ZERO
798
+ * stored state. Includes an image if it is `anon-pi/*`-tagged OR (even when
799
+ * DANGLING/untagged) it carries an `anon-pi.source-machine` label, so an
800
+ * ORPHANED snapshot (its `:latest` tag overwritten by a re-snapshot) is still
801
+ * shown by its ID. Prints `<name-or-<none>> from machine <M> <when> id:<short>`.
802
+ */
803
+ function imageList() {
804
+ if (!hasNetcage())
805
+ return netcageMissing();
806
+ const images = queryAnonPiImages();
807
+ if (images.length === 0) {
808
+ process.stdout.write('anon-pi: no anon-pi images yet. Create one with `anon-pi image snapshot <name>`.\n');
809
+ return 0;
810
+ }
811
+ for (const img of images) {
812
+ const prov = parseImageProvenance(img.labels);
813
+ const nameCol = img.tag ?? '<none>';
814
+ const fromCol = prov.sourceMachine !== undefined
815
+ ? `from machine ${prov.sourceMachine}`
816
+ : 'from machine <unknown>';
817
+ const whenCol = prov.snapshotAt ?? '<unknown>';
818
+ const idCol = `id:${img.id.slice(0, 12)}`;
819
+ process.stdout.write(`${nameCol} ${fromCol} ${whenCol} ${idCol}\n`);
698
820
  }
699
821
  return 0;
700
822
  }
823
+ /**
824
+ * Shared home carry-over from a source machine to a dest machine (ADR-0003): the
825
+ * home-minus-sessions copy (copyHomeMinusSessions) + the interactive per-project
826
+ * session picker (carryOverSessions). Both the `image snapshot --create-machine`
827
+ * path and the provenance-aware `machine create --image` path call this; they
828
+ * differ ONLY in how they learn `sourceMachine`. Honors the no-TTY "copy
829
+ * nothing" rule already in carryOverSessions (a scripted create stays
830
+ * non-blocking). A no-op message-wise when the source home is absent.
831
+ */
832
+ function carryOverHomeFromMachine(env, sourceMachine, destMachine) {
833
+ if (existsSync(machineHomeDir(env, sourceMachine))) {
834
+ copyHomeMinusSessions(env, sourceMachine, destMachine);
835
+ process.stderr.write(`anon-pi: copied ${JSON.stringify(sourceMachine)}'s home (config + extensions) into ` +
836
+ `${JSON.stringify(destMachine)} (minus conversations).\n`);
837
+ }
838
+ carryOverSessions(env, sourceMachine, destMachine);
839
+ }
701
840
  /**
702
841
  * Recursively copy machine <source>'s home into machine <dest>'s home, EXCLUDING
703
842
  * the `.pi/agent/sessions/` subtree (conversations are carried over separately,
@@ -1794,29 +1933,50 @@ USAGE
1794
1933
  anon-pi machine list list machines and their images
1795
1934
  anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
1796
1935
  anon-pi machine rm <name> [--yes] delete the machine + its home
1797
- anon-pi machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]
1798
- commit a RUNNING container into a
1799
- new image + create <new-name>
1800
1936
 
1801
1937
  A machine is an image + a persistent host home (machines/<name>/{machine.json,home/}).
1802
1938
  The home is seeded on FIRST LAUNCH, not at create. \`set-image\` re-pins only and
1803
1939
  warns (the home was built for the old image); \`rm\` confirms on a TTY, skips with
1804
1940
  \`--yes\`, and aborts non-interactively without it.
1805
1941
 
1942
+ \`create --image <ref>\` is PROVENANCE-AWARE: if <ref> was produced by
1943
+ \`anon-pi image snapshot\` (it carries an \`anon-pi.source-machine\` label) AND
1944
+ that machine's home still exists, you are OFFERED its home + conversations to
1945
+ carry over (opt-in, no TTY => nothing copied). Otherwise a plain fresh create.
1946
+
1947
+ To SNAPSHOT a running container into an image, use \`anon-pi image snapshot\`
1948
+ (the verb moved off \`machine\` onto the \`image\` noun).
1949
+ `;
1950
+ /** The `image` subcommand help. */
1951
+ const IMAGE_HELP = `anon-pi image - snapshot a running container into an image, and list anon-pi images
1952
+
1953
+ USAGE
1954
+ anon-pi image snapshot <name> [-m <machine>] [--create-machine <m>]
1955
+ commit the RUNNING container into anon-pi/<name>:latest
1956
+ anon-pi image list list anon-pi images with their provenance (read-only)
1957
+
1806
1958
  \`snapshot\` captures the CURRENT filesystem of a RUNNING jailed container (e.g.
1807
- after \`sudo apt install\`) into a new image and creates <new-name> pinned to it,
1808
- so you can preserve an environment you built interactively WITHOUT having
1809
- pre-decided \`--keep\`. The container is auto-detected from the running anon-pi
1810
- containers (a picker when several are up); \`-m <machine>\` is an OPTIONAL filter,
1811
- not a required source. The container must still be RUNNING (do not exit the
1812
- session); podman pauses it briefly during the commit. Same forced-egress jail on
1813
- relaunch.
1959
+ after \`sudo apt install\`) into the clean tag \`anon-pi/<name>:latest\`, baking
1960
+ provenance as podman labels (source machine, source image, snapshot time). This
1961
+ is how you keep container-level system changes (every launch is throwaway):
1962
+ freeze the running box into a named image, then pin a machine to it. The
1963
+ container is auto-detected from the running anon-pi containers (a picker when
1964
+ several are up); \`-m <machine>\` is an OPTIONAL filter, not a required source.
1965
+ The container must still be RUNNING (do not exit the session); podman pauses it
1966
+ briefly during the commit. A same-name re-snapshot OVERWRITES the \`:latest\` tag
1967
+ (the previous image becomes dangling but keeps its provenance, so \`image list\`
1968
+ still shows it by ID). To preserve a specific snapshot, snapshot it under a
1969
+ different name.
1970
+
1971
+ \`--create-machine <m>\` ALSO creates machine <m> pinned to the fresh snapshot,
1972
+ copying the source machine's HOME (config + extensions + dotfiles) MINUS its
1973
+ conversations, then offering the conversations separately (grouped BY PROJECT,
1974
+ opt-in per project, default SKIP; no TTY => none copied). This is equivalent to
1975
+ \`image snapshot\` followed by a provenance-aware \`machine create --image\`.
1814
1976
 
1815
- The source machine's HOME is copied into the new machine (config + extensions +
1816
- dotfiles), MINUS its conversations. Conversations are offered separately, grouped
1817
- BY PROJECT, opt-in per project (default SKIP), COPY or SKIP each (no TTY => none
1818
- copied). After copying, one confirmed step (default No) can DELETE the copied
1819
- groups from the source machine (the only "move"); COPY never touches the source.
1977
+ \`list\` reads the provenance labels straight off the images (ZERO stored state):
1978
+ it shows every \`anon-pi/*\` image plus any dangling image still carrying an
1979
+ \`anon-pi.source-machine\` label (an orphaned snapshot), by its ID.
1820
1980
  `;
1821
1981
  // --- impure helpers ---------------------------------------------------------
1822
1982
  /** Read + parse <anon-pi-home>/config.json (tolerant: absent/garbage => {}). */
@@ -1848,23 +2008,9 @@ function homeFresh(machineHome) {
1848
2008
  const marker = join(machineHome, '.pi', 'agent', SEED_MARKER);
1849
2009
  return !existsSync(marker);
1850
2010
  }
1851
- /**
1852
- * Query netcage for its KEPT managed containers, surfacing each one's stamped
1853
- * anon-pi identity key so the pure run-vs-start decision can match it. Thin,
1854
- * best-effort I/O: on any failure (netcage missing the query, no containers, a
1855
- * parse error) it returns an EMPTY listing, so the decision falls back to a
1856
- * fresh `run` (safe: it never wrongly resumes, it just creates a new container).
1857
- */
1858
- function queryKeptContainers() {
1859
- // Ask netcage for ALL its managed containers as JSON (netcage >= 0.10.0
1860
- // forwards podman's --format json over its managed scope), then keep the ones
1861
- // carrying an anon-pi.key label (a sidecar has none) and decode it. -a so a
1862
- // STOPPED kept container is included (run-vs-start resumes it).
1863
- return queryManagedContainers({ all: true }).map(({ key, ref }) => ({ key, ref }));
1864
- }
1865
2011
  /**
1866
2012
  * Decode a base64 anon-pi.key label back to its identity key (the reverse of
1867
- * withKeyLabel's encode; keptContainerKey embeds newlines, so it is base64'd to
2013
+ * withKeyLabel's encode; launchIdentityKey embeds newlines, so it is base64'd to
1868
2014
  * stay a single safe label value). undefined on a decode error.
1869
2015
  */
1870
2016
  function decodeKeyLabel(raw) {
@@ -1921,6 +2067,138 @@ function queryNetcagePorts(ref) {
1921
2067
  return [];
1922
2068
  return parseNetcagePortsJson(res.stdout);
1923
2069
  }
2070
+ // --- `image`: read a container/image's provenance from netcage inspect --------
2071
+ /**
2072
+ * Best-effort: the image ref a RUNNING container is ACTUALLY built on, via
2073
+ * `netcage inspect <ref> --format '{{.ImageName}}'`. Used to bake the
2074
+ * `anon-pi.source-image` label (the container's image can diverge from the
2075
+ * machine's pin when `-i` was passed). undefined on any miss (older netcage, a
2076
+ * parse/format hiccup): the caller falls back to machine.json.image, then omits
2077
+ * the label. NEVER throws.
2078
+ */
2079
+ function inspectContainerImage(ref) {
2080
+ const res = spawnSync('netcage', ['inspect', ref, '--format', '{{.ImageName}}'], { encoding: 'utf8' });
2081
+ if (res.error || res.status !== 0 || !res.stdout)
2082
+ return undefined;
2083
+ const out = res.stdout.trim();
2084
+ return out === '' || out === '<no value>' ? undefined : out;
2085
+ }
2086
+ /**
2087
+ * Best-effort: the anon-pi provenance an IMAGE ref carries, via `netcage inspect
2088
+ * <ref> --format '{{json .Config.Labels}}'` parsed through the pure
2089
+ * parseImageProvenance. Used by provenance-aware `machine create`. Empty
2090
+ * provenance (all fields undefined) on any miss (older netcage, no labels, a
2091
+ * parse hiccup). NEVER throws.
2092
+ */
2093
+ function inspectImageProvenance(ref) {
2094
+ const labels = inspectLabels(ref);
2095
+ return parseImageProvenance(labels);
2096
+ }
2097
+ /**
2098
+ * Best-effort: an image/container's label map via `netcage inspect <ref>
2099
+ * --format '{{json .Config.Labels}}'`. null on any miss / unparseable output, so
2100
+ * the pure parseImageProvenance sees an absent map (all fields undefined).
2101
+ */
2102
+ function inspectLabels(ref) {
2103
+ const res = spawnSync('netcage', ['inspect', ref, '--format', '{{json .Config.Labels}}'], { encoding: 'utf8' });
2104
+ if (res.error || res.status !== 0 || !res.stdout)
2105
+ return null;
2106
+ const text = res.stdout.trim();
2107
+ if (text === '' || text === 'null' || text === '<no value>')
2108
+ return null;
2109
+ try {
2110
+ const parsed = JSON.parse(text);
2111
+ return parsed !== null && typeof parsed === 'object'
2112
+ ? parsed
2113
+ : null;
2114
+ }
2115
+ catch {
2116
+ return null;
2117
+ }
2118
+ }
2119
+ /**
2120
+ * Best-effort: the anon-pi images in netcage's store for `image list`. Reads
2121
+ * `netcage images --format json`, keeps an image if it is `anon-pi/*`-tagged OR
2122
+ * (even dangling/untagged) it carries an `anon-pi.source-machine` label (so an
2123
+ * orphaned snapshot is still shown by its ID), reading each candidate's labels
2124
+ * via inspect. ZERO stored state. [] on any failure (older netcage, a parse
2125
+ * miss), so `image list` reports "no images" cleanly rather than crashing.
2126
+ */
2127
+ function queryAnonPiImages() {
2128
+ const res = spawnSync('netcage', ['images', '--format', 'json'], {
2129
+ encoding: 'utf8',
2130
+ });
2131
+ if (res.error || res.status !== 0 || !res.stdout)
2132
+ return [];
2133
+ let parsed;
2134
+ try {
2135
+ parsed = JSON.parse(res.stdout);
2136
+ }
2137
+ catch {
2138
+ return [];
2139
+ }
2140
+ if (!Array.isArray(parsed))
2141
+ return [];
2142
+ const out = [];
2143
+ const seen = new Set();
2144
+ for (const raw of parsed) {
2145
+ if (raw === null || typeof raw !== 'object')
2146
+ continue;
2147
+ const rec = raw;
2148
+ const id = firstString(rec['Id'], rec['ID'], rec['id']);
2149
+ if (id === undefined || seen.has(id))
2150
+ continue;
2151
+ const tags = imageTags(rec);
2152
+ const anonTag = tags.find((t) => t.startsWith('anon-pi/'));
2153
+ // An anon-pi/*-tagged image always qualifies; else inspect it for the
2154
+ // source-machine label (an orphaned/dangling snapshot still qualifies).
2155
+ if (anonTag === undefined) {
2156
+ if (tags.length > 0)
2157
+ continue; // a non-anon-pi tagged image is never ours.
2158
+ const labels = inspectLabels(id);
2159
+ if (labels === null ||
2160
+ typeof labels[PROVENANCE_LABEL_SOURCE_MACHINE] !== 'string')
2161
+ continue;
2162
+ seen.add(id);
2163
+ out.push({ id, labels });
2164
+ continue;
2165
+ }
2166
+ seen.add(id);
2167
+ out.push({ id, tag: anonTag, labels: inspectLabels(id) });
2168
+ }
2169
+ return out;
2170
+ }
2171
+ /** First defined string among the candidates (tolerant field-name reader). */
2172
+ function firstString(...vals) {
2173
+ for (const v of vals) {
2174
+ if (typeof v === 'string' && v.trim() !== '')
2175
+ return v;
2176
+ }
2177
+ return undefined;
2178
+ }
2179
+ /**
2180
+ * The repository tags on an image record, tolerant of netcage/podman's field
2181
+ * shapes: `Names`/`RepoTags`/`Tags` (an array of `repo:tag`), or a single
2182
+ * `Repository`+`Tag` pair. `<none>:<none>` entries (dangling) are dropped.
2183
+ */
2184
+ function imageTags(rec) {
2185
+ const tags = [];
2186
+ for (const key of ['Names', 'RepoTags', 'Tags']) {
2187
+ const v = rec[key];
2188
+ if (Array.isArray(v)) {
2189
+ for (const t of v) {
2190
+ if (typeof t === 'string' && t !== '' && !t.startsWith('<none>'))
2191
+ tags.push(t);
2192
+ }
2193
+ }
2194
+ }
2195
+ const repo = firstString(rec['Repository']);
2196
+ const tag = firstString(rec['Tag']);
2197
+ if (repo !== undefined && repo !== '<none>' && tag !== undefined) {
2198
+ tags.push(`${repo}:${tag}`);
2199
+ }
2200
+ return tags;
2201
+ }
1924
2202
  /**
1925
2203
  * Resolve the ONE running anon-pi container a forward/ports should act on:
1926
2204
  * filter the running managed containers by machine (+ project if given), then
@@ -2147,9 +2425,10 @@ function netcageMissing() {
2147
2425
  }
2148
2426
  /**
2149
2427
  * Insert the anon-pi identity label into a `netcage run` argv (right after
2150
- * `run`), so a kept container can be found on re-entry. The key is base64'd
2151
- * (keptContainerKey embeds newlines) to keep it a single safe label value. This
2152
- * is ADDITIVE and touches NO egress flag (the RunPlan owns --proxy/--allow-direct).
2428
+ * `run`), so `forward`/`ports`/`snapshot` can find the RUNNING container by
2429
+ * machine + project. The key is base64'd (launchIdentityKey embeds newlines) to
2430
+ * keep it a single safe label value. This is ADDITIVE and touches NO egress flag
2431
+ * (the RunPlan owns --proxy/--allow-direct).
2153
2432
  */
2154
2433
  function withKeyLabel(netcageArgs, key) {
2155
2434
  const enc = Buffer.from(key, 'utf8').toString('base64');