anon-pi 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/anon-pi.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  // init's proxy detect/verify decisions). cli.ts owns only the impure edges (fs,
31
31
  // the interactive TUI, the netcage query, the spawn).
32
32
 
33
- import {existsSync} from 'node:fs';
33
+ import {existsSync, readFileSync} from 'node:fs';
34
34
  import {homedir} from 'node:os';
35
35
  import {dirname, join, resolve} from 'node:path';
36
36
  import {fileURLToPath} from 'node:url';
@@ -433,7 +433,9 @@ export const ROOT_TOKEN = '.';
433
433
  * reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
434
434
  * CONTAINER path, not a name in this namespace, so it needs no reservation.
435
435
  */
436
- export const RESERVED_NAMES: readonly string[] = ['.', '..'];
436
+ export const RESERVED_NAMES: readonly string[] = ['.', '..', 'pi'];
437
+ // NOTE: `pi` is reserved so the `anon-pi pi <args…>` passthrough token
438
+ // (PI_PASSTHROUGH_TOKEN) can never be shadowed by a project named `pi`.
437
439
 
438
440
  /** What a name names, for a clear validation error. */
439
441
  export type NameKind = 'machine' | 'project';
@@ -523,6 +525,24 @@ export function resolveCwd(kind: RootKind, token: string): string {
523
525
  return `${rootCwd(kind)}/${validateName(token, 'project')}`;
524
526
  }
525
527
 
528
+ /**
529
+ * PURE: the launch cwd for a resolved (mode, rootKind, project). With a project
530
+ * token it resolves under the active root (resolveCwd). With NO project: a
531
+ * `shell` sits at the machine home (`/root`) — the "sit on the machine" mode —
532
+ * while `pi` (a `--session`/`--resume` launch that pi cwd-switches itself) starts
533
+ * at the projects root (`rootCwd`), a real pi launch position. `menu` never
534
+ * reaches here (it is argv-less). Shared by resolveRunPlan + keptContainerKey so
535
+ * the run cwd and the container-identity key always agree.
536
+ */
537
+ export function launchCwd(
538
+ mode: LaunchMode,
539
+ kind: RootKind,
540
+ project: string | undefined,
541
+ ): string {
542
+ if (project !== undefined) return resolveCwd(kind, project);
543
+ return mode === 'shell' ? CONTAINER_HOME_ROOT : rootCwd(kind);
544
+ }
545
+
526
546
  /** Parsed shape of config.json. All fields optional (a hand-edited file may omit any). */
527
547
  export interface AnonPiConfig {
528
548
  /** socks5h proxy URL. */
@@ -586,14 +606,54 @@ function nonEmpty(v: string | undefined): string | undefined {
586
606
  return v && v.trim() !== '' ? v.trim() : undefined;
587
607
  }
588
608
 
609
+ /**
610
+ * PURE: expand a leading `~` / `~/` in a user-supplied HOST path to the given
611
+ * home (`node:path.resolve` does NOT do this — it would produce a literal `~`
612
+ * directory). Only a LEADING `~` (bare or before a separator) is expanded; a `~`
613
+ * elsewhere is left alone. Used everywhere anon-pi takes a host path from a
614
+ * human (the projects root, `--mount`), so `~/dev/x` means `$HOME/dev/x`.
615
+ */
616
+ export function expandTilde(p: string, home: string): string {
617
+ if (p === '~') return home;
618
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
619
+ return join(home, p.slice(2));
620
+ }
621
+ return p;
622
+ }
623
+
624
+ /**
625
+ * netcage's default podman graphroot (podman's global `--root`). Since netcage
626
+ * v0.7.0 (host-identity hardening, ADR-0013) EVERY netcage podman call runs
627
+ * against a private, username-free store at this path, NOT the operator's
628
+ * default rootless store. So an image anon-pi builds with a plain `podman build`
629
+ * lands in the WRONG store and `netcage run` cannot see it (it tries to pull the
630
+ * `localhost/…` ref and fails). anon-pi must place a built image into THIS store.
631
+ * Overridable via NETCAGE_GRAPHROOT (netcage's own test-seam env), so a caller
632
+ * that points netcage elsewhere stays in sync.
633
+ */
634
+ export const NETCAGE_DEFAULT_GRAPHROOT = '/var/tmp/netcage-storage';
635
+
636
+ /**
637
+ * PURE: resolve netcage's podman graphroot the same way netcage does: the
638
+ * NETCAGE_GRAPHROOT env override when set, else the fixed default. anon-pi builds
639
+ * images into this store so `netcage run` finds them. This is a temporary
640
+ * coupling to a netcage-internal path; it goes away once netcage exposes a
641
+ * `build`/`load` verb (then anon-pi delegates to netcage instead).
642
+ */
643
+ export function resolveNetcageGraphroot(
644
+ penv: Record<string, string | undefined>,
645
+ ): string {
646
+ const p = penv.NETCAGE_GRAPHROOT;
647
+ return p && p.trim() !== '' ? p.trim() : NETCAGE_DEFAULT_GRAPHROOT;
648
+ }
649
+
589
650
  /**
590
651
  * PURE: resolve the projects root (the host dir mounted at /projects) with the
591
652
  * decided precedence, highest first:
592
653
  * --mount (CLI) > env ANON_PI_PROJECTS > machine.json.projects >
593
654
  * config.json.projects > built-in <home>/projects
594
- * This task delivers the config/env/machine layers; `mountParent` is the
595
- * documented top slot the later --mount CLI task threads in (pass the resolved
596
- * host parent). A relative override is resolved to an absolute path.
655
+ * A leading `~` in any override is expanded to $HOME; a relative override is
656
+ * resolved to an absolute path.
597
657
  */
598
658
  export function resolveProjectsRoot(args: {
599
659
  env: AnonPiEnv;
@@ -608,7 +668,7 @@ export function resolveProjectsRoot(args: {
608
668
  nonEmpty(env.projects) ??
609
669
  nonEmpty(machine?.projects) ??
610
670
  nonEmpty(config?.projects);
611
- if (pick !== undefined) return resolve(pick);
671
+ if (pick !== undefined) return resolve(expandTilde(pick, env.home));
612
672
  return builtinProjectsRoot(env);
613
673
  }
614
674
 
@@ -782,14 +842,105 @@ export interface ParsedLaunch {
782
842
  piArgs?: string[];
783
843
  }
784
844
 
845
+ /**
846
+ * pi flags that make sense with NO anon-pi project, so `anon-pi <flag> ...`
847
+ * launches pi (at the projects root) and forwards this flag + everything after
848
+ * it verbatim. Two families:
849
+ * - SESSION selection (`--session <id>` etc.): pi finds the session file (in the
850
+ * always-mounted machine home) and switches to its own project cwd, so no
851
+ * project is needed. Mirrors pi's own resume hint (`pi --session <id>`), so
852
+ * pasting `anon-pi --session <id>` just works.
853
+ * - QUERY (`--list-models`/`--models`): pi prints + exits, no project relevant.
854
+ * For arbitrary pi flags with no project (e.g. `--model x`), use the explicit
855
+ * `anon-pi pi <args…>` passthrough instead.
856
+ */
857
+ const PI_NO_PROJECT_FLAGS: ReadonlySet<string> = new Set([
858
+ // session selection
859
+ '--session',
860
+ '--session-id',
861
+ '--resume',
862
+ '-r',
863
+ '--continue',
864
+ '-c',
865
+ '--fork',
866
+ // query-and-exit
867
+ '--list-models',
868
+ '--models',
869
+ ]);
870
+
871
+ /** True iff `a` is a pi flag anon-pi accepts with no project (see PI_NO_PROJECT_FLAGS). */
872
+ function isPiNoProjectFlag(a: string): boolean {
873
+ return PI_NO_PROJECT_FLAGS.has(a);
874
+ }
875
+
876
+ /**
877
+ * The explicit pi-passthrough token: `anon-pi pi <args…>` runs pi with the given
878
+ * args and NO project (the general escape hatch for any pi flag). It is a
879
+ * RESERVED project name (see RESERVED_NAMES) so a project can never shadow it.
880
+ */
881
+ export const PI_PASSTHROUGH_TOKEN = 'pi';
882
+
883
+ /**
884
+ * PURE: whether forwarded pi args request pi's NON-INTERACTIVE (print) mode,
885
+ * i.e. contain `-p`/`--print`. This is the ONLY headless shape (it needs no
886
+ * TTY): other forwarded args (`--session <id>`, `--model x`, ...) are still
887
+ * INTERACTIVE and need a TTY + `-it`. Shared by the CLI's no-TTY discipline and
888
+ * the RunPlan's `-it` decision so they agree.
889
+ */
890
+ export function isHeadlessPiArgs(
891
+ piArgs: readonly string[] | undefined,
892
+ ): boolean {
893
+ return !!piArgs && piArgs.some((a) => a === '-p' || a === '--print');
894
+ }
895
+
896
+ /**
897
+ * Finish parsing a NO-PROJECT pi launch (`anon-pi --session <id> ...`,
898
+ * `anon-pi --list-models`, or the explicit `anon-pi pi <args…>`): pi mode, NO
899
+ * project (pi picks its own cwd / prints + exits), the flag(s) + rest forwarded.
900
+ * `--shell` is incompatible (a shell forwards no pi args).
901
+ */
902
+ function finishPiNoProjectLaunch(args: {
903
+ machine: string;
904
+ machineExplicit: boolean;
905
+ mountParent?: string;
906
+ keep: boolean;
907
+ rm: boolean;
908
+ shell: boolean;
909
+ piArgs: string[];
910
+ fail: (msg: string) => never;
911
+ }): ParsedLaunch {
912
+ if (args.keep && args.rm) {
913
+ args.fail(
914
+ '--keep and --rm are contradictory (pick one; --rm is the default)',
915
+ );
916
+ }
917
+ if (args.shell) {
918
+ args.fail(
919
+ '--shell forwards no pi args (a shell has no session/query). Drop --shell.',
920
+ );
921
+ }
922
+ return {
923
+ mode: 'pi',
924
+ machine: args.machine,
925
+ machineExplicit: args.machineExplicit,
926
+ project: undefined,
927
+ mountParent: args.mountParent,
928
+ keep: args.keep,
929
+ piArgs: args.piArgs,
930
+ };
931
+ }
932
+
785
933
  /**
786
934
  * PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
787
935
  * (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
788
936
  * project positional; the FIRST bare positional is the project (`.` allowed as
789
937
  * the root token). In pi mode every token AFTER the project is forwarded to pi
790
938
  * verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
791
- * the project. In shell/menu mode a stray extra positional is an error (bash has
792
- * no forwarded-args grammar; the menu takes no project).
939
+ * the project. A pi session-resume flag (`--session <id>`, `--continue`,
940
+ * `--resume`, `--fork <id>`) in the project position starts a NO-project pi
941
+ * launch that forwards to pi (pi resolves the session + cwd itself). In
942
+ * shell/menu mode a stray extra positional is an error (bash has no
943
+ * forwarded-args grammar; the menu takes no project).
793
944
  *
794
945
  * Validates the project name and the `-m` machine name via validateName (the
795
946
  * reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
@@ -845,6 +996,44 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
845
996
  i++;
846
997
  break;
847
998
  }
999
+ if (a === PI_PASSTHROUGH_TOKEN) {
1000
+ // `anon-pi pi <args…>`: the explicit passthrough. Run pi with the
1001
+ // following args and NO project (pi picks its own cwd, or prints + exits
1002
+ // for a query). The general escape hatch for ANY pi flag with no project
1003
+ // (`anon-pi pi --model x`, `anon-pi pi --export out.html --session <id>`).
1004
+ return finishPiNoProjectLaunch({
1005
+ machine,
1006
+ machineExplicit: machineSet,
1007
+ mountParent,
1008
+ keep: keepSeen,
1009
+ rm: rmSeen,
1010
+ shell,
1011
+ piArgs: args.slice(i + 1),
1012
+ fail,
1013
+ });
1014
+ }
1015
+ if (isPiNoProjectFlag(a)) {
1016
+ // A pi flag that needs NO anon-pi project (`--session <id>`/`--continue`/
1017
+ // `--fork` resume by id; `--list-models`/`--models` print + exit). pi
1018
+ // resolves its own cwd (or just prints), so anon-pi launches pi at the
1019
+ // projects root and forwards this flag + everything after it verbatim.
1020
+ // This makes pi's own "To resume: pi --session <id>" hint usable as
1021
+ // `anon-pi --session <id>`. (For ARBITRARY pi flags with no project, use
1022
+ // the explicit `anon-pi pi <args…>` passthrough.)
1023
+ piArgs = args.slice(i);
1024
+ project = undefined;
1025
+ i = args.length;
1026
+ return finishPiNoProjectLaunch({
1027
+ machine,
1028
+ machineExplicit: machineSet,
1029
+ mountParent,
1030
+ keep: keepSeen,
1031
+ rm: rmSeen,
1032
+ shell,
1033
+ piArgs,
1034
+ fail,
1035
+ });
1036
+ }
848
1037
  if (a.startsWith('-')) {
849
1038
  fail(`unknown option: ${a}`);
850
1039
  }
@@ -959,10 +1148,7 @@ export function resolveRunPlan(
959
1148
  // Which root the cwd resolves under: /work when --mount, else /projects.
960
1149
  const rootKind: RootKind = mounted ? 'mount' : 'projects';
961
1150
 
962
- // cwd: shell with no project sits at the machine home (/root); otherwise the
963
- // project token (a name or `.`) resolves under the active root uniformly.
964
- const cwd =
965
- project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
1151
+ const cwd = launchCwd(mode, rootKind, project);
966
1152
 
967
1153
  const fresh = homeFresh(machine.home);
968
1154
  const seedVersion = intent.seedVersion ?? SEED_VERSION;
@@ -974,7 +1160,7 @@ export function resolveRunPlan(
974
1160
  // (podman fails to allocate a TTY on a non-tty stdin). The CLI's broader
975
1161
  // no-TTY discipline (erroring when an interactive mode has no TTY) is a later
976
1162
  // task; here the argv simply omits -it for the one headless shape.
977
- const headless = mode === 'pi' && !!intent.piArgs && intent.piArgs.length > 0;
1163
+ const headless = mode === 'pi' && isHeadlessPiArgs(intent.piArgs);
978
1164
 
979
1165
  const netcageArgs: string[] = ['run'];
980
1166
  // --rm by DEFAULT (throwaway); --keep leaves the container kept.
@@ -1134,13 +1320,12 @@ export type RunVsStart = {action: 'run'} | {action: 'start'; ref: string};
1134
1320
  * its internal shape is not a contract (compare only keys this function makes).
1135
1321
  */
1136
1322
  export function keptContainerKey(intent: LaunchIntent): string {
1137
- const {machine, projectsRoot, project, mountParent} = intent;
1323
+ const {machine, mode, projectsRoot, project, mountParent} = intent;
1138
1324
  const mounted = nonEmpty(mountParent) !== undefined;
1139
1325
  const rootKind: RootKind = mounted ? 'mount' : 'projects';
1140
1326
  // The same cwd resolution resolveRunPlan uses, so the key names the exact
1141
1327
  // container a matching launch would run in (its conversation key).
1142
- const cwd =
1143
- project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
1328
+ const cwd = launchCwd(mode, rootKind, project);
1144
1329
  return [
1145
1330
  `machine=${machine.name}`,
1146
1331
  `projectsRoot=${projectsRoot}`,
@@ -1889,6 +2074,73 @@ export interface ProxyFinding {
1889
2074
  processHint?: string;
1890
2075
  }
1891
2076
 
2077
+ /** What `netcage detect-proxy --json` reports (the fields anon-pi consumes). */
2078
+ export interface NetcageDetectProxy {
2079
+ schemaVersion?: number;
2080
+ candidates?: Array<{
2081
+ port?: number;
2082
+ open?: boolean;
2083
+ socks5?: boolean;
2084
+ processHint?: string;
2085
+ }>;
2086
+ exitIP?: string;
2087
+ }
2088
+
2089
+ /**
2090
+ * PURE: map a parsed `netcage detect-proxy --json` result into anon-pi's
2091
+ * ProxyFinding[] (so init can REUSE netcage's SOCKS scanner instead of its own
2092
+ * probe, and both render through the same formatProxyFindings). The host is
2093
+ * 127.0.0.1 (detect-proxy probes loopback); the socks5 boolean becomes a
2094
+ * SocksHandshake verdict; the structural port hint is attached from
2095
+ * DEFAULT_SOCKS_PROBE_PORTS by port. Tolerates missing/garbage (returns []).
2096
+ * The per-candidate processHint is NOT copied onto each finding (it is host-wide;
2097
+ * the CLI passes it once as formatProxyFindings' note).
2098
+ */
2099
+ export function findingsFromNetcageDetect(
2100
+ raw: NetcageDetectProxy | undefined,
2101
+ ): ProxyFinding[] {
2102
+ const rows = raw?.candidates;
2103
+ if (!Array.isArray(rows)) return [];
2104
+ const hintByPort = new Map(
2105
+ DEFAULT_SOCKS_PROBE_PORTS.map((p) => [p.port, p.hint]),
2106
+ );
2107
+ const out: ProxyFinding[] = [];
2108
+ for (const c of rows) {
2109
+ if (!c || typeof c.port !== 'number') continue;
2110
+ const open = c.open === true;
2111
+ const handshake: SocksHandshake | undefined = !open
2112
+ ? undefined
2113
+ : c.socks5 === true
2114
+ ? {socks5: true, method: 0}
2115
+ : {socks5: false, reason: 'not SOCKS5'};
2116
+ out.push({
2117
+ host: '127.0.0.1',
2118
+ port: c.port,
2119
+ open,
2120
+ handshake,
2121
+ portHint: hintByPort.get(c.port),
2122
+ });
2123
+ }
2124
+ return out;
2125
+ }
2126
+
2127
+ /**
2128
+ * PURE: the host-wide process note from a `netcage detect-proxy --json` result:
2129
+ * the FIRST candidate that carries a `processHint` (they are all the same
2130
+ * host-wide hint). Returns undefined when none. Rendered ONCE by the CLI (not
2131
+ * per port), same as the local-probe path.
2132
+ */
2133
+ export function processNoteFromNetcageDetect(
2134
+ raw: NetcageDetectProxy | undefined,
2135
+ ): string | undefined {
2136
+ for (const c of raw?.candidates ?? []) {
2137
+ if (c && typeof c.processHint === 'string' && c.processHint.trim() !== '') {
2138
+ return c.processHint.trim();
2139
+ }
2140
+ }
2141
+ return undefined;
2142
+ }
2143
+
1892
2144
  /**
1893
2145
  * The set of substrings a findings line must NEVER contain: known exit-provider
1894
2146
  * / VPN brand names. This is the machine-checkable half of the never-label rule
@@ -2096,6 +2348,22 @@ function shippedFile(rel: string): string | undefined {
2096
2348
  return undefined;
2097
2349
  }
2098
2350
 
2351
+ /**
2352
+ * anon-pi's own version, read from the package.json shipped in the package root
2353
+ * (resolved via shippedFile). Returns undefined if it cannot be found/parsed, so
2354
+ * `--version` can fall back to a placeholder. Read-only.
2355
+ */
2356
+ export function anonPiVersion(): string | undefined {
2357
+ const pkg = shippedFile('package.json');
2358
+ if (!pkg) return undefined;
2359
+ try {
2360
+ const parsed = JSON.parse(readFileSync(pkg, 'utf8')) as {version?: unknown};
2361
+ return typeof parsed.version === 'string' ? parsed.version : undefined;
2362
+ } catch {
2363
+ return undefined;
2364
+ }
2365
+ }
2366
+
2099
2367
  // --- The `machine {create,list,set-image,rm}` verbs (pure parts) -------------
2100
2368
  //
2101
2369
  // Machines are first-class: an image + a persistent host home
@@ -2281,7 +2549,12 @@ export const HELP = `anon-pi - run pi on anonymized, jailed machines (netcage: f
2281
2549
  USAGE
2282
2550
  anon-pi MENU: pick a project (pi), a shell, or a new project
2283
2551
  anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
2284
- anon-pi <project> <pi-args…> forward args to pi (headless/one-shot; no TTY needed)
2552
+ anon-pi <project> <pi-args…> forward args to pi (e.g. -p for a headless one-shot)
2553
+ anon-pi --session <id> resume a pi session by id (forwarded to pi; no project needed)
2554
+ anon-pi --continue continue your most recent pi session (also -r/--resume, --fork)
2555
+ anon-pi --list-models list the models pi sees (also --models; no project needed)
2556
+ anon-pi pi <pi-args…> run pi with ANY args and no project (the passthrough)
2557
+ anon-pi --version print anon-pi's version (also -V)
2285
2558
  anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
2286
2559
  anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
2287
2560
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root