anon-pi 0.9.1 → 0.11.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 +49 -6
- package/dist/anon-pi.d.ts +172 -1
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +389 -17
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +332 -6
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/anon-pi.ts +479 -17
- package/src/cli.ts +376 -7
package/src/anon-pi.ts
CHANGED
|
@@ -748,6 +748,15 @@ export interface LaunchIntent {
|
|
|
748
748
|
* undefined (shell-at-home / menu). Resolves the cwd via resolveCwd.
|
|
749
749
|
*/
|
|
750
750
|
project?: string;
|
|
751
|
+
/**
|
|
752
|
+
* A RESUME-family launch's resolved session cwd (e.g. `/projects/test`), the
|
|
753
|
+
* cwd pi keyed the resumed session by. Set by the CLI ONLY for a NO-project
|
|
754
|
+
* `--session`/`--resume <id>` whose id it found in the host session store; it
|
|
755
|
+
* OVERRIDES the default no-project cwd (the projects root) so pi resumes in
|
|
756
|
+
* place instead of prompting to fork. Ignored when a project is given (the
|
|
757
|
+
* user is trusted) or when the id is unresolvable (pi decides, as before).
|
|
758
|
+
*/
|
|
759
|
+
sessionCwd?: string;
|
|
751
760
|
/**
|
|
752
761
|
* `--mount <parent>`: a resolved HOST parent path. When set it adds EXACTLY
|
|
753
762
|
* one mount (<parent>:/work) and re-roots the cwd there (/work[/<project>]);
|
|
@@ -843,13 +852,18 @@ export interface ParsedLaunch {
|
|
|
843
852
|
}
|
|
844
853
|
|
|
845
854
|
/**
|
|
846
|
-
* pi flags
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
* -
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
855
|
+
* pi flags anon-pi RECOGNISES in the no-project position, so `anon-pi <flag> ...`
|
|
856
|
+
* forwards this flag + everything after it verbatim. Three families with three
|
|
857
|
+
* no-project policies:
|
|
858
|
+
* - RESUME (`--session`/`--session-id`/`--resume`/`-r <id>`): resume ONE session
|
|
859
|
+
* in place. anon-pi resolves the session's recorded cwd from the host store
|
|
860
|
+
* and cds there (isPiResumeFlag / resumeSessionId), so pi resumes cleanly.
|
|
861
|
+
* Mirrors pi's own resume hint (`pi --session <id>`), so pasting `anon-pi
|
|
862
|
+
* --session <id>` just works.
|
|
863
|
+
* - NEEDS-PROJECT (`--fork`, `--continue`/`-c`): REFUSED without a project
|
|
864
|
+
* (isPiNeedsProjectFlag) so the (new / newest) conversation never lands in
|
|
865
|
+
* the projects root by surprise. Add a project (`.` for the root; created on
|
|
866
|
+
* demand): `anon-pi <project> --fork <id>`.
|
|
853
867
|
* - QUERY (`--list-models`/`--models`): pi prints + exits, no project relevant.
|
|
854
868
|
* For arbitrary pi flags with no project (e.g. `--model x`), use the explicit
|
|
855
869
|
* `anon-pi pi <args…>` passthrough instead.
|
|
@@ -873,6 +887,111 @@ function isPiNoProjectFlag(a: string): boolean {
|
|
|
873
887
|
return PI_NO_PROJECT_FLAGS.has(a);
|
|
874
888
|
}
|
|
875
889
|
|
|
890
|
+
/**
|
|
891
|
+
* The RESUME family: session-selecting flags that resume ONE existing session in
|
|
892
|
+
* place (`--session`/`--session-id <id>`, `--resume`/`-r <id>`). With NO project,
|
|
893
|
+
* anon-pi resolves the session's recorded cwd from the host session store and
|
|
894
|
+
* cds THERE (setSessionCwd), so pi resumes cleanly instead of prompting to fork
|
|
895
|
+
* (its guard fires when the launch cwd differs from the session cwd). With an
|
|
896
|
+
* explicit project the user is trusted verbatim: anon-pi cds into that project
|
|
897
|
+
* and lets pi's own fork-prompt guard a mismatch. `--continue`/`--fork` are NOT
|
|
898
|
+
* here: they need a project (see PI_RESUME_NEEDS_PROJECT_FLAGS).
|
|
899
|
+
*/
|
|
900
|
+
const PI_RESUME_FLAGS: ReadonlySet<string> = new Set([
|
|
901
|
+
'--session',
|
|
902
|
+
'--session-id',
|
|
903
|
+
'--resume',
|
|
904
|
+
'-r',
|
|
905
|
+
]);
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Session flags that REQUIRE an explicit project with no-project: `--fork` and
|
|
909
|
+
* `--continue`/`-c`. `--fork` writes a NEW session and would otherwise land it
|
|
910
|
+
* silently in the projects ROOT (a surprise); `--continue`/`-c` resumes the
|
|
911
|
+
* newest session for the launch cwd, so at the root it resolves ambiguously.
|
|
912
|
+
* anon-pi refuses both without a project (the project may be `.` for the root,
|
|
913
|
+
* and is created on demand), so where the conversation lands is always explicit.
|
|
914
|
+
*/
|
|
915
|
+
const PI_RESUME_NEEDS_PROJECT_FLAGS: ReadonlySet<string> = new Set([
|
|
916
|
+
'--fork',
|
|
917
|
+
'--continue',
|
|
918
|
+
'-c',
|
|
919
|
+
]);
|
|
920
|
+
|
|
921
|
+
/** True iff `a` is a RESUME-family flag (resolve session cwd; see PI_RESUME_FLAGS). */
|
|
922
|
+
export function isPiResumeFlag(a: string): boolean {
|
|
923
|
+
return PI_RESUME_FLAGS.has(a);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** True iff `a` is a session flag that needs an explicit project (--fork/--continue). */
|
|
927
|
+
export function isPiNeedsProjectFlag(a: string): boolean {
|
|
928
|
+
return PI_RESUME_NEEDS_PROJECT_FLAGS.has(a);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* PURE: the human name a --fork/--continue no-project refusal quotes. `-c` is
|
|
933
|
+
* spelled as its long form `--continue` in the message (clearer guidance).
|
|
934
|
+
*/
|
|
935
|
+
export function needsProjectFlagName(flag: string): string {
|
|
936
|
+
return flag === '-c' ? '--continue' : flag;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* PURE: the leading session-id `--fork <id>` / `--continue <id>` accepts, or
|
|
941
|
+
* undefined. Used only to build a copy-pasteable "add a project" hint in the
|
|
942
|
+
* refusal (`anon-pi . --fork <id>`); the id is the token right after the flag
|
|
943
|
+
* when it is not itself another flag.
|
|
944
|
+
*/
|
|
945
|
+
export function resumeFlagId(piArgs: readonly string[]): string | undefined {
|
|
946
|
+
if (piArgs.length < 2) return undefined;
|
|
947
|
+
const id = piArgs[1];
|
|
948
|
+
return id.startsWith('-') ? undefined : id;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* PURE: extract the session id a RESUME-family launch selects, so the CLI can
|
|
953
|
+
* look its cwd up in the host session store. Scans forwarded pi args for a
|
|
954
|
+
* resume flag (isPiResumeFlag) and returns the NEXT token when it is an id (not
|
|
955
|
+
* another flag). Returns undefined when there is no resume flag or no id after
|
|
956
|
+
* it (e.g. a bare `--resume` picker), in which case the CLI cds nowhere and pi
|
|
957
|
+
* decides as today.
|
|
958
|
+
*/
|
|
959
|
+
export function resumeSessionId(
|
|
960
|
+
piArgs: readonly string[] | undefined,
|
|
961
|
+
): string | undefined {
|
|
962
|
+
if (!piArgs) return undefined;
|
|
963
|
+
for (let i = 0; i < piArgs.length; i++) {
|
|
964
|
+
if (isPiResumeFlag(piArgs[i])) {
|
|
965
|
+
const next = piArgs[i + 1];
|
|
966
|
+
if (next !== undefined && !next.startsWith('-')) return next;
|
|
967
|
+
return undefined;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return undefined;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* PURE: read a pi session's recorded cwd from its session-file HEADER line (the
|
|
975
|
+
* first JSONL record, `{"type":"session","id":"…","cwd":"/projects/x"}`). This
|
|
976
|
+
* is the authoritative cwd (what pi keys the conversation by), better than
|
|
977
|
+
* reversing the lossy `--…--` dir slug. Returns the cwd string, or undefined if
|
|
978
|
+
* the line is not the expected session header with a non-empty string cwd. The
|
|
979
|
+
* caller (CLI) supplies the file's first line; this stays pure + testable.
|
|
980
|
+
*/
|
|
981
|
+
export function sessionHeaderCwd(headerLine: string): string | undefined {
|
|
982
|
+
let parsed: unknown;
|
|
983
|
+
try {
|
|
984
|
+
parsed = JSON.parse(headerLine);
|
|
985
|
+
} catch {
|
|
986
|
+
return undefined;
|
|
987
|
+
}
|
|
988
|
+
if (!parsed || typeof parsed !== 'object') return undefined;
|
|
989
|
+
const rec = parsed as Record<string, unknown>;
|
|
990
|
+
if (rec.type !== 'session') return undefined;
|
|
991
|
+
const cwd = rec.cwd;
|
|
992
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined;
|
|
993
|
+
}
|
|
994
|
+
|
|
876
995
|
/**
|
|
877
996
|
* The explicit pi-passthrough token: `anon-pi pi <args…>` runs pi with the given
|
|
878
997
|
* args and NO project (the general escape hatch for any pi flag). It is a
|
|
@@ -1013,13 +1132,34 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
|
|
|
1013
1132
|
});
|
|
1014
1133
|
}
|
|
1015
1134
|
if (isPiNoProjectFlag(a)) {
|
|
1016
|
-
// A pi flag that needs NO anon-pi project (`--session <id
|
|
1017
|
-
// `--
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1135
|
+
// A pi flag that needs NO anon-pi project (RESUME family `--session <id>`/
|
|
1136
|
+
// `--resume <id>`; `--list-models`/`--models` print + exit). pi resolves
|
|
1137
|
+
// its own cwd (or just prints), so anon-pi launches pi at the projects
|
|
1138
|
+
// root and forwards this flag + everything after it verbatim. For the
|
|
1139
|
+
// RESUME family the CLI then resolves the session's recorded cwd and cds
|
|
1140
|
+
// there so pi resumes in place (no fork prompt). This makes pi's own "To
|
|
1141
|
+
// resume: pi --session <id>" hint usable as `anon-pi --session <id>`. (For
|
|
1142
|
+
// ARBITRARY pi flags with no project, use `anon-pi pi <args…>`.)
|
|
1143
|
+
//
|
|
1144
|
+
// --fork / --continue are REFUSED with no project: they would land a
|
|
1145
|
+
// (new / newest) conversation in the projects ROOT silently. Require an
|
|
1146
|
+
// explicit project (created on demand; `.` for the root) so where the
|
|
1147
|
+
// conversation lands is never a surprise.
|
|
1148
|
+
if (isPiNeedsProjectFlag(a)) {
|
|
1149
|
+
const rest = args.slice(i);
|
|
1150
|
+
const name = needsProjectFlagName(a);
|
|
1151
|
+
const id = resumeFlagId(rest);
|
|
1152
|
+
const example = id
|
|
1153
|
+
? `anon-pi <project> ${name} ${id}` +
|
|
1154
|
+
` (or \`anon-pi . ${name} ${id}\` for the root)`
|
|
1155
|
+
: `anon-pi <project> ${name} …` +
|
|
1156
|
+
` (or \`anon-pi . ${name} …\` for the root)`;
|
|
1157
|
+
fail(
|
|
1158
|
+
`${name} needs a project so the conversation lands in a known ` +
|
|
1159
|
+
`directory, not the projects root. Add one (it is created on ` +
|
|
1160
|
+
`demand): ${example}.`,
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1023
1163
|
piArgs = args.slice(i);
|
|
1024
1164
|
project = undefined;
|
|
1025
1165
|
i = args.length;
|
|
@@ -1148,7 +1288,15 @@ export function resolveRunPlan(
|
|
|
1148
1288
|
// Which root the cwd resolves under: /work when --mount, else /projects.
|
|
1149
1289
|
const rootKind: RootKind = mounted ? 'mount' : 'projects';
|
|
1150
1290
|
|
|
1151
|
-
|
|
1291
|
+
// A RESUME-family launch with NO project overrides the default no-project cwd
|
|
1292
|
+
// (the projects root) with the session's own recorded cwd, so pi resumes in
|
|
1293
|
+
// place. Only honoured for a projectless pi launch; a given project always
|
|
1294
|
+
// wins (the user is trusted, pi guards a mismatch).
|
|
1295
|
+
const sessionCwd = nonEmpty(intent.sessionCwd);
|
|
1296
|
+
const cwd =
|
|
1297
|
+
mode === 'pi' && project === undefined && sessionCwd !== undefined
|
|
1298
|
+
? sessionCwd
|
|
1299
|
+
: launchCwd(mode, rootKind, project);
|
|
1152
1300
|
|
|
1153
1301
|
const fresh = homeFresh(machine.home);
|
|
1154
1302
|
const seedVersion = intent.seedVersion ?? SEED_VERSION;
|
|
@@ -1360,6 +1508,318 @@ export function resolveRunVsStart(
|
|
|
1360
1508
|
return match ? {action: 'start', ref: match.ref} : {action: 'run'};
|
|
1361
1509
|
}
|
|
1362
1510
|
|
|
1511
|
+
// --- `forward` / `ports`: reach an in-jail server from the host --------------
|
|
1512
|
+
//
|
|
1513
|
+
// netcage owns two host-access verbs (>= 0.9.0): `netcage forward <container>
|
|
1514
|
+
// [<hostPort>:]<jailPort>` stands up ONE host->jail inbound forward, and `netcage
|
|
1515
|
+
// ports <container> --json` lists the jail's TCP LISTEN sockets image-independently
|
|
1516
|
+
// (it reads the sidecar's /proc/net/tcp*, so a minimal image with no ss/netstat/nc
|
|
1517
|
+
// still works). anon-pi wraps them so the user never handles the raw netcage
|
|
1518
|
+
// container name: it resolves the RUNNING anon-pi container(s) by the identity key
|
|
1519
|
+
// it now stamps on EVERY launch (withKeyLabel, not just --keep), disambiguates with
|
|
1520
|
+
// a picker annotated by the open listeners, and shells out to `netcage forward`.
|
|
1521
|
+
// The forced-egress invariant is untouched: `forward` adds no OUTPUT rule (ADR-0014)
|
|
1522
|
+
// and `ports` only reads /proc; anon-pi composes neither egress flag here.
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* PURE: the decoded fields of a stamped keptContainerKey (the reverse of
|
|
1526
|
+
* keptContainerKey's `k=v\n` record). Used by `forward`/`ports` to filter the
|
|
1527
|
+
* running managed containers by machine + project WITHOUT reconstructing the
|
|
1528
|
+
* exact key (which would couple to launchCwd). Unknown/missing fields are ''.
|
|
1529
|
+
*/
|
|
1530
|
+
export interface KeptKeyFields {
|
|
1531
|
+
machine: string;
|
|
1532
|
+
projectsRoot: string;
|
|
1533
|
+
mountParent: string;
|
|
1534
|
+
cwd: string;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
|
|
1538
|
+
export function parseKeptKey(key: string): KeptKeyFields {
|
|
1539
|
+
const out: KeptKeyFields = {
|
|
1540
|
+
machine: '',
|
|
1541
|
+
projectsRoot: '',
|
|
1542
|
+
mountParent: '',
|
|
1543
|
+
cwd: '',
|
|
1544
|
+
};
|
|
1545
|
+
for (const line of key.split('\n')) {
|
|
1546
|
+
const eq = line.indexOf('=');
|
|
1547
|
+
if (eq < 0) continue;
|
|
1548
|
+
const k = line.slice(0, eq);
|
|
1549
|
+
const v = line.slice(eq + 1);
|
|
1550
|
+
if (k === 'machine') out.machine = v;
|
|
1551
|
+
else if (k === 'projectsRoot') out.projectsRoot = v;
|
|
1552
|
+
else if (k === 'mountParent') out.mountParent = v;
|
|
1553
|
+
else if (k === 'cwd') out.cwd = v;
|
|
1554
|
+
}
|
|
1555
|
+
return out;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* PURE: the leaf name of a stamped key's cwd, i.e. the project a container hosts
|
|
1560
|
+
* (`/projects/recon` -> `recon`, `/projects` -> '.', `/work/x` -> `x`, `/root`
|
|
1561
|
+
* -> '' for a bare shell). Used to filter the picker by `<project>` and to label
|
|
1562
|
+
* each row. A root cwd (`/projects`, `/work`) maps to the `.` root token.
|
|
1563
|
+
*/
|
|
1564
|
+
export function keyProject(fields: KeptKeyFields): string {
|
|
1565
|
+
const cwd = fields.cwd;
|
|
1566
|
+
if (cwd === CONTAINER_PROJECTS_ROOT || cwd === CONTAINER_MOUNT_ROOT) {
|
|
1567
|
+
return ROOT_TOKEN;
|
|
1568
|
+
}
|
|
1569
|
+
if (cwd === CONTAINER_HOME_ROOT) return ''; // a bare shell, no project
|
|
1570
|
+
const slash = cwd.lastIndexOf('/');
|
|
1571
|
+
return slash < 0 ? cwd : cwd.slice(slash + 1);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* PURE: pick the RUNNING anon-pi containers a `forward`/`ports` should offer.
|
|
1576
|
+
* Filters the supplied running managed containers (each with its decoded key
|
|
1577
|
+
* fields) to those on `machine`, optionally narrowed to `project` (its leaf cwd
|
|
1578
|
+
* name). With no project, every anon-pi container on the machine qualifies. The
|
|
1579
|
+
* caller resolves 0 (error) / 1 (auto) / many (picker).
|
|
1580
|
+
*/
|
|
1581
|
+
export function resolveManagedMatches(args: {
|
|
1582
|
+
containers: readonly ManagedContainer[];
|
|
1583
|
+
machine: string;
|
|
1584
|
+
project?: string;
|
|
1585
|
+
}): ManagedContainer[] {
|
|
1586
|
+
const {containers, machine, project} = args;
|
|
1587
|
+
return containers.filter((c) => {
|
|
1588
|
+
const f = parseKeptKey(c.key);
|
|
1589
|
+
if (f.machine !== machine) return false;
|
|
1590
|
+
if (project !== undefined && keyProject(f) !== project) return false;
|
|
1591
|
+
return true;
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* A RUNNING netcage-managed container the CLI surfaces to the pure forward/ports
|
|
1597
|
+
* resolution: its anon-pi identity `key` (stamped label, decoded), the `ref` to
|
|
1598
|
+
* pass to `netcage forward`/`ports` (id or name), and a human `name` for the
|
|
1599
|
+
* picker. Mirrors KeptContainer with the display name added.
|
|
1600
|
+
*/
|
|
1601
|
+
export interface ManagedContainer {
|
|
1602
|
+
key: string;
|
|
1603
|
+
ref: string;
|
|
1604
|
+
name: string;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* A parsed, validated port argument for `forward`: the in-jail port to reach and
|
|
1609
|
+
* the host port to bind it on (equal to the jail port unless a `<hostPort>:`
|
|
1610
|
+
* prefix remapped it). `raw` is the exact token to hand to netcage verbatim
|
|
1611
|
+
* (`3001` or `8080:3001`), so anon-pi never re-serialises netcage's grammar.
|
|
1612
|
+
*/
|
|
1613
|
+
export interface ForwardPort {
|
|
1614
|
+
hostPort: number;
|
|
1615
|
+
jailPort: number;
|
|
1616
|
+
raw: string;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* PURE: parse a `forward` port token `[<hostPort>:]<jailPort>` (docker/kubectl
|
|
1621
|
+
* host-first order). One port `3001` maps host 3001 -> jail 3001; `8080:3001`
|
|
1622
|
+
* maps host 8080 -> jail 3001. Both sides must be integers in 1..65535. Throws
|
|
1623
|
+
* AnonPiError on a bad shape / out-of-range / extra colon, with copy-pasteable
|
|
1624
|
+
* guidance. `raw` is normalised to `<host>:<jail>` only when they differ, else
|
|
1625
|
+
* the bare jail port, matching netcage's own accepted forms.
|
|
1626
|
+
*/
|
|
1627
|
+
export function parsePortArg(token: string): ForwardPort {
|
|
1628
|
+
const bad = (why: string): never => {
|
|
1629
|
+
throw new AnonPiError(
|
|
1630
|
+
`anon-pi: invalid --port ${JSON.stringify(token)}: ${why}. ` +
|
|
1631
|
+
'Use <jailPort> (e.g. 3001) or <hostPort>:<jailPort> (e.g. 8080:3001), ' +
|
|
1632
|
+
'each 1..65535.',
|
|
1633
|
+
);
|
|
1634
|
+
};
|
|
1635
|
+
const parts = token.split(':');
|
|
1636
|
+
if (parts.length > 2) bad('too many colons');
|
|
1637
|
+
const toPort = (s: string): number => {
|
|
1638
|
+
if (!/^[0-9]+$/.test(s)) bad(`${JSON.stringify(s)} is not a port number`);
|
|
1639
|
+
const n = Number(s);
|
|
1640
|
+
if (n < 1 || n > 65535) bad(`${s} is out of range (1..65535)`);
|
|
1641
|
+
return n;
|
|
1642
|
+
};
|
|
1643
|
+
if (parts.length === 1) {
|
|
1644
|
+
const p = toPort(parts[0]);
|
|
1645
|
+
return {hostPort: p, jailPort: p, raw: String(p)};
|
|
1646
|
+
}
|
|
1647
|
+
const hostPort = toPort(parts[0]);
|
|
1648
|
+
const jailPort = toPort(parts[1]);
|
|
1649
|
+
const raw =
|
|
1650
|
+
hostPort === jailPort ? String(jailPort) : `${hostPort}:${jailPort}`;
|
|
1651
|
+
return {hostPort, jailPort, raw};
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** A parsed `anon-pi forward` command (pure; the CLI does the netcage I/O). */
|
|
1655
|
+
export interface ForwardCommand {
|
|
1656
|
+
project?: string;
|
|
1657
|
+
machine: string;
|
|
1658
|
+
machineExplicit: boolean;
|
|
1659
|
+
/** The parsed port, or undefined to prompt from the container's listeners. */
|
|
1660
|
+
port?: ForwardPort;
|
|
1661
|
+
/** `--bind <addr>` passed through to netcage verbatim (undefined => netcage default). */
|
|
1662
|
+
bind?: string;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* PURE: parse `anon-pi forward [<project>] [--port <[hostPort:]jailPort>]
|
|
1667
|
+
* [--bind <addr>] [-m <machine>]`. The bare positional is ALWAYS the project (so
|
|
1668
|
+
* a numeric name like `3001` is a project, never a port); the port is the
|
|
1669
|
+
* `--port`/`-p` flag, removing the number-vs-project ambiguity. `--bind` is
|
|
1670
|
+
* passed through to netcage (which validates 127.0.0.1 / 0.0.0.0). Throws
|
|
1671
|
+
* AnonPiError on an unknown flag, a missing flag argument, a second positional,
|
|
1672
|
+
* or a bad port.
|
|
1673
|
+
*/
|
|
1674
|
+
export function parseForwardArgs(args: readonly string[]): ForwardCommand {
|
|
1675
|
+
const fail = (msg: string): never => {
|
|
1676
|
+
throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi forward --help\`.`);
|
|
1677
|
+
};
|
|
1678
|
+
let project: string | undefined;
|
|
1679
|
+
let machine = DEFAULT_MACHINE;
|
|
1680
|
+
let machineExplicit = false;
|
|
1681
|
+
let port: ForwardPort | undefined;
|
|
1682
|
+
let bind: string | undefined;
|
|
1683
|
+
for (let i = 0; i < args.length; i++) {
|
|
1684
|
+
const a = args[i];
|
|
1685
|
+
if (a === '-m' || a === '--machine') {
|
|
1686
|
+
const v = args[++i];
|
|
1687
|
+
if (v === undefined) fail(`${a} needs a machine name`);
|
|
1688
|
+
machine = validateName(v as string, 'machine');
|
|
1689
|
+
machineExplicit = true;
|
|
1690
|
+
} else if (a === '-p' || a === '--port') {
|
|
1691
|
+
const v = args[++i];
|
|
1692
|
+
if (v === undefined) fail(`${a} needs a port ([hostPort:]jailPort)`);
|
|
1693
|
+
port = parsePortArg(v as string);
|
|
1694
|
+
} else if (a === '--bind') {
|
|
1695
|
+
const v = args[++i];
|
|
1696
|
+
if (v === undefined)
|
|
1697
|
+
fail('--bind needs an address (127.0.0.1 or 0.0.0.0)');
|
|
1698
|
+
bind = v as string;
|
|
1699
|
+
} else if (a.startsWith('-')) {
|
|
1700
|
+
fail(`unknown option: ${a}`);
|
|
1701
|
+
} else if (project === undefined) {
|
|
1702
|
+
project = validateName(a, 'project');
|
|
1703
|
+
} else {
|
|
1704
|
+
fail(`unexpected argument: ${a} (forward takes at most one project)`);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return {project, machine, machineExplicit, port, bind};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/** A parsed `anon-pi ports` command (pure). */
|
|
1711
|
+
export interface PortsCommand {
|
|
1712
|
+
project?: string;
|
|
1713
|
+
machine: string;
|
|
1714
|
+
machineExplicit: boolean;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* PURE: parse `anon-pi ports [<project>] [-m <machine>]`. Like forward but with
|
|
1719
|
+
* no port/bind: it lists a container's open in-jail listeners. The bare
|
|
1720
|
+
* positional is the project filter.
|
|
1721
|
+
*/
|
|
1722
|
+
export function parsePortsArgs(args: readonly string[]): PortsCommand {
|
|
1723
|
+
const fail = (msg: string): never => {
|
|
1724
|
+
throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi ports --help\`.`);
|
|
1725
|
+
};
|
|
1726
|
+
let project: string | undefined;
|
|
1727
|
+
let machine = DEFAULT_MACHINE;
|
|
1728
|
+
let machineExplicit = false;
|
|
1729
|
+
for (let i = 0; i < args.length; i++) {
|
|
1730
|
+
const a = args[i];
|
|
1731
|
+
if (a === '-m' || a === '--machine') {
|
|
1732
|
+
const v = args[++i];
|
|
1733
|
+
if (v === undefined) fail(`${a} needs a machine name`);
|
|
1734
|
+
machine = validateName(v as string, 'machine');
|
|
1735
|
+
machineExplicit = true;
|
|
1736
|
+
} else if (a.startsWith('-')) {
|
|
1737
|
+
fail(`unknown option: ${a}`);
|
|
1738
|
+
} else if (project === undefined) {
|
|
1739
|
+
project = validateName(a, 'project');
|
|
1740
|
+
} else {
|
|
1741
|
+
fail(`unexpected argument: ${a} (ports takes at most one project)`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
return {project, machine, machineExplicit};
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* A jail TCP LISTEN socket, as netcage's `ports --json` reports it: the bind
|
|
1749
|
+
* `address`, the `port`, and `loopbackOnly` (bound 127.0.0.0/8 or ::1). The
|
|
1750
|
+
* contract is netcage's (ADR-0015); anon-pi only consumes it.
|
|
1751
|
+
*/
|
|
1752
|
+
export interface NetcageListener {
|
|
1753
|
+
address: string;
|
|
1754
|
+
port: number;
|
|
1755
|
+
loopbackOnly: boolean;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* PURE: parse `netcage ports --json` output into listeners (best-effort). Keeps
|
|
1760
|
+
* only well-formed `{address:string, port:int, loopbackOnly:bool}` entries;
|
|
1761
|
+
* anything else (a netcage version drift, a non-array) yields []. The caller
|
|
1762
|
+
* treats [] as "no hint", never an error.
|
|
1763
|
+
*/
|
|
1764
|
+
export function parseNetcagePortsJson(stdout: string): NetcageListener[] {
|
|
1765
|
+
let parsed: unknown;
|
|
1766
|
+
try {
|
|
1767
|
+
parsed = JSON.parse(stdout);
|
|
1768
|
+
} catch {
|
|
1769
|
+
return [];
|
|
1770
|
+
}
|
|
1771
|
+
if (!Array.isArray(parsed)) return [];
|
|
1772
|
+
const out: NetcageListener[] = [];
|
|
1773
|
+
for (const e of parsed) {
|
|
1774
|
+
if (!e || typeof e !== 'object') continue;
|
|
1775
|
+
const r = e as Record<string, unknown>;
|
|
1776
|
+
if (
|
|
1777
|
+
typeof r.address === 'string' &&
|
|
1778
|
+
typeof r.port === 'number' &&
|
|
1779
|
+
typeof r.loopbackOnly === 'boolean'
|
|
1780
|
+
) {
|
|
1781
|
+
out.push({
|
|
1782
|
+
address: r.address,
|
|
1783
|
+
port: r.port,
|
|
1784
|
+
loopbackOnly: r.loopbackOnly,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
return out;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/** netcage's in-jail DNS forwarder always listens here; anon-pi hides it from the port hint. */
|
|
1792
|
+
export const NETCAGE_DNS_PORT = 53;
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* PURE: the in-jail ports worth offering as forward targets: the listeners with
|
|
1796
|
+
* netcage's own `127.0.0.1:53` DNS forwarder dropped (it is never something a
|
|
1797
|
+
* user forwards), de-duplicated by port, sorted ascending. A server bound on
|
|
1798
|
+
* both IPv4 and IPv6 (two listeners, same port) collapses to one entry.
|
|
1799
|
+
*/
|
|
1800
|
+
export function forwardablePorts(
|
|
1801
|
+
listeners: readonly NetcageListener[],
|
|
1802
|
+
): number[] {
|
|
1803
|
+
const ports = new Set<number>();
|
|
1804
|
+
for (const l of listeners) {
|
|
1805
|
+
if (l.port === NETCAGE_DNS_PORT && l.loopbackOnly) continue;
|
|
1806
|
+
ports.add(l.port);
|
|
1807
|
+
}
|
|
1808
|
+
return [...ports].sort((a, b) => a - b);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* PURE: a compact one-line hint of a container's forwardable in-jail ports for
|
|
1813
|
+
* the picker / the pre-forward confirmation, e.g. `open: 3001, 5173` or
|
|
1814
|
+
* `open: (none detected)`. Never includes the DNS forwarder (forwardablePorts).
|
|
1815
|
+
*/
|
|
1816
|
+
export function formatPortsHint(listeners: readonly NetcageListener[]): string {
|
|
1817
|
+
const ports = forwardablePorts(listeners);
|
|
1818
|
+
return ports.length === 0
|
|
1819
|
+
? 'open: (none detected)'
|
|
1820
|
+
: `open: ${ports.join(', ')}`;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1363
1823
|
// --- The bare-launch menu: choice-list + per-machine project-usage record ----
|
|
1364
1824
|
//
|
|
1365
1825
|
// anon-pi's bare launch shows a HOST-side arrow-key menu of a machine's
|
|
@@ -2550,12 +3010,14 @@ USAGE
|
|
|
2550
3010
|
anon-pi MENU: pick a project (pi), a shell, or a new project
|
|
2551
3011
|
anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
|
|
2552
3012
|
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
|
|
2554
|
-
anon-pi --
|
|
3013
|
+
anon-pi --session <id> resume a pi session by id, in its own project (also -r/--resume)
|
|
3014
|
+
anon-pi <project> --fork <id> fork a session into <project> (\`.\`=root; --continue too; project required)
|
|
2555
3015
|
anon-pi --list-models list the models pi sees (also --models; no project needed)
|
|
2556
3016
|
anon-pi pi <pi-args…> run pi with ANY args and no project (the passthrough)
|
|
2557
3017
|
anon-pi --version print anon-pi's version (also -V)
|
|
2558
3018
|
anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
|
|
3019
|
+
anon-pi forward [<p>] [--port …] open a host port onto a running container's in-jail server
|
|
3020
|
+
anon-pi ports [<project>] list a running container's open in-jail TCP listeners
|
|
2559
3021
|
anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
|
|
2560
3022
|
anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
|
|
2561
3023
|
anon-pi init onboard: verify your proxy, capture your local model, pick an image
|