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/README.md +34 -16
- package/dist/anon-pi.d.ts +192 -110
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +285 -130
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +473 -99
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/anon-pi.ts +386 -200
- package/src/cli.ts +550 -109
package/dist/cli.js
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
10
10
|
// The forced-egress invariant is the RunPlan's guarantee: the composed argv
|
|
11
11
|
// ALWAYS carries --proxy + the one --allow-direct; the CLI never strips or adds
|
|
12
12
|
// egress.
|
|
13
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
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,
|
|
18
|
-
// The netcage label anon-pi stamps its launch-identity key onto (
|
|
19
|
-
// so
|
|
20
|
-
// netcage's `netcage.managed` label marks it a managed
|
|
21
|
-
// anon-pi identity ON TOP (netcage's label IS the
|
|
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([
|
|
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
|
|
153
|
-
|
|
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 (
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
|
262
|
-
//
|
|
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,
|
|
267
|
-
//
|
|
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
|
-
* `
|
|
640
|
-
*
|
|
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
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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,22 +741,176 @@ function machineSnapshot(env, name, machine, imageTag) {
|
|
|
666
741
|
const target = resolveRunningContainer(machine, 'snapshot');
|
|
667
742
|
if (target === undefined)
|
|
668
743
|
return 1;
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
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;
|
|
772
|
+
process.stderr.write(`anon-pi: netcage commit failed; image ${tag} NOT written.\n`);
|
|
674
773
|
return committed;
|
|
675
774
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
775
|
+
process.stdout.write(`anon-pi: snapshotted ${target.name} into image ${tag}` +
|
|
776
|
+
(sourceMachine !== undefined
|
|
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
|
+
}
|
|
683
794
|
return 0;
|
|
684
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`);
|
|
820
|
+
}
|
|
821
|
+
return 0;
|
|
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
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Recursively copy machine <source>'s home into machine <dest>'s home, EXCLUDING
|
|
842
|
+
* the `.pi/agent/sessions/` subtree (conversations are carried over separately,
|
|
843
|
+
* per-project, opt-in). Best-effort: an absent source home is a no-op (the dest
|
|
844
|
+
* just stays fresh). Uses cpSync with a filter that rejects the sessions dir and
|
|
845
|
+
* anything under it.
|
|
846
|
+
*/
|
|
847
|
+
function copyHomeMinusSessions(env, source, dest) {
|
|
848
|
+
const srcHome = machineHomeDir(env, source);
|
|
849
|
+
if (!existsSync(srcHome))
|
|
850
|
+
return;
|
|
851
|
+
const destHome = machineHomeDir(env, dest);
|
|
852
|
+
const sessionsPath = machineSessionsDir(env, source);
|
|
853
|
+
cpSync(srcHome, destHome, {
|
|
854
|
+
recursive: true,
|
|
855
|
+
// Exclude the sessions dir itself and everything beneath it (pure predicate).
|
|
856
|
+
filter: (src) => copyIncludesForHomeMinusSessions(src, sessionsPath),
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Offer the source machine's pi conversation history (grouped BY PROJECT) as an
|
|
861
|
+
* opt-in carry-over into the new machine. Each present `sessions/<slug>/` group
|
|
862
|
+
* is a project row (or an orphan-slug row); DEFAULT all UNSELECTED, per-project
|
|
863
|
+
* COPY or SKIP. Copy duplicates that session dir into the new home. There is NO
|
|
864
|
+
* per-row move; after the copies, ONE confirmed (default No) step can delete the
|
|
865
|
+
* copied groups from the SOURCE home (the only "move"). No-TTY: copy nothing.
|
|
866
|
+
*/
|
|
867
|
+
function carryOverSessions(env, source, dest) {
|
|
868
|
+
const presentSlugs = readDirNames(machineSessionsDir(env, source));
|
|
869
|
+
if (presentSlugs.length === 0)
|
|
870
|
+
return;
|
|
871
|
+
if (!process.stdin.isTTY) {
|
|
872
|
+
process.stderr.write(`anon-pi: ${presentSlugs.length} conversation group(s) on ${JSON.stringify(source)} ` +
|
|
873
|
+
'were NOT copied (no TTY to choose). The new machine starts with no history.\n');
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
// Label rows by project name (matching the machine-invariant slug); an orphan
|
|
877
|
+
// slug with no current project folder is still offered by its raw slug.
|
|
878
|
+
const config = readJsonConfig(env);
|
|
879
|
+
const projectsRoot = resolveProjectsRoot({ env, config });
|
|
880
|
+
const groups = snapshotSessionGroups({
|
|
881
|
+
presentSlugs,
|
|
882
|
+
projects: readDirNames(projectsRoot),
|
|
883
|
+
});
|
|
884
|
+
process.stderr.write(`anon-pi: ${JSON.stringify(source)} has ${groups.length} conversation group(s) ` +
|
|
885
|
+
'(by project). Choose COPY or SKIP for each (default SKIP):\n');
|
|
886
|
+
const copied = [];
|
|
887
|
+
for (const g of groups) {
|
|
888
|
+
const ans = promptLine(` ${g.label} [copy/SKIP]: `);
|
|
889
|
+
if (ans !== undefined && /^c(opy)?$/i.test(ans.trim())) {
|
|
890
|
+
const from = join(machineSessionsDir(env, source), g.slug);
|
|
891
|
+
const to = join(machineSessionsDir(env, dest), g.slug);
|
|
892
|
+
mkdirSync(machineSessionsDir(env, dest), { recursive: true });
|
|
893
|
+
cpSync(from, to, { recursive: true });
|
|
894
|
+
copied.push(g);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (copied.length === 0) {
|
|
898
|
+
process.stderr.write('anon-pi: no conversation groups copied.\n');
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
process.stderr.write(`anon-pi: copied ${copied.length} conversation group(s) into ${JSON.stringify(dest)}.\n`);
|
|
902
|
+
// The ONLY "move": an explicit, confirmed, default-No delete from the SOURCE.
|
|
903
|
+
const ans = promptLine(`Also DELETE the ${copied.length} copied group(s) from source machine ${JSON.stringify(source)}? [y/N] `);
|
|
904
|
+
if (ans !== undefined && /^y(es)?$/i.test(ans.trim())) {
|
|
905
|
+
for (const g of copied) {
|
|
906
|
+
rmSync(join(machineSessionsDir(env, source), g.slug), {
|
|
907
|
+
recursive: true,
|
|
908
|
+
force: true,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
process.stderr.write(`anon-pi: removed ${copied.length} group(s) from ${JSON.stringify(source)}.\n`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
685
914
|
// --- the destructive cleanup verbs (thin I/O over the pure resolvers) --------
|
|
686
915
|
//
|
|
687
916
|
// `--delete-home [<machine>]` and `--delete-project <project>` REPLACE the old
|
|
@@ -1704,24 +1933,50 @@ USAGE
|
|
|
1704
1933
|
anon-pi machine list list machines and their images
|
|
1705
1934
|
anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
|
|
1706
1935
|
anon-pi machine rm <name> [--yes] delete the machine + its home
|
|
1707
|
-
anon-pi machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]
|
|
1708
|
-
commit a RUNNING container into a
|
|
1709
|
-
new image + create <new-name>
|
|
1710
1936
|
|
|
1711
1937
|
A machine is an image + a persistent host home (machines/<name>/{machine.json,home/}).
|
|
1712
1938
|
The home is seeded on FIRST LAUNCH, not at create. \`set-image\` re-pins only and
|
|
1713
1939
|
warns (the home was built for the old image); \`rm\` confirms on a TTY, skips with
|
|
1714
1940
|
\`--yes\`, and aborts non-interactively without it.
|
|
1715
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
|
+
|
|
1716
1958
|
\`snapshot\` captures the CURRENT filesystem of a RUNNING jailed container (e.g.
|
|
1717
|
-
after \`sudo apt install\`) into
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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\`.
|
|
1976
|
+
|
|
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.
|
|
1725
1980
|
`;
|
|
1726
1981
|
// --- impure helpers ---------------------------------------------------------
|
|
1727
1982
|
/** Read + parse <anon-pi-home>/config.json (tolerant: absent/garbage => {}). */
|
|
@@ -1753,23 +2008,9 @@ function homeFresh(machineHome) {
|
|
|
1753
2008
|
const marker = join(machineHome, '.pi', 'agent', SEED_MARKER);
|
|
1754
2009
|
return !existsSync(marker);
|
|
1755
2010
|
}
|
|
1756
|
-
/**
|
|
1757
|
-
* Query netcage for its KEPT managed containers, surfacing each one's stamped
|
|
1758
|
-
* anon-pi identity key so the pure run-vs-start decision can match it. Thin,
|
|
1759
|
-
* best-effort I/O: on any failure (netcage missing the query, no containers, a
|
|
1760
|
-
* parse error) it returns an EMPTY listing, so the decision falls back to a
|
|
1761
|
-
* fresh `run` (safe: it never wrongly resumes, it just creates a new container).
|
|
1762
|
-
*/
|
|
1763
|
-
function queryKeptContainers() {
|
|
1764
|
-
// Ask netcage for ALL its managed containers as JSON (netcage >= 0.10.0
|
|
1765
|
-
// forwards podman's --format json over its managed scope), then keep the ones
|
|
1766
|
-
// carrying an anon-pi.key label (a sidecar has none) and decode it. -a so a
|
|
1767
|
-
// STOPPED kept container is included (run-vs-start resumes it).
|
|
1768
|
-
return queryManagedContainers({ all: true }).map(({ key, ref }) => ({ key, ref }));
|
|
1769
|
-
}
|
|
1770
2011
|
/**
|
|
1771
2012
|
* Decode a base64 anon-pi.key label back to its identity key (the reverse of
|
|
1772
|
-
* withKeyLabel's encode;
|
|
2013
|
+
* withKeyLabel's encode; launchIdentityKey embeds newlines, so it is base64'd to
|
|
1773
2014
|
* stay a single safe label value). undefined on a decode error.
|
|
1774
2015
|
*/
|
|
1775
2016
|
function decodeKeyLabel(raw) {
|
|
@@ -1826,6 +2067,138 @@ function queryNetcagePorts(ref) {
|
|
|
1826
2067
|
return [];
|
|
1827
2068
|
return parseNetcagePortsJson(res.stdout);
|
|
1828
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
|
+
}
|
|
1829
2202
|
/**
|
|
1830
2203
|
* Resolve the ONE running anon-pi container a forward/ports should act on:
|
|
1831
2204
|
* filter the running managed containers by machine (+ project if given), then
|
|
@@ -2052,9 +2425,10 @@ function netcageMissing() {
|
|
|
2052
2425
|
}
|
|
2053
2426
|
/**
|
|
2054
2427
|
* Insert the anon-pi identity label into a `netcage run` argv (right after
|
|
2055
|
-
* `run`), so
|
|
2056
|
-
*
|
|
2057
|
-
* is ADDITIVE and touches NO egress flag
|
|
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).
|
|
2058
2432
|
*/
|
|
2059
2433
|
function withKeyLabel(netcageArgs, key) {
|
|
2060
2434
|
const enc = Buffer.from(key, 'utf8').toString('base64');
|