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