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/anon-pi.js
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
|
import { existsSync, readFileSync } from 'node:fs';
|
|
33
34
|
import { homedir } from 'node:os';
|
|
34
35
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -291,14 +292,29 @@ export function resolveDeleteProject(args) {
|
|
|
291
292
|
*/
|
|
292
293
|
export const ROOT_TOKEN = '.';
|
|
293
294
|
/**
|
|
294
|
-
* Reserved names that a machine/project may NOT take (case-sensitive).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
|
|
301
|
-
|
|
295
|
+
* Reserved names that a machine/project/image may NOT take (case-sensitive).
|
|
296
|
+
* `.` is the root token (see ROOT_TOKEN); `..` is parent-traversal (both are
|
|
297
|
+
* also rejected by the structural checks below, but listed here so the
|
|
298
|
+
* reserved-name concept is explicit). `pi` is the passthrough token. The
|
|
299
|
+
* SUBCOMMAND NOUN words (`machine`, `image`, `init`, `forward`, `ports`) are
|
|
300
|
+
* reserved too: each is dispatched BEFORE the launch grammar, so a folder so
|
|
301
|
+
* named would be UNREACHABLE by bare name (a latent trap). Reserving them makes
|
|
302
|
+
* validateName refuse such a name up front with a clear error, closing the
|
|
303
|
+
* trap. `--mount`'s `/work` is a CONTAINER path, not a name here, so it needs no
|
|
304
|
+
* reservation. The reservation is GLOBAL (validateName is the one validator);
|
|
305
|
+
* the menu tolerates a pre-existing folder now reserved by FILTERING it out via
|
|
306
|
+
* the try/catch isProjectName, so a now-reserved folder is skipped, not a crash.
|
|
307
|
+
*/
|
|
308
|
+
export const RESERVED_NAMES = [
|
|
309
|
+
'.',
|
|
310
|
+
'..',
|
|
311
|
+
'pi',
|
|
312
|
+
'machine',
|
|
313
|
+
'image',
|
|
314
|
+
'init',
|
|
315
|
+
'forward',
|
|
316
|
+
'ports',
|
|
317
|
+
];
|
|
302
318
|
/**
|
|
303
319
|
* PURE: validate a machine or project name as a safe single path segment, and
|
|
304
320
|
* return it unchanged on success. Rejects (with AnonPiError):
|
|
@@ -387,7 +403,7 @@ export function resolveCwd(kind, token) {
|
|
|
387
403
|
* and files written under the home land in the machine's config home on the
|
|
388
404
|
* host; a shell is the project-hopper, so `/projects` is the natural landing.
|
|
389
405
|
* The machine home is one `cd ~` away for the rare case. `menu` never reaches
|
|
390
|
-
* here (it is argv-less). Shared by resolveRunPlan +
|
|
406
|
+
* here (it is argv-less). Shared by resolveRunPlan + launchIdentityKey so the run
|
|
391
407
|
* cwd and the container-identity key always agree.
|
|
392
408
|
*/
|
|
393
409
|
export function launchCwd(_mode, kind, project) {
|
|
@@ -516,6 +532,22 @@ export function resolveProxy(args) {
|
|
|
516
532
|
export function resolveLlm(args) {
|
|
517
533
|
return nonEmpty(args.env.llmDirect) ?? nonEmpty(args.config?.llm);
|
|
518
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* PURE: resolve the IMAGE a launch runs against, highest-priority first:
|
|
537
|
+
* a per-launch `-i`/`--image` override > the machine's pinned image
|
|
538
|
+
* (machine.json) > `ANON_PI_IMAGE` (the env fallback) > undefined (the CLL then
|
|
539
|
+
* errors). The `-i` override is STRICTLY EPHEMERAL: it selects the image for
|
|
540
|
+
* THIS launch only and is NEVER written back to machine.json (that persistent
|
|
541
|
+
* pin is `machine set-image` / `machine create --image`). No mismatch warning is
|
|
542
|
+
* ever emitted (ADR-0003 section 3: `-i` is explicit + ephemeral, so a warning
|
|
543
|
+
* carries no information the user lacks). Empty strings are treated as unset at
|
|
544
|
+
* every tier (nonEmpty), so a blank env/pin falls through cleanly.
|
|
545
|
+
*/
|
|
546
|
+
export function resolveLaunchImage(args) {
|
|
547
|
+
return (nonEmpty(args.override) ??
|
|
548
|
+
nonEmpty(args.machineImage) ??
|
|
549
|
+
nonEmpty(args.envImage));
|
|
550
|
+
}
|
|
519
551
|
// --- Grammar A: the pure argv -> ParsedLaunch parser -------------------------
|
|
520
552
|
//
|
|
521
553
|
// A bare positional is a PROJECT; `-m` picks the machine. The CLI (cli.ts)
|
|
@@ -666,6 +698,24 @@ export function sessionHeaderCwd(headerLine) {
|
|
|
666
698
|
* RESERVED project name (see RESERVED_NAMES) so a project can never shadow it.
|
|
667
699
|
*/
|
|
668
700
|
export const PI_PASSTHROUGH_TOKEN = 'pi';
|
|
701
|
+
/**
|
|
702
|
+
* The retired launch flags. `--keep`/`--rm` are GONE (ADR-0004): every launch is
|
|
703
|
+
* throwaway now, so there is no flag to toggle. `--keep`'s exploratory
|
|
704
|
+
* "apt install, quit, re-enter" use case is served, better, by snapshotting a
|
|
705
|
+
* running container into a named image and pinning a machine to it (explicit +
|
|
706
|
+
* named, no inference). The label a launch is passed one of these RETIRED flags
|
|
707
|
+
* gets a clear error pointing there.
|
|
708
|
+
*/
|
|
709
|
+
export const RETIRED_LAUNCH_FLAGS = ['--keep', '--rm'];
|
|
710
|
+
/** PURE: the error message for a retired `--keep`/`--rm` flag, pointing at the image-based replacement. */
|
|
711
|
+
export function retiredKeepRmMessage(flag) {
|
|
712
|
+
return (`${flag} is gone: every launch is throwaway now (the container is always ` +
|
|
713
|
+
`removed on exit). To persist system state you set up in a session (e.g. after ` +
|
|
714
|
+
`\`apt install\`), snapshot the RUNNING container into a named image and use it:\n` +
|
|
715
|
+
` anon-pi image snapshot <name> (freeze the running container -> anon-pi/<name>:latest)\n` +
|
|
716
|
+
` anon-pi machine create <m> --image anon-pi/<name>:latest (a durable machine pinned to it)\n` +
|
|
717
|
+
`Your pi config + conversations live in the machine home (a host mount) and persist regardless.`);
|
|
718
|
+
}
|
|
669
719
|
/**
|
|
670
720
|
* PURE: whether forwarded pi args request pi's NON-INTERACTIVE (print) mode,
|
|
671
721
|
* i.e. contain `-p`/`--print`. This is the ONLY headless shape (it needs no
|
|
@@ -683,9 +733,6 @@ export function isHeadlessPiArgs(piArgs) {
|
|
|
683
733
|
* `--shell` is incompatible (a shell forwards no pi args).
|
|
684
734
|
*/
|
|
685
735
|
function finishPiNoProjectLaunch(args) {
|
|
686
|
-
if (args.keep && args.rm) {
|
|
687
|
-
args.fail('--keep and --rm are contradictory (pick one; --rm is the default)');
|
|
688
|
-
}
|
|
689
736
|
if (args.shell) {
|
|
690
737
|
args.fail('--shell forwards no pi args (a shell has no session/query). Drop --shell.');
|
|
691
738
|
}
|
|
@@ -695,14 +742,14 @@ function finishPiNoProjectLaunch(args) {
|
|
|
695
742
|
machineExplicit: args.machineExplicit,
|
|
696
743
|
project: undefined,
|
|
697
744
|
mountParent: args.mountParent,
|
|
698
|
-
|
|
745
|
+
image: args.image,
|
|
699
746
|
piArgs: args.piArgs,
|
|
700
747
|
};
|
|
701
748
|
}
|
|
702
749
|
/**
|
|
703
750
|
* PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
|
|
704
|
-
* (`-m <machine>`, `--shell`, `--mount <parent>`,
|
|
705
|
-
* project positional; the FIRST bare positional is the project (`.` allowed as
|
|
751
|
+
* (`-m <machine>`, `--shell`, `--mount <parent>`, `-i`/`--image <ref>`) LEFT of
|
|
752
|
+
* the project positional; the FIRST bare positional is the project (`.` allowed as
|
|
706
753
|
* the root token). In pi mode every token AFTER the project is forwarded to pi
|
|
707
754
|
* verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
|
|
708
755
|
* the project. A pi session-resume flag (`--session <id>`, `--continue`,
|
|
@@ -715,15 +762,14 @@ function finishPiNoProjectLaunch(args) {
|
|
|
715
762
|
* reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
|
|
716
763
|
* distinct from the project-name namespace (NAME vs `--mount` exclusivity), so
|
|
717
764
|
* it is NOT name-validated here. Throws AnonPiError for an unknown option, a
|
|
718
|
-
* missing `-m`/`--mount` argument, a
|
|
765
|
+
* missing `-m`/`--mount` argument, a RETIRED `--keep`/`--rm` flag, or a bad name.
|
|
719
766
|
*/
|
|
720
767
|
export function parseLaunchArgs(args) {
|
|
721
768
|
let machine = DEFAULT_MACHINE;
|
|
722
769
|
let machineSet = false;
|
|
723
770
|
let shell = false;
|
|
724
771
|
let mountParent;
|
|
725
|
-
let
|
|
726
|
-
let rmSeen = false;
|
|
772
|
+
let image;
|
|
727
773
|
let project;
|
|
728
774
|
let piArgs;
|
|
729
775
|
const fail = (msg) => {
|
|
@@ -751,13 +797,21 @@ export function parseLaunchArgs(args) {
|
|
|
751
797
|
mountParent = v;
|
|
752
798
|
continue;
|
|
753
799
|
}
|
|
754
|
-
if (a === '--
|
|
755
|
-
|
|
800
|
+
if (a === '-i' || a === '--image') {
|
|
801
|
+
// The EPHEMERAL per-launch image override. A raw ref, NOT a
|
|
802
|
+
// name-namespaced anon-pi token (it resolves in netcage's private
|
|
803
|
+
// image store, so any podman ref / `anon-pi/<name>:latest` snapshot tag
|
|
804
|
+
// is valid): not name-validated here. It never mutates machine.json.
|
|
805
|
+
const v = args[++i];
|
|
806
|
+
if (v === undefined)
|
|
807
|
+
fail(`${a} needs an image ref`);
|
|
808
|
+
image = v;
|
|
756
809
|
continue;
|
|
757
810
|
}
|
|
758
|
-
if (a
|
|
759
|
-
|
|
760
|
-
|
|
811
|
+
if (RETIRED_LAUNCH_FLAGS.includes(a)) {
|
|
812
|
+
// `--keep`/`--rm` are retired (ADR-0004): throwaway is the only
|
|
813
|
+
// behaviour now. Point at the image-based replacement.
|
|
814
|
+
fail(retiredKeepRmMessage(a));
|
|
761
815
|
}
|
|
762
816
|
if (a === '.') {
|
|
763
817
|
// the root token is a valid project positional (not a name).
|
|
@@ -774,8 +828,7 @@ export function parseLaunchArgs(args) {
|
|
|
774
828
|
machine,
|
|
775
829
|
machineExplicit: machineSet,
|
|
776
830
|
mountParent,
|
|
777
|
-
|
|
778
|
-
rm: rmSeen,
|
|
831
|
+
image,
|
|
779
832
|
shell,
|
|
780
833
|
piArgs: args.slice(i + 1),
|
|
781
834
|
fail,
|
|
@@ -815,8 +868,7 @@ export function parseLaunchArgs(args) {
|
|
|
815
868
|
machine,
|
|
816
869
|
machineExplicit: machineSet,
|
|
817
870
|
mountParent,
|
|
818
|
-
|
|
819
|
-
rm: rmSeen,
|
|
871
|
+
image,
|
|
820
872
|
shell,
|
|
821
873
|
piArgs,
|
|
822
874
|
fail,
|
|
@@ -830,9 +882,6 @@ export function parseLaunchArgs(args) {
|
|
|
830
882
|
i++;
|
|
831
883
|
break;
|
|
832
884
|
}
|
|
833
|
-
if (keepSeen && rmSeen) {
|
|
834
|
-
fail('--keep and --rm are contradictory (pick one; --rm is the default)');
|
|
835
|
-
}
|
|
836
885
|
// tokens remaining after the project.
|
|
837
886
|
const rest = args.slice(i);
|
|
838
887
|
if (shell) {
|
|
@@ -846,7 +895,7 @@ export function parseLaunchArgs(args) {
|
|
|
846
895
|
machineExplicit: machineSet,
|
|
847
896
|
project,
|
|
848
897
|
mountParent,
|
|
849
|
-
|
|
898
|
+
image,
|
|
850
899
|
};
|
|
851
900
|
}
|
|
852
901
|
if (project === undefined) {
|
|
@@ -859,7 +908,7 @@ export function parseLaunchArgs(args) {
|
|
|
859
908
|
machineExplicit: machineSet,
|
|
860
909
|
project: undefined,
|
|
861
910
|
mountParent,
|
|
862
|
-
|
|
911
|
+
image,
|
|
863
912
|
};
|
|
864
913
|
}
|
|
865
914
|
// pi mode: every token after the project is forwarded to pi verbatim.
|
|
@@ -871,7 +920,7 @@ export function parseLaunchArgs(args) {
|
|
|
871
920
|
machineExplicit: machineSet,
|
|
872
921
|
project,
|
|
873
922
|
mountParent,
|
|
874
|
-
|
|
923
|
+
image,
|
|
875
924
|
piArgs,
|
|
876
925
|
};
|
|
877
926
|
}
|
|
@@ -885,7 +934,7 @@ export function parseLaunchArgs(args) {
|
|
|
885
934
|
* - the two mounts <home>:/root and <projectsRoot>:/projects, always;
|
|
886
935
|
* - --mount adds EXACTLY <parent>:/work and re-roots cwd, nothing else;
|
|
887
936
|
* - --proxy <p> + exactly one --allow-direct <llm> (forced egress, fail-closed);
|
|
888
|
-
* - --rm
|
|
937
|
+
* - --rm on EVERY launch (throwaway always; ADR-0004).
|
|
889
938
|
*
|
|
890
939
|
* Throws AnonPiError (a plan is NEVER produced) when the image, the machine
|
|
891
940
|
* home, the proxy, or the direct-hole llm is missing.
|
|
@@ -938,9 +987,9 @@ export function resolveRunPlan(intent, homeFresh) {
|
|
|
938
987
|
// task; here the argv simply omits -it for the one headless shape.
|
|
939
988
|
const headless = mode === 'pi' && isHeadlessPiArgs(intent.piArgs);
|
|
940
989
|
const netcageArgs = ['run'];
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
990
|
+
// Throwaway ALWAYS: every launch is `--rm` (ADR-0004). Durable state is
|
|
991
|
+
// image-based (snapshot + a pinned machine), never an accreting container.
|
|
992
|
+
netcageArgs.push('--rm');
|
|
944
993
|
// Forced egress: the proxy + the ONE direct hole. Never omitted.
|
|
945
994
|
netcageArgs.push('--proxy', proxy, '--allow-direct', directTarget);
|
|
946
995
|
if (!headless)
|
|
@@ -1018,42 +1067,45 @@ function containerSeedThen(seedVersion, exec) {
|
|
|
1018
1067
|
`fi && ` +
|
|
1019
1068
|
`${exec}`);
|
|
1020
1069
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
* the cwd keeps the container identity aligned with the conversation the
|
|
1040
|
-
* kept container hosts.
|
|
1070
|
+
// --- The per-launch identity label (for `forward`/`ports`/`snapshot`) --------
|
|
1071
|
+
//
|
|
1072
|
+
// anon-pi stamps an identity key onto EVERY launch's container (an additive
|
|
1073
|
+
// netcage label; see withKeyLabel in cli.ts). It is NOT a kept-container match
|
|
1074
|
+
// key (there are no kept containers: every launch is throwaway, ADR-0004). Its
|
|
1075
|
+
// sole job is to let `forward`/`ports`/`snapshot` find a RUNNING container and
|
|
1076
|
+
// read back which machine + project it hosts (parseKeptKey -> keyProject),
|
|
1077
|
+
// while the container is up (the label goes away with the `--rm` container on
|
|
1078
|
+
// exit). netcage's `netcage.managed` label marks the container managed; this
|
|
1079
|
+
// adds anon-pi's own identity on top. anon-pi invents NO registry file.
|
|
1080
|
+
/**
|
|
1081
|
+
* PURE: the anon-pi launch-identity key stamped on EVERY (throwaway) launch,
|
|
1082
|
+
* derived from the (machine, projects-root, project) identity (ADR-0002's
|
|
1083
|
+
* cwd/project reasoning still underpins it). It is NOT a kept-container MATCH
|
|
1084
|
+
* key (every launch is throwaway; nothing is ever resumed). It exists ONLY so
|
|
1085
|
+
* `forward`/`ports`/`snapshot` can resolve a RUNNING container by machine +
|
|
1086
|
+
* project: the CLI stamps it onto a netcage label and reads it back with
|
|
1087
|
+
* parseKeptKey -> keyProject.
|
|
1041
1088
|
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1089
|
+
* The fields, and why each is retained:
|
|
1090
|
+
* - `machine.name`: the forward/ports filter scopes by machine.
|
|
1091
|
+
* - `cwd` (the resolved container cwd, via launchCwd): encodes the project
|
|
1092
|
+
* token (`/projects/<p>`, `/work/<p>`, or a root), so keyProject can name
|
|
1093
|
+
* the project a running container hosts.
|
|
1094
|
+
* - `projectsRoot` + `mountParent`: kept in the record for stability of the
|
|
1095
|
+
* decode shape (parseKeptKey reads them best-effort); no consumer filters on
|
|
1096
|
+
* them today, but they cost nothing and keep the label self-describing.
|
|
1046
1097
|
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
* its internal shape is not a contract
|
|
1098
|
+
* Independent of the forced-egress inputs and forwarded pi args (identity only).
|
|
1099
|
+
* The key is a single opaque string (a `\n`-joined, field-tagged record) the CLI
|
|
1100
|
+
* stamps verbatim onto a netcage label; its internal shape is not a contract
|
|
1101
|
+
* (decode only with parseKeptKey).
|
|
1050
1102
|
*/
|
|
1051
|
-
export function
|
|
1103
|
+
export function launchIdentityKey(intent) {
|
|
1052
1104
|
const { machine, mode, projectsRoot, project, mountParent } = intent;
|
|
1053
1105
|
const mounted = nonEmpty(mountParent) !== undefined;
|
|
1054
1106
|
const rootKind = mounted ? 'mount' : 'projects';
|
|
1055
|
-
// The same cwd resolution resolveRunPlan uses, so
|
|
1056
|
-
//
|
|
1107
|
+
// The same cwd resolution resolveRunPlan uses, so keyProject names the exact
|
|
1108
|
+
// project the running container hosts (its conversation key).
|
|
1057
1109
|
const cwd = launchCwd(mode, rootKind, project);
|
|
1058
1110
|
return [
|
|
1059
1111
|
`machine=${machine.name}`,
|
|
@@ -1062,29 +1114,7 @@ export function keptContainerKey(intent) {
|
|
|
1062
1114
|
`cwd=${cwd}`,
|
|
1063
1115
|
].join('\n');
|
|
1064
1116
|
}
|
|
1065
|
-
/**
|
|
1066
|
-
* PURE: decide run-vs-start for a launch given a SUPPLIED listing of kept
|
|
1067
|
-
* `netcage.managed` containers (the CLI's netcage query result).
|
|
1068
|
-
*
|
|
1069
|
-
* - `--rm` (throwaway, `intent.keep !== true`): ALWAYS a fresh `run`. The
|
|
1070
|
-
* listing is NOT consulted (a throwaway launch never resumes a kept box).
|
|
1071
|
-
* - `--keep`: a kept container whose `key` equals this launch's
|
|
1072
|
-
* keptContainerKey is present -> `start` it (by its `ref`); else -> `run`
|
|
1073
|
-
* (resolveRunPlan leaves it kept because `--keep` omits `--rm`).
|
|
1074
|
-
*
|
|
1075
|
-
* Never spawns, never queries netcage: the listing is injected, so the whole
|
|
1076
|
-
* decision is a pure function of (intent, listing).
|
|
1077
|
-
*/
|
|
1078
|
-
export function resolveRunVsStart(intent, kept) {
|
|
1079
|
-
// Throwaway short-circuit: a `--rm` launch is always a fresh run and never
|
|
1080
|
-
// consults the listing (it must not resume a kept container).
|
|
1081
|
-
if (intent.keep !== true)
|
|
1082
|
-
return { action: 'run' };
|
|
1083
|
-
const want = keptContainerKey(intent);
|
|
1084
|
-
const match = kept.find((c) => c.key === want);
|
|
1085
|
-
return match ? { action: 'start', ref: match.ref } : { action: 'run' };
|
|
1086
|
-
}
|
|
1087
|
-
/** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
|
|
1117
|
+
/** PURE: parse a stamped launchIdentityKey back into its fields (best-effort). */
|
|
1088
1118
|
export function parseKeptKey(key) {
|
|
1089
1119
|
const out = {
|
|
1090
1120
|
machine: '',
|
|
@@ -1308,7 +1338,7 @@ export const ANON_PI_KEY_LABEL = 'anon-pi.key';
|
|
|
1308
1338
|
* ref: <Id>, name: <first Names entry or Id>}. When `runningOnly`, entries whose
|
|
1309
1339
|
* State is not "running" are dropped (forward/ports can only reach a live jail).
|
|
1310
1340
|
* The base64 DECODE of `key` is the CLI's job (Buffer), so this stays pure; the
|
|
1311
|
-
* caller decodes before matching against a
|
|
1341
|
+
* caller decodes before matching against a launchIdentityKey. [] on bad JSON.
|
|
1312
1342
|
*/
|
|
1313
1343
|
export function parseNetcagePsJson(stdout, opts = {}) {
|
|
1314
1344
|
let parsed;
|
|
@@ -1461,6 +1491,51 @@ export function deriveProjectUsage(args) {
|
|
|
1461
1491
|
return { project, machines, currentMachineIsNew };
|
|
1462
1492
|
});
|
|
1463
1493
|
}
|
|
1494
|
+
/**
|
|
1495
|
+
* PURE: the cpSync filter predicate for a snapshot's "copy the home MINUS the
|
|
1496
|
+
* sessions subtree" copy: true = copy `src`, false = skip it. It rejects the
|
|
1497
|
+
* sessions dir itself and everything beneath it (`<sessionsDir>` and
|
|
1498
|
+
* `<sessionsDir>/...`), and copies everything else. Extracted so the
|
|
1499
|
+
* home-minus-sessions contract is unit-testable without the fs.
|
|
1500
|
+
*/
|
|
1501
|
+
export function copyIncludesForHomeMinusSessions(src, sessionsDir) {
|
|
1502
|
+
return src !== sessionsDir && !src.startsWith(sessionsDir + '/');
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* PURE: map the session-dir slugs PRESENT under a source machine's `sessions/`
|
|
1506
|
+
* to per-project rows a snapshot's carry-over picker offers. For each present
|
|
1507
|
+
* slug, if it equals `projectSessionSlug(<project>)` for a known project, it is a
|
|
1508
|
+
* PROJECT row (labelled by the project name); otherwise an ORPHAN-slug row
|
|
1509
|
+
* (labelled by the raw slug, so a session with no current project folder is
|
|
1510
|
+
* still shown, never silently dropped). Rows are sorted: named projects first
|
|
1511
|
+
* (case-insensitive by name), then orphan slugs (by slug), for a stable picker.
|
|
1512
|
+
* The caller (CLI) does the actual copy/delete of each chosen slug dir.
|
|
1513
|
+
*/
|
|
1514
|
+
export function snapshotSessionGroups(args) {
|
|
1515
|
+
const slugToProject = new Map();
|
|
1516
|
+
for (const p of args.projects) {
|
|
1517
|
+
// projectSessionSlug validates the name; a bad project name throws, which is
|
|
1518
|
+
// correct (the projects list comes from real folder names).
|
|
1519
|
+
slugToProject.set(projectSessionSlug(p), p);
|
|
1520
|
+
}
|
|
1521
|
+
const rows = args.presentSlugs.map((slug) => {
|
|
1522
|
+
const project = slugToProject.get(slug);
|
|
1523
|
+
return project !== undefined
|
|
1524
|
+
? { slug, project, label: project }
|
|
1525
|
+
: { slug, label: `${slug} (no current project folder)` };
|
|
1526
|
+
});
|
|
1527
|
+
const lc = (s) => s.toLowerCase();
|
|
1528
|
+
return rows.sort((a, b) => {
|
|
1529
|
+
// named projects before orphan slugs; within each, by their label key.
|
|
1530
|
+
const an = a.project !== undefined ? 0 : 1;
|
|
1531
|
+
const bn = b.project !== undefined ? 0 : 1;
|
|
1532
|
+
if (an !== bn)
|
|
1533
|
+
return an - bn;
|
|
1534
|
+
const ak = lc(a.project ?? a.slug);
|
|
1535
|
+
const bk = lc(b.project ?? b.slug);
|
|
1536
|
+
return ak < bk ? -1 : ak > bk ? 1 : 0;
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1464
1539
|
/** The fixed labels for the non-project affordances (one source, so the TUI + its test agree). */
|
|
1465
1540
|
export const MENU_HERE_LABEL = '. (here: a scratch pi at the root)';
|
|
1466
1541
|
export const MENU_NEW_LABEL = '+ new project\u2026';
|
|
@@ -2126,7 +2201,7 @@ export function parseMachineArgs(args) {
|
|
|
2126
2201
|
};
|
|
2127
2202
|
const verb = args[0];
|
|
2128
2203
|
if (verb === undefined) {
|
|
2129
|
-
fail('`machine` needs a subcommand: create | list | set-image | rm
|
|
2204
|
+
fail('`machine` needs a subcommand: create | list | set-image | rm');
|
|
2130
2205
|
}
|
|
2131
2206
|
const rest = args.slice(1);
|
|
2132
2207
|
if (verb === 'list') {
|
|
@@ -2200,15 +2275,37 @@ export function parseMachineArgs(args) {
|
|
|
2200
2275
|
fail('machine rm needs a <name>');
|
|
2201
2276
|
return { verb: 'rm', name: name, yes };
|
|
2202
2277
|
}
|
|
2278
|
+
return fail(`unknown machine subcommand: ${verb} (create | list | set-image | rm)`);
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* PURE: parse the tokens AFTER `image` into an ImageCommand. Validates the image
|
|
2282
|
+
* name + the `-m` / `--create-machine` machine names via validateName (the
|
|
2283
|
+
* reserved-name / traversal guard), so the CLI only ever joins safe segments.
|
|
2284
|
+
* Throws AnonPiError (printed verbatim, exit 1) for an unknown/missing verb, a
|
|
2285
|
+
* missing or extra positional, an unknown flag, or a bad name.
|
|
2286
|
+
*
|
|
2287
|
+
* `<name>` is validated with the `machine` kind: it shares the same
|
|
2288
|
+
* folder-safe / reserved-name rules, and a snapshot name is an image-tag
|
|
2289
|
+
* segment (`anon-pi/<name>:latest`), so the same guard applies.
|
|
2290
|
+
*/
|
|
2291
|
+
export function parseImageArgs(args) {
|
|
2292
|
+
const fail = (msg) => {
|
|
2293
|
+
throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi image --help\` or \`anon-pi --help\`.`);
|
|
2294
|
+
};
|
|
2295
|
+
const verb = args[0];
|
|
2296
|
+
if (verb === undefined) {
|
|
2297
|
+
fail('`image` needs a subcommand: snapshot | list');
|
|
2298
|
+
}
|
|
2299
|
+
const rest = args.slice(1);
|
|
2300
|
+
if (verb === 'list') {
|
|
2301
|
+
if (rest.length > 0)
|
|
2302
|
+
fail(`image list takes no arguments, got: ${rest.join(' ')}`);
|
|
2303
|
+
return { verb: 'list' };
|
|
2304
|
+
}
|
|
2203
2305
|
if (verb === 'snapshot') {
|
|
2204
|
-
// snapshot <new-name> [-m <machine>] [--image-tag <ref>]: commit a RUNNING
|
|
2205
|
-
// container into a new image and create <new-name> pinned to it. The sole
|
|
2206
|
-
// positional is the new machine name; `-m` is an OPTIONAL filter (which
|
|
2207
|
-
// container when several are up), not a required source. The CLI auto-detects
|
|
2208
|
-
// the container (picker when several match).
|
|
2209
2306
|
let name;
|
|
2210
2307
|
let machine;
|
|
2211
|
-
let
|
|
2308
|
+
let createMachine;
|
|
2212
2309
|
for (let i = 0; i < rest.length; i++) {
|
|
2213
2310
|
const a = rest[i];
|
|
2214
2311
|
if (a === '-m' || a === '--machine') {
|
|
@@ -2218,41 +2315,81 @@ export function parseMachineArgs(args) {
|
|
|
2218
2315
|
machine = validateName(v, 'machine');
|
|
2219
2316
|
continue;
|
|
2220
2317
|
}
|
|
2221
|
-
if (a === '--
|
|
2318
|
+
if (a === '--create-machine') {
|
|
2222
2319
|
const v = rest[++i];
|
|
2223
2320
|
if (v === undefined)
|
|
2224
|
-
fail('--
|
|
2225
|
-
|
|
2321
|
+
fail('--create-machine needs a machine name');
|
|
2322
|
+
createMachine = validateName(v, 'machine');
|
|
2226
2323
|
continue;
|
|
2227
2324
|
}
|
|
2228
2325
|
if (a.startsWith('-'))
|
|
2229
2326
|
fail(`unknown option: ${a}`);
|
|
2230
2327
|
if (name !== undefined)
|
|
2231
|
-
fail(`
|
|
2328
|
+
fail(`image snapshot takes one <name>, got extra: ${a}`);
|
|
2232
2329
|
name = validateName(a, 'machine');
|
|
2233
2330
|
}
|
|
2234
2331
|
if (name === undefined)
|
|
2235
|
-
fail('
|
|
2332
|
+
fail('image snapshot needs a <name>');
|
|
2236
2333
|
return {
|
|
2237
2334
|
verb: 'snapshot',
|
|
2238
2335
|
name: name,
|
|
2239
2336
|
machine: nonEmpty(machine),
|
|
2240
|
-
|
|
2337
|
+
createMachine: nonEmpty(createMachine),
|
|
2241
2338
|
};
|
|
2242
2339
|
}
|
|
2243
|
-
return fail(`unknown
|
|
2244
|
-
}
|
|
2245
|
-
/**
|
|
2246
|
-
* PURE: the
|
|
2247
|
-
*
|
|
2248
|
-
*
|
|
2249
|
-
*
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2340
|
+
return fail(`unknown image subcommand: ${verb} (snapshot | list)`);
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* PURE: the clean image tag a `image snapshot <name>` writes:
|
|
2344
|
+
* `anon-pi/<name>:latest`. A same-name re-snapshot OVERWRITES this tag (that is
|
|
2345
|
+
* what `:latest` means); the previous image becomes dangling but keeps its
|
|
2346
|
+
* provenance label. The name is a validated image/machine name (a safe
|
|
2347
|
+
* image-path segment).
|
|
2348
|
+
*/
|
|
2349
|
+
export function snapshotImageTag(name) {
|
|
2350
|
+
return `anon-pi/${validateName(name, 'machine')}:latest`;
|
|
2351
|
+
}
|
|
2352
|
+
/** The podman/anon-pi provenance label keys baked into a snapshot image. */
|
|
2353
|
+
export const PROVENANCE_LABEL_SOURCE_MACHINE = 'anon-pi.source-machine';
|
|
2354
|
+
export const PROVENANCE_LABEL_SOURCE_IMAGE = 'anon-pi.source-image';
|
|
2355
|
+
export const PROVENANCE_LABEL_SNAPSHOT_AT = 'anon-pi.snapshot-at';
|
|
2356
|
+
/**
|
|
2357
|
+
* PURE: build the `LABEL k=v` change instructions a `netcage commit -c '…'`
|
|
2358
|
+
* bakes into a snapshot image (ADR-0003 §2). Provenance is best-effort HISTORY:
|
|
2359
|
+
* a label whose value is undefined/empty is OMITTED (a missing label beats a
|
|
2360
|
+
* wrong one). `at` is required (the snapshot time is always known). Each string
|
|
2361
|
+
* is ONE `LABEL key=value` instruction (the CLI passes each as a `-c` argv
|
|
2362
|
+
* element; podman round-trips `/` and `:` in the value un-quoted, verified).
|
|
2363
|
+
*/
|
|
2364
|
+
export function snapshotProvenanceLabels(args) {
|
|
2365
|
+
const labels = [];
|
|
2366
|
+
const push = (key, value) => {
|
|
2367
|
+
const v = nonEmpty(value);
|
|
2368
|
+
if (v !== undefined)
|
|
2369
|
+
labels.push(`LABEL ${key}=${v}`);
|
|
2370
|
+
};
|
|
2371
|
+
push(PROVENANCE_LABEL_SOURCE_MACHINE, args.sourceMachine);
|
|
2372
|
+
push(PROVENANCE_LABEL_SOURCE_IMAGE, args.sourceImage);
|
|
2373
|
+
push(PROVENANCE_LABEL_SNAPSHOT_AT, args.at);
|
|
2374
|
+
return labels;
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* PURE: parse the anon-pi provenance labels read back off an image (the CLI
|
|
2378
|
+
* supplies the label map from `inspect --format '{{json .Config.Labels}}'`).
|
|
2379
|
+
* Returns only the anon-pi provenance fields (a missing/empty label => an
|
|
2380
|
+
* undefined field). Tolerant: any non-string / absent value is dropped, so a
|
|
2381
|
+
* hand-edited or partial label set never throws.
|
|
2382
|
+
*/
|
|
2383
|
+
export function parseImageProvenance(labels) {
|
|
2384
|
+
const get = (key) => {
|
|
2385
|
+
const v = labels?.[key];
|
|
2386
|
+
return typeof v === 'string' ? nonEmpty(v) : undefined;
|
|
2387
|
+
};
|
|
2388
|
+
return {
|
|
2389
|
+
sourceMachine: get(PROVENANCE_LABEL_SOURCE_MACHINE),
|
|
2390
|
+
sourceImage: get(PROVENANCE_LABEL_SOURCE_IMAGE),
|
|
2391
|
+
snapshotAt: get(PROVENANCE_LABEL_SNAPSHOT_AT),
|
|
2392
|
+
};
|
|
2256
2393
|
}
|
|
2257
2394
|
/**
|
|
2258
2395
|
* PURE: the JSON body a machine.json carries, given the pinned image (and an
|
|
@@ -2316,26 +2453,44 @@ USAGE
|
|
|
2316
2453
|
anon-pi forward [<p>] [--port …] open a host port onto a running container's in-jail server
|
|
2317
2454
|
anon-pi ports [<project>] list a running container's open in-jail TCP listeners
|
|
2318
2455
|
anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
|
|
2456
|
+
anon-pi -i <ref> [<p>] run against <ref> for THIS launch only (also --image; ephemeral)
|
|
2319
2457
|
anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
|
|
2320
2458
|
anon-pi init onboard: verify your proxy, capture your local model, pick an image
|
|
2321
|
-
anon-pi machine … manage machines (create / list / set-image / rm
|
|
2459
|
+
anon-pi machine … manage machines (create / list / set-image / rm)
|
|
2460
|
+
anon-pi image … snapshot a running container into an image; list anon-pi images
|
|
2322
2461
|
anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files
|
|
2323
2462
|
anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes
|
|
2324
2463
|
|
|
2325
2464
|
<project> a folder under the projects root (mounted at ${CONTAINER_PROJECTS_ROOT}; pi's cwd). \`.\` means
|
|
2326
2465
|
the root itself (a scratch pi at ${CONTAINER_PROJECTS_ROOT}, ${CONTAINER_MOUNT_ROOT} for --mount, or ~).
|
|
2327
2466
|
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2467
|
+
-i <ref>, --image <ref> EPHEMERAL per-launch image override, highest priority
|
|
2468
|
+
(\`-i\` > the machine's machine.json image > ANON_PI_IMAGE). It picks the
|
|
2469
|
+
IMAGE for this launch only; \`-m\` picks the HOME, and the two compose.
|
|
2470
|
+
It NEVER changes machine.json (to re-pin a machine's image, use
|
|
2471
|
+
\`anon-pi machine set-image\` / \`machine create --image\`). No mismatch
|
|
2472
|
+
warning is printed. <ref> resolves in NETCAGE'S private image store
|
|
2473
|
+
(\`anon-pi/<name>:latest\` snapshots + \`init\`-built images live there),
|
|
2474
|
+
NOT your default podman store; anon-pi does NOT pre-check it and does
|
|
2475
|
+
NOT auto-pull (an anonymity tool must not silently fetch a remote
|
|
2476
|
+
image). A "not found" means the ref is not in netcage's store: snapshot
|
|
2477
|
+
it (\`anon-pi image snapshot <name>\`) or build it into that store.
|
|
2478
|
+
On a FRESH machine home \`-i\` is REFUSED (it would seed the home from
|
|
2479
|
+
the wrong image); establish the machine's image with
|
|
2480
|
+
\`anon-pi machine create <m> --image <ref>\` first.
|
|
2481
|
+
|
|
2482
|
+
Every launch is THROWAWAY: the container is removed on exit. To persist system
|
|
2483
|
+
state you built in a session, snapshot the running container into a named image
|
|
2484
|
+
(\`anon-pi image snapshot <name>\`) and pin a machine to it (\`anon-pi machine
|
|
2485
|
+
create <m> --image anon-pi/<name>:latest\`). Your pi config + conversations live
|
|
2486
|
+
in the machine home (a host mount) and persist regardless.
|
|
2332
2487
|
|
|
2333
2488
|
WHAT IT DOES
|
|
2334
2489
|
Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy
|
|
2335
2490
|
(fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE
|
|
2336
2491
|
is an image + a persistent HOST home (bind-mounted at ${CONTAINER_HOME_ROOT}) holding your pi
|
|
2337
|
-
config, extensions, and conversations; the container is disposable,
|
|
2338
|
-
loses nothing. Files (projects) are global by default; conversations are
|
|
2492
|
+
config, extensions, and conversations; the container is disposable (throwaway),
|
|
2493
|
+
so it loses nothing. Files (projects) are global by default; conversations are
|
|
2339
2494
|
per-machine. On a FRESH machine home the image's staged defaults + your
|
|
2340
2495
|
models.json are seeded in once; after that pi owns the home. Requires \`netcage\`.
|
|
2341
2496
|
|