@tablation/crew 0.51.0 → 0.52.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.
Files changed (2) hide show
  1. package/dist/cli.js +154 -13
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11608,7 +11608,8 @@ function keyIsAdmin(route) {
11608
11608
  return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
11609
11609
  }
11610
11610
  var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
11611
- var MEMBER_NO_KEY = "no agent key for this seat \u2014 minting needs a workspace admin key";
11611
+ var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
11612
+ var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
11612
11613
  function seatKeySlug(workspaceSlug, agentId) {
11613
11614
  return `${workspaceSlug}#agent:${agentId}`;
11614
11615
  }
@@ -11644,6 +11645,13 @@ async function resolveSeatKey(route, role, getStore = getSessionStore2) {
11644
11645
  return ship("OS keychain unavailable");
11645
11646
  }
11646
11647
  }
11648
+ function describeStoreError(e) {
11649
+ const msg = e instanceof Error ? e.message : String(e);
11650
+ if (/User interaction is not allowed/i.test(msg)) {
11651
+ return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
11652
+ }
11653
+ return "keychain unavailable (could not write to the OS keychain)";
11654
+ }
11647
11655
  async function mintMissingSeatKeys(route, o) {
11648
11656
  const out = [];
11649
11657
  if (!route.resolved) return out;
@@ -11660,11 +11668,27 @@ async function mintMissingSeatKeys(route, o) {
11660
11668
  return targets.map(({ role }) => ({
11661
11669
  role,
11662
11670
  action: "failed",
11663
- detail: `OS keychain unavailable: ${e.message}`
11671
+ detail: describeStoreError(e)
11664
11672
  }));
11665
11673
  }
11666
11674
  const host = hostFromUrl2(route.baseUrl);
11667
11675
  const base = route.baseUrl.replace(/\/+$/, "");
11676
+ if (!o.dryRun) {
11677
+ const probeSlug = seatKeySlug(splitRoute(route.route)[0], "_probe");
11678
+ try {
11679
+ await store.set(host, probeSlug, {
11680
+ apiKey: "probe",
11681
+ shipId: "",
11682
+ workspaceId: resolved.workspaceId,
11683
+ identityId: "_probe",
11684
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
11685
+ });
11686
+ await store.clear(host, probeSlug);
11687
+ } catch (e) {
11688
+ const detail = `${describeStoreError(e)}; no keys minted`;
11689
+ return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
11690
+ }
11691
+ }
11668
11692
  for (const { role, agentId } of targets) {
11669
11693
  if (!agentId) {
11670
11694
  out.push({ role, action: "no_agent" });
@@ -11689,6 +11713,10 @@ async function mintMissingSeatKeys(route, o) {
11689
11713
  },
11690
11714
  body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
11691
11715
  });
11716
+ if (res.status === 403) {
11717
+ out.push({ role, action: "forbidden" });
11718
+ break;
11719
+ }
11692
11720
  if (!res.ok) {
11693
11721
  out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
11694
11722
  continue;
@@ -11698,16 +11726,39 @@ async function mintMissingSeatKeys(route, o) {
11698
11726
  out.push({ role, action: "failed", detail: "response carried no key" });
11699
11727
  continue;
11700
11728
  }
11701
- await store.set(host, slug, {
11702
- apiKey: body.key,
11703
- shipId: o.shipName ?? "",
11704
- workspaceId: resolved.workspaceId,
11705
- identityId: agentId,
11706
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
11707
- });
11729
+ try {
11730
+ const session = {
11731
+ apiKey: body.key,
11732
+ shipId: o.shipName ?? "",
11733
+ workspaceId: resolved.workspaceId,
11734
+ identityId: agentId,
11735
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
11736
+ ...body.id ? { keyId: body.id } : {}
11737
+ };
11738
+ await store.set(host, slug, session);
11739
+ } catch (e) {
11740
+ let revoked = false;
11741
+ if (body.id) {
11742
+ try {
11743
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
11744
+ method: "DELETE",
11745
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11746
+ });
11747
+ revoked = del2.ok;
11748
+ } catch {
11749
+ revoked = false;
11750
+ }
11751
+ }
11752
+ out.push({
11753
+ role,
11754
+ action: "failed",
11755
+ detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
11756
+ });
11757
+ continue;
11758
+ }
11708
11759
  out.push({ role, action: "minted" });
11709
11760
  } catch (e) {
11710
- out.push({ role, action: "failed", detail: e.message });
11761
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
11711
11762
  }
11712
11763
  }
11713
11764
  return out;
@@ -11720,10 +11771,92 @@ function describeMintOutcome(o) {
11720
11771
  return `${o.role}: agent key already stored`;
11721
11772
  case "no_agent":
11722
11773
  return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
11774
+ case "forbidden":
11775
+ return FORBIDDEN_LINE;
11723
11776
  case "failed":
11724
11777
  return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
11725
11778
  }
11726
11779
  }
11780
+ async function revokeSeatKeys(route, o) {
11781
+ const out = [];
11782
+ if (!route.resolved) return out;
11783
+ const resolved = route.resolved;
11784
+ const doFetch = o.fetchImpl ?? fetch;
11785
+ const targets = [
11786
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
11787
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
11788
+ ];
11789
+ let store;
11790
+ try {
11791
+ store = (o.getStore ?? getSessionStore2)();
11792
+ } catch (e) {
11793
+ return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
11794
+ }
11795
+ const host = hostFromUrl2(route.baseUrl);
11796
+ const base = route.baseUrl.replace(/\/+$/, "");
11797
+ for (const { role, agentId } of targets) {
11798
+ if (!agentId) {
11799
+ out.push({ role, action: "no_agent" });
11800
+ continue;
11801
+ }
11802
+ const slug = slugFor(route, agentId);
11803
+ try {
11804
+ const session = await store.get(host, slug);
11805
+ if (!session?.apiKey) {
11806
+ out.push({ role, action: "not_stored" });
11807
+ continue;
11808
+ }
11809
+ if (!session.keyId) {
11810
+ out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
11811
+ continue;
11812
+ }
11813
+ if (o.dryRun) {
11814
+ out.push({ role, action: "revoked", detail: "dry run" });
11815
+ continue;
11816
+ }
11817
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
11818
+ method: "DELETE",
11819
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11820
+ });
11821
+ if (!del2.ok && del2.status !== 404) {
11822
+ out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
11823
+ continue;
11824
+ }
11825
+ await store.clear(host, slug);
11826
+ out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
11827
+ } catch (e) {
11828
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
11829
+ }
11830
+ }
11831
+ return out;
11832
+ }
11833
+ function describeRevokeOutcome(o) {
11834
+ switch (o.action) {
11835
+ case "revoked":
11836
+ return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
11837
+ case "not_stored":
11838
+ return `${o.role}: no agent key stored on this ship`;
11839
+ case "no_agent":
11840
+ return `${o.role}: no linked Agents row`;
11841
+ case "failed":
11842
+ return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
11843
+ }
11844
+ }
11845
+ async function canMintAgentKeys(route, o = {}) {
11846
+ const resolved = route.resolved;
11847
+ if (!resolved) return void 0;
11848
+ const agentId = seatAgentId(route, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route, r)).find(Boolean);
11849
+ if (!agentId) return void 0;
11850
+ try {
11851
+ const res = await (o.fetchImpl ?? fetch)(`${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
11852
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11853
+ });
11854
+ if (res.ok) return true;
11855
+ return res.status === 403 ? false : void 0;
11856
+ } catch {
11857
+ return void 0;
11858
+ }
11859
+ }
11727
11860
 
11728
11861
  // src/logbook.ts
11729
11862
  import { createHash as createHash3 } from "crypto";
@@ -13622,7 +13755,7 @@ function usage() {
13622
13755
  crew inbox [--member NAME] your tickets across every workspace (or a colleague's)
13623
13756
  crew connect resolve a workspace's ids into a crew.yaml block (--no-mcp skips registering the Tablation MCP server with Claude Code; --mcp replace overwrites a different one)
13624
13757
  crew disconnect remove what connect installed for Pair (Claude MCP; Codex/Gemini instructions block and MCP entry per ship.pair)
13625
- crew agents sync [route] [--force] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt)
13758
+ crew agents sync [route] [--force] [--revoke-seat-keys] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt)
13626
13759
  crew agents prompt R [route] print one persona's current prompt (e.g. R=pair, for a SessionStart hook; --harness H adds which CLI it serves)
13627
13760
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
13628
13761
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
@@ -15143,7 +15276,7 @@ ${changed2.map((c) => ` - ${c}
15143
15276
  break;
15144
15277
  }
15145
15278
  if (sub !== "sync") {
15146
- process.stderr.write("crew agents sync [route] [--force] push crew's personas (Developer/Design/QA/Triage/Pair) AND skill files (e.g. grill-me) into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt)\n");
15279
+ process.stderr.write("crew agents sync [route] [--force] push crew's personas (Developer/Design/QA/Triage/Pair) AND skill files (e.g. grill-me) into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt; --revoke-seat-keys deletes this ship's seat keys server-side and from the keychain, so the next sync mints fresh ones)\n");
15147
15280
  process.stderr.write(`crew agents prompt <role> [route] print a persona's current prompt (roles: ${ROLE_NAMES.join(", ")})
15148
15281
  `);
15149
15282
  process.exit(2);
@@ -15226,7 +15359,12 @@ ${changed2.map((c) => ` - ${c}
15226
15359
  }
15227
15360
  if (readOnly) {
15228
15361
  process.stdout.write("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)\n");
15229
- process.stdout.write("seat keys: not minted (needs a workspace admin key)\n");
15362
+ }
15363
+ if (flag("revoke-seat-keys")) {
15364
+ for (const o of await revokeSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun })) {
15365
+ process.stdout.write(`${describeRevokeOutcome(o)}
15366
+ `);
15367
+ }
15230
15368
  } else {
15231
15369
  for (const o of await mintMissingSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun, shipName: cfg.ship.name })) {
15232
15370
  process.stdout.write(`${describeMintOutcome(o)}
@@ -16631,6 +16769,9 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
16631
16769
  const keyRole = route.resolved?.role ?? await fetchKeyRole(route, cfg.ship.userAgent);
16632
16770
  const roleRoute = route.resolved && keyRole ? { ...route, resolved: { ...route.resolved, role: keyRole } } : route;
16633
16771
  process.stdout.write(`api key role: ${keyRole ?? "unknown"}
16772
+ `);
16773
+ const canMint = await canMintAgentKeys(roleRoute, { userAgent: cfg.ship.userAgent });
16774
+ process.stdout.write(`can mint agent keys: ${canMint === void 0 ? "unknown" : canMint ? "yes" : "no"}
16634
16775
  `);
16635
16776
  for (const role of SEAT_KEY_ROLES) {
16636
16777
  if (!route.resolved?.seats[role]) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",