@openparachute/hub 0.5.0 → 0.5.2

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.
@@ -517,9 +517,13 @@ describe("parachute start", () => {
517
517
  }
518
518
  });
519
519
 
520
- test("third-party with no installDir errors as unknown service", async () => {
521
- // A row whose name isn't a known short name AND has no installDir is
522
- // unmanageable we have no way to find a spec for it.
520
+ test("start: installDir-less third-party row surfaces an actionable error", async () => {
521
+ // A services.json row whose name isn't first-party AND has no installDir
522
+ // can't yield a startCmd. Pre-fix this hit the generic "unknown service"
523
+ // path (misleading — the row exists, just with stale shape). Post-fix
524
+ // resolveTargets returns the entry with spec=undefined and start prints
525
+ // an actionable message that points at the real fix (re-install or
526
+ // upgrade-the-module).
523
527
  const h = makeHarness();
524
528
  try {
525
529
  upsertService(
@@ -539,7 +543,30 @@ describe("parachute start", () => {
539
543
  log: (l) => lines.push(l),
540
544
  });
541
545
  expect(code).toBe(1);
542
- expect(lines.join("\n")).toMatch(/unknown service "mystery"/);
546
+ const out = lines.join("\n");
547
+ expect(out).toMatch(/services\.json entry has no installDir/);
548
+ expect(out).toMatch(/parachute install <path-to-mystery>/);
549
+ expect(out).not.toMatch(/unknown service/);
550
+ } finally {
551
+ h.cleanup();
552
+ }
553
+ });
554
+
555
+ test("start: name absent from services.json still errors as unknown service", async () => {
556
+ // The genuinely-unknown path: no first-party fallback, no row in
557
+ // services.json. Distinguish from the above (row exists but lacks
558
+ // installDir) so the error message is right-shaped for each.
559
+ const h = makeHarness();
560
+ try {
561
+ seedVault(h.manifestPath);
562
+ const lines: string[] = [];
563
+ const code = await start("ghost", {
564
+ configDir: h.configDir,
565
+ manifestPath: h.manifestPath,
566
+ log: (l) => lines.push(l),
567
+ });
568
+ expect(code).toBe(1);
569
+ expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
543
570
  } finally {
544
571
  h.cleanup();
545
572
  }
@@ -721,6 +748,45 @@ describe("parachute stop", () => {
721
748
  h.cleanup();
722
749
  }
723
750
  });
751
+
752
+ test("third-party row without installDir: stops via pidfile", async () => {
753
+ // Graceful-degradation path: an installed-but-stale third-party row
754
+ // (no installDir field — pre-installDir-contract self-registration)
755
+ // should still be stoppable. stop only needs the short name to find
756
+ // the pidfile; spec resolution isn't on the critical path for stop.
757
+ const h = makeHarness();
758
+ try {
759
+ upsertService(
760
+ {
761
+ name: "mystery",
762
+ port: 1944,
763
+ paths: ["/mystery"],
764
+ health: "/mystery/health",
765
+ version: "0.0.1",
766
+ },
767
+ h.manifestPath,
768
+ );
769
+ writePid("mystery", 4242, h.configDir);
770
+ const killed: Array<[number, string | number]> = [];
771
+ let aliveCall = 0;
772
+ const code = await stop("mystery", {
773
+ configDir: h.configDir,
774
+ manifestPath: h.manifestPath,
775
+ kill: (pid, sig) => killed.push([pid, sig]),
776
+ alive: () => {
777
+ aliveCall++;
778
+ return aliveCall === 1;
779
+ },
780
+ sleep: async () => {},
781
+ log: () => {},
782
+ });
783
+ expect(code).toBe(0);
784
+ expect(killed).toEqual([[4242, "SIGTERM"]]);
785
+ expect(readPid("mystery", h.configDir)).toBeUndefined();
786
+ } finally {
787
+ h.cleanup();
788
+ }
789
+ });
724
790
  });
725
791
 
726
792
  describe("parachute restart", () => {
@@ -822,6 +888,37 @@ describe("parachute logs", () => {
822
888
  h.cleanup();
823
889
  }
824
890
  });
891
+
892
+ test("third-party row without installDir: tails by short name", async () => {
893
+ // Graceful-degradation path: log file is keyed by short name, written by
894
+ // start. installDir is irrelevant for tailing — the entry just needs to
895
+ // exist in services.json.
896
+ const h = makeHarness();
897
+ try {
898
+ upsertService(
899
+ {
900
+ name: "mystery",
901
+ port: 1944,
902
+ paths: ["/mystery"],
903
+ health: "/mystery/health",
904
+ version: "0.0.1",
905
+ },
906
+ h.manifestPath,
907
+ );
908
+ const p = ensureLogPath("mystery", h.configDir);
909
+ writeFileSync(p, "mystery line 1\nmystery line 2\n");
910
+ const lines: string[] = [];
911
+ const code = await logs("mystery", {
912
+ configDir: h.configDir,
913
+ manifestPath: h.manifestPath,
914
+ log: (l) => lines.push(l),
915
+ });
916
+ expect(code).toBe(0);
917
+ expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
918
+ } finally {
919
+ h.cleanup();
920
+ }
921
+ });
825
922
  });
826
923
 
827
924
  describe("process-group lifecycle (hub#88)", () => {
@@ -130,6 +130,19 @@ describe("validateModuleManifest", () => {
130
130
  const m = validateModuleManifest(VALID, "x");
131
131
  expect(m.managementUrl).toBeUndefined();
132
132
  });
133
+
134
+ test("stripPrefix accepts boolean true and false; rejects non-boolean", () => {
135
+ expect(validateModuleManifest({ ...VALID, stripPrefix: true }, "x").stripPrefix).toBe(true);
136
+ expect(validateModuleManifest({ ...VALID, stripPrefix: false }, "x").stripPrefix).toBe(false);
137
+ expect(() => validateModuleManifest({ ...VALID, stripPrefix: "yes" }, "x")).toThrow(
138
+ /stripPrefix/,
139
+ );
140
+ });
141
+
142
+ test("stripPrefix absent stays absent", () => {
143
+ const m = validateModuleManifest(VALID, "x");
144
+ expect(m.stripPrefix).toBeUndefined();
145
+ });
133
146
  });
134
147
 
135
148
  describe("readModuleManifest", () => {
@@ -196,6 +196,32 @@ describe("services-manifest", () => {
196
196
  cleanup();
197
197
  }
198
198
  });
199
+
200
+ test("round-trips optional stripPrefix (true and false)", () => {
201
+ const { path, cleanup } = makeTempPath();
202
+ try {
203
+ const stripping: ServiceEntry = { ...vault, stripPrefix: true };
204
+ upsertService(stripping, path);
205
+ expect(readManifest(path).services[0]).toEqual(stripping);
206
+
207
+ const explicitFalse: ServiceEntry = { ...vault, stripPrefix: false };
208
+ upsertService(explicitFalse, path);
209
+ expect(readManifest(path).services[0]).toEqual(explicitFalse);
210
+ } finally {
211
+ cleanup();
212
+ }
213
+ });
214
+
215
+ test("rejects non-boolean stripPrefix", () => {
216
+ const { path, cleanup } = makeTempPath();
217
+ try {
218
+ expect(() =>
219
+ upsertService({ ...vault, stripPrefix: "yes" as unknown as boolean }, path),
220
+ ).toThrow(/stripPrefix/);
221
+ } finally {
222
+ cleanup();
223
+ }
224
+ });
199
225
  });
200
226
 
201
227
  describe("claw → agent migration", () => {
@@ -25,7 +25,13 @@ import { listGrantsForUser, revokeGrant } from "../grants.ts";
25
25
  import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
26
26
  import { openHubDb } from "../hub-db.ts";
27
27
  import { deriveHubOrigin } from "../hub-origin.ts";
28
- import { issueOperatorToken } from "../operator-token.ts";
28
+ import { inferAudience } from "../jwt-audience.ts";
29
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
30
+ import {
31
+ OPERATOR_TOKEN_CLIENT_ID,
32
+ issueOperatorToken,
33
+ readOperatorTokenFile,
34
+ } from "../operator-token.ts";
29
35
  import { rotateSigningKey } from "../signing-keys.ts";
30
36
  import {
31
37
  SingleUserModeError,
@@ -54,6 +60,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
54
60
  "set-password",
55
61
  "list-users",
56
62
  "rotate-operator",
63
+ "mint-token",
57
64
  "pending-clients",
58
65
  "approve-client",
59
66
  "list-grants",
@@ -73,6 +80,9 @@ Usage:
73
80
  parachute auth 2fa backup-codes Regenerate backup codes
74
81
  parachute auth rotate-key Rotate the hub's JWT signing key
75
82
  parachute auth rotate-operator Mint a fresh ~/.parachute/operator.token
83
+ parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]
84
+ Mint a scope-narrow JWT against the
85
+ operator's identity (stdout = JWT)
76
86
  parachute auth pending-clients List OAuth clients awaiting approval
77
87
  parachute auth approve-client <id> Approve a pending OAuth client
78
88
  parachute auth list-grants [--username <name>]
@@ -104,6 +114,15 @@ rotate-operator mints a fresh long-lived operator token at
104
114
  as their bearer when calling on-box services. set-password also writes
105
115
  the file on first-run / password reset.
106
116
 
117
+ mint-token issues a single scope-narrow JWT against the operator's
118
+ identity, signed with the same key as OAuth-issued tokens. Pipeable:
119
+ \`parachute auth mint-token --scope scribe:transcribe | pbcopy\`. The
120
+ audience defaults via the same inference rule the OAuth flow uses
121
+ (named \`vault:<name>:<verb>\` → \`vault.<name>\`, otherwise the first
122
+ colon-prefixed scope's namespace, fallback \`hub\`). TTL defaults to 90d,
123
+ caps at 365d. Requires a valid ~/.parachute/operator.token (run
124
+ \`parachute auth set-password\` or \`rotate-operator\` first).
125
+
107
126
  pending-clients + approve-client gate /oauth/register against operator
108
127
  approval (closes #74). Self-served DCR registrations land as 'pending'
109
128
  and cannot OAuth until you run \`parachute auth approve-client <id>\`.
@@ -571,6 +590,177 @@ function runRevokeGrant(args: readonly string[], deps: AuthDeps): number {
571
590
  }
572
591
  }
573
592
 
593
+ interface MintTokenFlags {
594
+ scope?: string;
595
+ aud?: string;
596
+ ttl?: string;
597
+ sub?: string;
598
+ error?: string;
599
+ }
600
+
601
+ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
602
+ let scope: string | undefined;
603
+ let aud: string | undefined;
604
+ let ttl: string | undefined;
605
+ let sub: string | undefined;
606
+ for (let i = 0; i < args.length; i++) {
607
+ const a = args[i];
608
+ if (a === "--scope") {
609
+ const v = args[++i];
610
+ if (!v) return { error: "--scope requires a value" };
611
+ scope = v;
612
+ } else if (a?.startsWith("--scope=")) {
613
+ scope = a.slice("--scope=".length);
614
+ if (!scope) return { error: "--scope requires a value" };
615
+ } else if (a === "--aud") {
616
+ const v = args[++i];
617
+ if (!v) return { error: "--aud requires a value" };
618
+ aud = v;
619
+ } else if (a?.startsWith("--aud=")) {
620
+ aud = a.slice("--aud=".length);
621
+ if (!aud) return { error: "--aud requires a value" };
622
+ } else if (a === "--ttl") {
623
+ const v = args[++i];
624
+ if (!v) return { error: "--ttl requires a value" };
625
+ ttl = v;
626
+ } else if (a?.startsWith("--ttl=")) {
627
+ ttl = a.slice("--ttl=".length);
628
+ if (!ttl) return { error: "--ttl requires a value" };
629
+ } else if (a === "--sub") {
630
+ const v = args[++i];
631
+ if (!v) return { error: "--sub requires a value" };
632
+ sub = v;
633
+ } else if (a?.startsWith("--sub=")) {
634
+ sub = a.slice("--sub=".length);
635
+ if (!sub) return { error: "--sub requires a value" };
636
+ } else {
637
+ return { error: `unknown flag "${a}"` };
638
+ }
639
+ }
640
+ return { scope, aud, ttl, sub };
641
+ }
642
+
643
+ const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
644
+ const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
645
+
646
+ /**
647
+ * Parse a Go-ish duration string: integer + one of d/h/m/s. Caps at 365d.
648
+ * `90d` → 7776000. We don't honor Go's stdlib `time.ParseDuration` exactly
649
+ * (no `d` there), so this is a small custom parser to keep the operator
650
+ * surface obvious.
651
+ */
652
+ function parseTtl(input: string): { seconds: number } | { error: string } {
653
+ const m = /^(\d+)(d|h|m|s)$/.exec(input);
654
+ if (!m) return { error: `invalid --ttl "${input}" — expected e.g. 90d, 24h, 30m, 60s` };
655
+ const n = Number.parseInt(m[1]!, 10);
656
+ if (!Number.isFinite(n) || n <= 0) return { error: `invalid --ttl "${input}" — must be > 0` };
657
+ const unit = m[2]!;
658
+ const mult = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
659
+ const seconds = n * mult;
660
+ if (seconds > MINT_TOKEN_TTL_MAX_SECONDS) {
661
+ return { error: `--ttl "${input}" exceeds 365d cap` };
662
+ }
663
+ return { seconds };
664
+ }
665
+
666
+ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<number> {
667
+ const flags = parseMintTokenFlags(args);
668
+ if (flags.error) {
669
+ console.error(`parachute auth mint-token: ${flags.error}`);
670
+ return 1;
671
+ }
672
+ if (!flags.scope) {
673
+ console.error("parachute auth mint-token: --scope is required");
674
+ console.error(
675
+ "usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]",
676
+ );
677
+ return 1;
678
+ }
679
+
680
+ const scopes = flags.scope.split(/\s+/).filter((s) => s.length > 0);
681
+ if (scopes.length === 0) {
682
+ console.error("parachute auth mint-token: --scope must contain at least one scope");
683
+ return 1;
684
+ }
685
+
686
+ let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
687
+ if (flags.ttl) {
688
+ const parsed = parseTtl(flags.ttl);
689
+ if ("error" in parsed) {
690
+ console.error(`parachute auth mint-token: ${parsed.error}`);
691
+ return 1;
692
+ }
693
+ ttlSeconds = parsed.seconds;
694
+ }
695
+
696
+ const configDir = deps.configDir ?? CONFIG_DIR;
697
+ const operatorToken = await readOperatorTokenFile(configDir);
698
+ if (!operatorToken) {
699
+ console.error(
700
+ "parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
701
+ );
702
+ console.error(
703
+ "run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
704
+ );
705
+ return 1;
706
+ }
707
+
708
+ const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
709
+
710
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
711
+ try {
712
+ let operatorSub: string;
713
+ try {
714
+ const validated = await validateAccessToken(db, operatorToken, issuer);
715
+ const sub = validated.payload.sub;
716
+ if (typeof sub !== "string" || sub.length === 0) {
717
+ console.error("parachute auth mint-token: operator token has no sub claim");
718
+ return 1;
719
+ }
720
+ // Scope gate: a valid signature + non-expired JWT at this path is not
721
+ // sufficient — the token must carry operator-equivalent scope. Without
722
+ // this, a narrowly-scoped JWT stashed at ~/.parachute/operator.token
723
+ // would be treated as operator-bearer and mint arbitrary tokens
724
+ // (privilege escalation: narrow → arbitrary). Only set-password and
725
+ // rotate-operator legitimately write to this path; both seed the full
726
+ // OPERATOR_TOKEN_SCOPES set, so hub:admin is the right gate.
727
+ const tokenScope =
728
+ typeof validated.payload.scope === "string"
729
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
730
+ : [];
731
+ if (!tokenScope.includes("hub:admin")) {
732
+ console.error("parachute auth mint-token: operator token lacks hub:admin scope");
733
+ console.error("run `parachute auth rotate-operator` to mint a fresh one");
734
+ return 1;
735
+ }
736
+ operatorSub = sub;
737
+ } catch (err) {
738
+ const msg = err instanceof Error ? err.message : String(err);
739
+ console.error(`parachute auth mint-token: operator token invalid — ${msg}`);
740
+ console.error(
741
+ "run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
742
+ );
743
+ return 1;
744
+ }
745
+
746
+ const audience = flags.aud ?? inferAudience(scopes);
747
+ const sub = flags.sub ?? operatorSub;
748
+
749
+ const minted = await signAccessToken(db, {
750
+ sub,
751
+ scopes,
752
+ audience,
753
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
754
+ issuer,
755
+ ttlSeconds,
756
+ });
757
+ console.log(minted.token);
758
+ return 0;
759
+ } finally {
760
+ db.close();
761
+ }
762
+ }
763
+
574
764
  function runListUsers(deps: AuthDeps): number {
575
765
  const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
576
766
  try {
@@ -641,6 +831,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
641
831
  return 1;
642
832
  }
643
833
  }
834
+ if (sub === "mint-token") {
835
+ try {
836
+ return await runMintToken(args.slice(1), normalized);
837
+ } catch (err) {
838
+ const msg = err instanceof Error ? err.message : String(err);
839
+ console.error(`parachute auth mint-token: ${msg}`);
840
+ return 1;
841
+ }
842
+ }
644
843
  if (sub === "pending-clients") {
645
844
  try {
646
845
  return runPendingClients(normalized);
@@ -214,8 +214,10 @@ interface ResolvedTarget {
214
214
  * Lifecycle spec resolved at request time. First-party comes from
215
215
  * `getSpec(short)`; third-party comes from
216
216
  * `getSpecFromInstallDir(entry.installDir, ...)`. May be undefined when
217
- * a row has neither — lifecycle prints "lifecycle not yet supported"
218
- * for that service rather than crashing the whole sweep.
217
+ * a row has neither — `start` prints the actionable "no installDir"
218
+ * re-install message for an installDir-less third-party row, or
219
+ * "lifecycle not yet supported" otherwise; `stop`/`logs` keep working
220
+ * via pidfile/logfile semantics keyed by `short`.
219
221
  */
220
222
  spec: ServiceSpec | undefined;
221
223
  }
@@ -248,6 +250,13 @@ async function specForEntry(
248
250
  * `module.json` (which is what install copied to `entry.name` for
249
251
  * third-party). First-party are addressed by their short name (vault,
250
252
  * notes, …) and matched via `shortNameForManifest`.
253
+ *
254
+ * Named-path detail: a third-party row whose name matches but lacks
255
+ * `installDir` resolves to the entry with `spec: undefined` (rather than
256
+ * an "unknown service" error). `stop`/`logs` handle the spec-less case
257
+ * via pidfile/logfile semantics; `start` surfaces an actionable
258
+ * re-install hint downstream. The genuinely-unknown path (no first-party
259
+ * fallback AND no row in services.json) still errors as `unknown service`.
251
260
  */
252
261
  async function resolveTargets(
253
262
  svc: string | undefined,
@@ -268,13 +277,19 @@ async function resolveTargets(
268
277
  }
269
278
  return { targets: [{ short: svc, entry, spec: firstPartySpec }] };
270
279
  }
271
- // Third-party: match a services.json row by name. Third-party rows
272
- // carry `installDir`; without it we have no way to resolve a spec.
280
+ // Third-party: match a services.json row by name. Rows with `installDir`
281
+ // resolve a full spec from the on-disk module.json. Rows without it are
282
+ // still managed (stop/logs use pidfile/logfile semantics keyed by short
283
+ // name), but with `spec: undefined` — `start` will surface an
284
+ // installDir-specific error downstream rather than reject up front.
273
285
  const entry = manifest.services.find((s) => s.name === svc);
274
- if (entry?.installDir) {
275
- const { spec, error } = await specForEntry(svc, entry);
276
- if (error) return { error: `${svc}: invalid module.json ${error}` };
277
- return { targets: [{ short: svc, entry, spec }] };
286
+ if (entry) {
287
+ if (entry.installDir) {
288
+ const { spec, error } = await specForEntry(svc, entry);
289
+ if (error) return { error: `${svc}: invalid module.json ${error}` };
290
+ return { targets: [{ short: svc, entry, spec }] };
291
+ }
292
+ return { targets: [{ short: svc, entry, spec: undefined }] };
278
293
  }
279
294
  return {
280
295
  error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
@@ -323,7 +338,17 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
323
338
 
324
339
  const cmd = spec?.startCmd?.(entry);
325
340
  if (!cmd || cmd.length === 0) {
326
- r.log(`${short}: lifecycle not yet supported for this service.`);
341
+ // Distinguish the missing-installDir case from "spec resolved but has
342
+ // no startCmd" — the former is fixable by re-registering the module,
343
+ // the latter is a hub-level limitation. Third-party rows hit the first
344
+ // branch when their self-registration predates the installDir contract.
345
+ if (!getSpec(short) && !entry.installDir) {
346
+ r.log(
347
+ `${short}: services.json entry has no installDir, so the start command can't be resolved. Re-run \`parachute install <path-to-${short}>\` to refresh its registration, or upgrade the module to a version that self-registers with installDir.`,
348
+ );
349
+ } else {
350
+ r.log(`${short}: lifecycle not yet supported for this service.`);
351
+ }
327
352
  failures++;
328
353
  continue;
329
354
  }
@@ -487,13 +512,14 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
487
512
 
488
513
  // logs only needs a valid short name to find the log file. First-party
489
514
  // wins via the spec lookup; third-party rows match by `entry.name`; the
490
- // internal hub is a known short outside of services.json. We don't need
491
- // the full spec here — we just need to confirm the name maps to
492
- // something the CLI manages.
515
+ // internal hub is a known short outside of services.json. installDir is
516
+ // irrelevant here — the log file is keyed by short name and exists once
517
+ // the service has run, regardless of how it was registered. We just need
518
+ // to confirm the name maps to something the CLI manages.
493
519
  const isFirstParty = getSpec(svc) !== undefined;
494
520
  if (!isFirstParty && svc !== HUB_SVC) {
495
521
  const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
496
- if (!entry?.installDir) {
522
+ if (!entry) {
497
523
  log(`unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`);
498
524
  return 1;
499
525
  }