@lanes-sh/link 0.9.2 → 0.9.3

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 (57) hide show
  1. package/README.md +10 -4
  2. package/instructions/skills/lanes-link/SKILL.md +73 -48
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +127 -26
  5. package/src/cli/accepts.ts +13 -3
  6. package/src/cli/commands/connect/declare.ts +16 -7
  7. package/src/cli/commands/connect/index.ts +4 -4
  8. package/src/cli/commands/connect/settle.ts +33 -7
  9. package/src/cli/commands/connection-list.ts +25 -1
  10. package/src/cli/commands/mcp/harnesses.ts +36 -22
  11. package/src/cli/commands/mcp/register.ts +27 -8
  12. package/src/cli/commands/mcp/stdio.ts +0 -1
  13. package/src/cli/commands/operate/inspect.ts +21 -10
  14. package/src/cli/commands/operate/outputs.ts +94 -61
  15. package/src/cli/commands/operate/serve.ts +0 -4
  16. package/src/cli/commands/operate/token.ts +305 -35
  17. package/src/cli/commands/operate/tools.ts +28 -5
  18. package/src/cli/commands/operate.ts +7 -1
  19. package/src/cli/commands/profile/removal.ts +10 -9
  20. package/src/cli/config-repair-sweep.ts +18 -4
  21. package/src/cli/config-repair.ts +1 -1
  22. package/src/cli/config-templates.ts +14 -7
  23. package/src/cli/contract3-credentials.ts +8 -8
  24. package/src/cli/contract4.ts +7 -2
  25. package/src/cli/contract5.ts +234 -0
  26. package/src/cli/endpoint-url.ts +17 -3
  27. package/src/cli/main.ts +28 -4
  28. package/src/cli/publish.ts +13 -4
  29. package/src/cli/runtime/open.ts +19 -2
  30. package/src/cli/runtime/select.ts +0 -12
  31. package/src/cli/runtime.ts +0 -1
  32. package/src/cli/selection.ts +40 -10
  33. package/src/cli/usage.ts +12 -7
  34. package/src/cli/workspace-migrate.ts +18 -12
  35. package/src/connectivity/context.ts +17 -0
  36. package/src/connectivity/manifest/provider.ts +9 -1
  37. package/src/deployments/prepare.ts +8 -33
  38. package/src/deployments/report.ts +6 -3
  39. package/src/dispatch/context.ts +3 -0
  40. package/src/dispatch/dispatch.ts +5 -0
  41. package/src/profile/connections.ts +32 -0
  42. package/src/profile/index.ts +9 -0
  43. package/src/profile/schema.ts +47 -3
  44. package/src/profile/tokens.ts +137 -0
  45. package/src/profile/workspace.ts +1 -1
  46. package/src/providers/harness.ts +1 -0
  47. package/src/providers/setup/plan.ts +16 -0
  48. package/src/providers/setup/provider.ts +39 -12
  49. package/src/server/container.ts +3 -3
  50. package/src/server/endpoint.ts +17 -29
  51. package/src/server/harness.ts +28 -3
  52. package/src/server/index.ts +8 -8
  53. package/src/server/mcp/visibility.ts +11 -3
  54. package/src/server/read/deployed.ts +4 -0
  55. package/src/server/read/open.ts +4 -0
  56. package/src/server/read/routes.ts +15 -2
  57. package/src/server/read/state.ts +30 -2
@@ -0,0 +1,234 @@
1
+ import {
2
+ CONNECTIONS_FILE,
3
+ layout,
4
+ listProfiles,
5
+ readWorkspaceFile,
6
+ workspaceFiles,
7
+ } from '#profile';
8
+ import { parseDocument } from 'yaml';
9
+ import { ConfigDocument } from './config-edit.ts';
10
+ import { openOrCreateConnections } from './config-repair-sweep.ts';
11
+ import { ensureRegistryContract } from './config-repair-sweep.ts';
12
+
13
+ /**
14
+ * Contract 4 to contract 5: the endpoint token becomes a person's.
15
+ *
16
+ * Contract 4 left `auth.token_ref` on every profile, defaulting to the constant
17
+ * `profile/token` out of a credential store that has been one per workspace
18
+ * since contract 3. So "the profile's token" was the workspace's wearing a
19
+ * per-profile name, which is why `profile remove` once deleted the token its
20
+ * siblings were being served by, and why every command whose subject is the
21
+ * endpoint had to name a profile in order to find it. ADR-068 moves it to
22
+ * `tokens:` in `connections.yaml`, one row per token, each naming the Lanes
23
+ * subject it was issued to.
24
+ *
25
+ * **The subject is what this migration cannot invent.** A row exists so a
26
+ * bearer token can resolve through `members:` the way an OAuth token does, and
27
+ * a row with the wrong subject is worse than no row: it is a credential that
28
+ * looks issued and reaches nothing, or reaches somebody else's profiles. So the
29
+ * subject comes from the profiles themselves — the owner-role member of a
30
+ * profile that actually holds the token — and when there is no such member this
31
+ * refuses and says which command fixes it.
32
+ *
33
+ * Three steps, ordered so a crash between any two leaves a workspace that still
34
+ * opens and a rerun that still finishes:
35
+ *
36
+ * 1. The row is written into `connections.yaml`, pointing at the ref the
37
+ * value is *already* under. Nothing moves in the credential store, which
38
+ * is what makes this safe to interrupt: the old key and the new row name
39
+ * the same bytes.
40
+ * 2. `auth.token_ref` is removed from each profile.
41
+ * 3. Each profile is stamped `contract: 5`.
42
+ *
43
+ * **The stamp is last**, for the reason contract 4 records: contract 3 shipped
44
+ * with it first, which left profiles claiming the new contract with every byte
45
+ * at the old path and a rerun that read the stamp and found nothing to do.
46
+ *
47
+ * **The ref is not renamed to `tokens/tok1`.** It would read better and it
48
+ * would mean copying a live credential in a store that may be Secret Manager,
49
+ * then deleting the original — two writes and a window where a deployed
50
+ * revision reads neither. A row may name any ref; `profile/token` is a
51
+ * perfectly good one, and the one thing that must not break here is an endpoint
52
+ * that was working before the upgrade.
53
+ */
54
+
55
+ export interface Contract5Migration {
56
+ readonly workspaceRoot: string;
57
+ readonly profiles: readonly string[];
58
+ readonly changes: readonly string[];
59
+ /** Written into `tokens:`, as `id → subject`. Empty when there was no token. */
60
+ readonly issued: readonly { readonly id: string; readonly subject: string }[];
61
+ readonly alreadyCurrent: boolean;
62
+ }
63
+
64
+ interface RawProfile {
65
+ readonly contract?: number;
66
+ readonly auth?: { readonly token_ref?: unknown };
67
+ readonly members?: readonly { readonly subject?: unknown; readonly role?: unknown }[];
68
+ }
69
+
70
+ /** Whether this workspace still holds anything at contract 4. */
71
+ export async function needsContract5(workspaceRoot: string): Promise<boolean> {
72
+ for (const profile of await listProfiles(workspaceRoot)) {
73
+ const raw = await readProfile(workspaceRoot, profile);
74
+ if (raw !== null && (raw.contract ?? 0) === 4) return true;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ export async function migrateToContract5(
80
+ workspaceRoot: string,
81
+ options: { apply: boolean; subject?: string } = { apply: true },
82
+ ): Promise<Contract5Migration> {
83
+ const legacy = new Map<string, RawProfile>();
84
+
85
+ for (const profile of await listProfiles(workspaceRoot)) {
86
+ const raw = await readProfile(workspaceRoot, profile);
87
+ if (raw !== null && (raw.contract ?? 0) === 4) legacy.set(profile, raw);
88
+ }
89
+
90
+ if (legacy.size === 0) {
91
+ return { workspaceRoot, profiles: [], changes: [], issued: [], alreadyCurrent: true };
92
+ }
93
+
94
+ const profiles = [...legacy.keys()];
95
+ const changes: string[] = [];
96
+ const issued: { id: string; subject: string }[] = [];
97
+
98
+ // Every distinct ref the profiles held, in the order they are declared. Almost
99
+ // always one — the template's constant — but a profile that overrode
100
+ // `token_ref` had a genuinely separate credential, and dropping it would take
101
+ // a working endpoint down.
102
+ const refs = new Map<string, string[]>();
103
+ for (const [profile, raw] of legacy) {
104
+ const ref = typeof raw.auth?.token_ref === 'string' ? raw.auth.token_ref : 'profile/token';
105
+ refs.set(ref, [...(refs.get(ref) ?? []), profile]);
106
+ }
107
+
108
+ if (!options.apply) {
109
+ for (const [ref, holders] of refs) {
110
+ changes.push(`${CONNECTIONS_FILE}: tokens += a row for "${ref}" (${holders.join(', ')})`);
111
+ }
112
+ for (const profile of profiles) changes.push(`${profile}: auth.token_ref removed`);
113
+ return { workspaceRoot, profiles, changes, issued: [], alreadyCurrent: false };
114
+ }
115
+
116
+ // **The one thing this cannot guess.** `--subject` from `update`, else the
117
+ // owner-role member of a profile that held the ref. Refusing beats writing a
118
+ // row nobody can use: a token bound to the wrong subject reaches the wrong
119
+ // profiles, and one bound to nobody reaches none while looking issued.
120
+ const document = await openOrCreateConnections(workspaceRoot);
121
+ // Through `toJSON`, not `getIn`. `getIn` hands back YAML nodes, so reading
122
+ // `row.ref` off one is `undefined` — the idempotence check below silently
123
+ // never matched, and a rerun after an interruption wrote the row twice.
124
+ const existing = asRows((document.toJSON() as { tokens?: unknown } | null)?.tokens);
125
+ let next = existing.length + 1;
126
+
127
+ for (const [ref, holders] of refs) {
128
+ // Already migrated, which is what an interrupted run looks like from here.
129
+ if (existing.some((row) => row.ref === ref)) continue;
130
+
131
+ const subject = options.subject ?? ownerSubject(legacy, holders);
132
+ if (subject === undefined) {
133
+ throw new ContractError(
134
+ `Cannot migrate the endpoint token at "${ref}": a token now names the person it was\n` +
135
+ 'issued to (ADR-068), and no profile holding it lists an owner.\n' +
136
+ ` Holding it: ${holders.join(', ')}\n` +
137
+ ' Sign in and say who you are, then run this again:\n' +
138
+ ' lanes auth login\n' +
139
+ ` lanes link profile members add --me --profile ${holders[0]} --workspace <name>`,
140
+ );
141
+ }
142
+
143
+ const id = `tok${next++}`;
144
+ document.addTo(['tokens'], { id, subject, ref, label: 'migrated' });
145
+ issued.push({ id, subject });
146
+ changes.push(`${CONNECTIONS_FILE}: tokens += ${id} → ${subject} ("${ref}")`);
147
+
148
+ // **Say so when the row reaches nothing.** A subject that no profile lists
149
+ // is a token the endpoint refuses, and the operator's CI would fail with a
150
+ // 401 that reads as a bad credential. It happens on the path a signed-in
151
+ // operator upgrades a workspace whose `members:` is empty — legitimate, and
152
+ // contract 3 only fills that in for workspaces it migrates itself.
153
+ if (!listedAnywhere(legacy, subject)) {
154
+ changes.push(
155
+ ` warning: no profile lists ${subject}, so ${id} reaches nothing until one does — ` +
156
+ 'lanes link profile members add --me --profile <name> --workspace <name>',
157
+ );
158
+ }
159
+ }
160
+
161
+ // The stamp, for the reason contract 4 gives where it does the same: nothing
162
+ // reads this field — every contract check is on a profile — which is exactly
163
+ // why it goes stale, and a marker that lies is worse than no marker for
164
+ // whoever writes the next migration.
165
+ document.setIn(['contract'], 5);
166
+
167
+ await document.save();
168
+
169
+ for (const profile of profiles) {
170
+ const config = await ConfigDocument.open(workspaceRoot, profile);
171
+ config.removeIn(['auth', 'token_ref']);
172
+ // Last, and the record that this profile finished.
173
+ config.setIn(['contract'], 5);
174
+ await config.save({ contract: 5 });
175
+ changes.push(`${profile}: auth.token_ref removed, contract 5`);
176
+ }
177
+
178
+ // After the profiles, so the registry never claims a contract they have not
179
+ // reached — `isUnmigrated` reads this field and nothing else.
180
+ await ensureRegistryContract(workspaceRoot, 5);
181
+
182
+ return { workspaceRoot, profiles, changes, issued, alreadyCurrent: false };
183
+ }
184
+
185
+ /** Raised so `doctor` and `update` can print it rather than a stack. */
186
+ export class ContractError extends Error {}
187
+
188
+ /** Whether any profile being migrated lists this subject as a member. */
189
+ function listedAnywhere(legacy: ReadonlyMap<string, RawProfile>, subject: string): boolean {
190
+ for (const raw of legacy.values()) {
191
+ if ((raw.members ?? []).some((member) => member.subject === subject)) return true;
192
+ }
193
+ return false;
194
+ }
195
+
196
+ /**
197
+ * The subject to bind a ref to: an owner of a profile that held it.
198
+ *
199
+ * `owner` rather than any member, because `owner` is who may edit the member
200
+ * list (ADR-060) and is therefore the one role that cannot have been delegated
201
+ * a narrower reach than the token used to have. Falls back to any member, since
202
+ * a profile with members and no owner still has somebody this belonged to, and
203
+ * a workspace migrated by contract 3 while signed out has neither.
204
+ */
205
+ function ownerSubject(
206
+ legacy: ReadonlyMap<string, RawProfile>,
207
+ holders: readonly string[],
208
+ ): string | undefined {
209
+ for (const role of ['owner', undefined]) {
210
+ for (const profile of holders) {
211
+ for (const member of legacy.get(profile)?.members ?? []) {
212
+ if (typeof member.subject !== 'string') continue;
213
+ if (role === undefined || member.role === role) return member.subject;
214
+ }
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function asRows(value: unknown): readonly { ref?: string }[] {
221
+ return Array.isArray(value) ? (value as { ref?: string }[]) : [];
222
+ }
223
+
224
+ async function readProfile(root: string, profile: string): Promise<RawProfile | null> {
225
+ const text = await readWorkspaceFile(workspaceFiles(root), layout.profileConfig(profile));
226
+ if (text === null) return null;
227
+ try {
228
+ return parseDocument(text).toJSON() as RawProfile;
229
+ } catch {
230
+ // A file that will not parse is not this migration's problem to report;
231
+ // `check` has a better sentence for it than "needs migrating" would.
232
+ return null;
233
+ }
234
+ }
@@ -42,19 +42,33 @@ export function localUrl(config: Config): string {
42
42
  *
43
43
  * Anonymous would be enough to prove a socket is bound, but the profile list is
44
44
  * behind the token, and the profile is the whole point of asking.
45
+ *
46
+ * **The token is optional** since ADR-068, because a healthy workspace may have
47
+ * issued none: a person's client signs in for itself and needs no static
48
+ * credential. Without one this still tells a caller whether anything is
49
+ * answering, which is the half every caller uses; `profiles` comes back empty
50
+ * rather than absent, so a caller cannot mistake "withheld" for "none served".
45
51
  */
46
- export async function endpointHealth(url: string, token: string): Promise<EndpointHealth | null> {
52
+ export async function endpointHealth(
53
+ url: string,
54
+ token?: string | undefined,
55
+ ): Promise<EndpointHealth | null> {
47
56
  try {
48
57
  const probe = new URL(url);
49
58
  probe.pathname = '/health';
50
59
  const response = await fetch(probe, {
51
- headers: { authorization: `Bearer ${token}` },
60
+ ...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }),
52
61
  signal: AbortSignal.timeout(700),
53
62
  });
54
63
  if (!response.ok) return null;
55
64
 
56
65
  const body = (await response.json()) as Partial<EndpointHealth>;
57
- return body.profile ? { profile: body.profile, profiles: body.profiles ?? [body.profile] } : null;
66
+ if (body.profile) {
67
+ return { profile: body.profile, profiles: body.profiles ?? [body.profile] };
68
+ }
69
+ // Answered, but withheld the list — which is what an unauthenticated probe
70
+ // gets. Reporting the endpoint as absent would be wrong: it is up.
71
+ return token === undefined ? { profile: '', profiles: [] } : null;
58
72
  } catch {
59
73
  return null;
60
74
  }
package/src/cli/main.ts CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  policyRule,
22
22
  start,
23
23
  status,
24
+ tokenIssue,
25
+ tokenList,
26
+ tokenRevoke,
24
27
  tokenRotate,
25
28
  tokenShow,
26
29
  tools,
@@ -284,16 +287,37 @@ export async function run(argv: readonly string[]): Promise<void> {
284
287
  throw new Error(`Unknown: ${PROGRAM} policy ${second}`);
285
288
  }
286
289
 
287
- case 'token':
290
+ case 'token': {
291
+ const row = {
292
+ ...global,
293
+ show,
294
+ raw,
295
+ ...(typeof flags['id'] === 'string' ? { id: flags['id'] } : {}),
296
+ ...(typeof flags['subject'] === 'string' ? { subject: flags['subject'] } : {}),
297
+ me: flags['me'] === true,
298
+ ...(typeof flags['label'] === 'string' ? { label: flags['label'] } : {}),
299
+ json: flags['json'] === true,
300
+ };
288
301
  switch (second) {
289
- case 'show':
302
+ // A bare `token` lists rather than showing one. It used to show, because
303
+ // there was one token and nothing to list; there are several now, and a
304
+ // command that printed a credential when asked for an inventory would be
305
+ // the wrong default in the more expensive direction.
306
+ case 'list':
290
307
  case undefined:
291
- return tokenShow({ ...global, show, raw });
308
+ return tokenList(row);
309
+ case 'issue':
310
+ return tokenIssue(row);
311
+ case 'show':
312
+ return tokenShow(row);
292
313
  case 'rotate':
293
- return tokenRotate({ ...global, show });
314
+ return tokenRotate(row);
315
+ case 'revoke':
316
+ return tokenRevoke(row);
294
317
  default:
295
318
  throw new Error(`Unknown: ${PROGRAM} token ${second}`);
296
319
  }
320
+ }
297
321
 
298
322
  case 'pair':
299
323
  return pair({
@@ -1,5 +1,5 @@
1
1
  import type { SecretStore } from '#secrets';
2
- import { openTarget, type Config } from '#profile';
2
+ import { anyIssuedToken, openTarget, type Config } from '#profile';
3
3
  import { publishWorkspace } from '#deployments/upload.ts';
4
4
  import { openSecretStoreFor, type Runtime } from './runtime.ts';
5
5
  import { endpointUrl } from './endpoint-url.ts';
@@ -131,14 +131,23 @@ async function notifyReload(input: {
131
131
  return { served: false, reason: `could not work out where the endpoint is: ${message(error)}` };
132
132
  }
133
133
 
134
- const token = await input.credentials.get(input.config.auth.token_ref);
135
- if (!token) {
134
+ // Any row the workspace holds (ADR-068). Which one is not a choice worth
135
+ // making here: this is the operator's own command reaching the operator's own
136
+ // endpoint, and `/reload` cares that the caller is authenticated rather than
137
+ // who they are.
138
+ const held = await anyIssuedToken(input.workspaceRoot, input.credentials);
139
+ if (!held) {
136
140
  return {
137
141
  served: false,
138
142
  url,
139
- reason: `no profile token at "${input.config.auth.token_ref}" to authenticate with`,
143
+ // Not a failure to fix in most cases, which is why it reads as a reason
144
+ // rather than an error. An endpoint serving browser clients needs no
145
+ // static token; what it costs is that a config change is picked up on the
146
+ // next reconcile instead of immediately.
147
+ reason: 'no static token is issued in this workspace, so the endpoint cannot be notified',
140
148
  };
141
149
  }
150
+ const token = held.value;
142
151
 
143
152
  try {
144
153
  const response = await fetch(url, {
@@ -9,6 +9,7 @@ import {
9
9
  assertGrantsResolve,
10
10
  layout,
11
11
  listProfiles,
12
+ membersResolver,
12
13
  readConnections,
13
14
  selectConnections,
14
15
  soleGrantFor,
@@ -190,7 +191,12 @@ export async function openRuntime(
190
191
  // Lazy for the same reason `refreshSkills` is: it reads the registry it is
191
192
  // registered into. Per call rather than a snapshot, so a policy the runtime
192
193
  // was opened with is re-evaluated rather than remembered.
193
- const reachable = (): ReadonlyArray<{ key: string; provider: string; account: string }> =>
194
+ const reachable = (): ReadonlyArray<{
195
+ key: string;
196
+ provider: string;
197
+ providerName: string;
198
+ account: string;
199
+ }> =>
194
200
  selected
195
201
  .filter(({ ref, connection }) =>
196
202
  registry
@@ -204,6 +210,11 @@ export async function openRuntime(
204
210
  .map(({ ref, connection }) => ({
205
211
  key: ref,
206
212
  provider: connection.provider,
213
+ // The manifest's display name, not the id. `defaultConnectionLabel`
214
+ // composes "Gmail (ada)" and needs the name — handed the id it produces
215
+ // "gmail (ada)", and for the owner layer, whose `account` is already the
216
+ // proper noun, "lanes_memory (Memory)".
217
+ providerName: registry.manifest(connection.provider)?.name ?? connection.provider,
207
218
  account: connection.account,
208
219
  ...(connection.label ? { label: connection.label } : {}),
209
220
  }));
@@ -329,10 +340,16 @@ export async function openRuntime(
329
340
  registry,
330
341
  refreshSkills,
331
342
  dispatcher,
343
+ // The workspace's issued tokens, not a profile's (ADR-068). The rows are
344
+ // re-read on every reload so a `token revoke` lands inside the cache
345
+ // window, and the subject on the matching row is resolved through
346
+ // `members:` — so a bearer token reaches what its holder is a member of
347
+ // rather than everything the workspace holds.
332
348
  authenticator: new BearerAuthenticator({
333
349
  profile: config.instance.profile,
334
- tokenRef: config.auth.token_ref,
350
+ tokens: async () => (await readConnections(root)).tokens,
335
351
  credentials,
352
+ profilesFor: membersResolver(root),
336
353
  }),
337
354
  connectorFor,
338
355
  authorizeRequest,
@@ -159,17 +159,5 @@ export async function openBlobStoreFor(
159
159
  return area === undefined ? storage() : storage(area);
160
160
  }
161
161
 
162
- /** Mint the profile token if it does not exist yet. Returns it either way. */
163
- export async function ensureProfileToken(
164
- credentials: SecretStore,
165
- tokenRef: string,
166
- ): Promise<{ token: string; created: boolean }> {
167
- const existing = await credentials.get(tokenRef);
168
- if (existing) return { token: existing, created: false };
169
-
170
- const token = generateProfileToken();
171
- await credentials.set(tokenRef, token);
172
- return { token, created: true };
173
- }
174
162
 
175
163
  export { ownerPrincipal };
@@ -17,7 +17,6 @@
17
17
  */
18
18
 
19
19
  export {
20
- ensureProfileToken,
21
20
  openBlobStoreFor,
22
21
  openSecretStoreFor,
23
22
  ownerPrincipal,
@@ -145,7 +145,14 @@ export const SELECTION: Record<string, Requires> = {
145
145
  // ignored, which is the defect this whole file exists for.
146
146
  'connect custom': 'workspace',
147
147
  setup: 'profile+workspace',
148
- token: 'profile+workspace',
148
+ // A token belongs to the workspace and names the person it was issued to
149
+ // (ADR-068). It used to sit at `auth.token_ref` on a profile, whose default
150
+ // was the same constant for every profile in a store that is one per
151
+ // workspace — so `--profile` was demanded here and could not change the
152
+ // answer. These commands refuse it outright rather than accept and ignore it:
153
+ // somebody passing `--profile work` believes they scoped the credential, and
154
+ // it is the member lists that decide.
155
+ token: 'workspace',
149
156
  // One chain per workspace since contract 3, so the workspace is the subject
150
157
  // and `--profile` filters the rows rather than choosing which log to read.
151
158
  audit: 'workspace',
@@ -160,7 +167,14 @@ export const SELECTION: Record<string, Requires> = {
160
167
  auth: 'workspace',
161
168
  // Target-scoped: see the note above. `--profile` narrows each to one profile.
162
169
  status: 'workspace',
163
- outputs: 'profile+workspace',
170
+ // Its subject has always been the endpoint rather than a profile — its own
171
+ // doc comment said so — and one endpoint serves every profile in the
172
+ // workspace. It named a profile only to find the endpoint's token. `--profile`
173
+ // stays accepted and picks whose `instance.port` the local URL names, which
174
+ // is the one thing profiles can still disagree about here.
175
+ outputs: 'workspace',
176
+ // Unlike the two above: this reports what *one profile's* policy resolves to,
177
+ // beside what the endpoint advertises. The profile is the subject.
164
178
  tools: 'profile+workspace',
165
179
  // It resolves nothing and opens nothing — it hands macOS a URL (ADR-053).
166
180
  // `target list` is the precedent for a `'none'` command that still takes a
@@ -171,8 +185,9 @@ export const SELECTION: Record<string, Requires> = {
171
185
  desktop: 'none',
172
186
  attach: 'profile+workspace',
173
187
  // One endpoint serves every profile in the workspace (ADR-009), so naming one
174
- // described a slice of what it does. `--profile` picks the primary, whose
175
- // token opens it, and `--only` is what narrows what is served.
188
+ // described a slice of what it does. `--profile` picks the primary — which is
189
+ // now only whose host and port it binds, since the token stopped being a
190
+ // profile's (ADR-068) — and `--only` is what narrows what is served.
176
191
  start: 'workspace',
177
192
  deploy: 'workspace',
178
193
  // Both spellings: `sync` alone is `sync targets`, which is the only thing
@@ -193,14 +208,24 @@ export const SELECTION: Record<string, Requires> = {
193
208
  // which profile was a question with no answer. `--profile` still narrows, and
194
209
  // is how a port is chosen when profiles disagree about one.
195
210
  pair: 'workspace',
196
- 'token show': 'profile+workspace',
197
- 'token rotate': 'profile+workspace',
211
+ 'token show': 'workspace',
212
+ 'token rotate': 'workspace',
213
+ 'token issue': 'workspace',
214
+ 'token list': 'workspace',
215
+ 'token revoke': 'workspace',
198
216
  'audit tail': 'workspace',
199
217
  'audit verify': 'workspace',
200
218
  'secrets set': 'workspace',
201
219
  'secrets list': 'workspace',
202
- 'mcp add': 'profile+workspace',
203
- 'mcp stdio': 'profile+workspace',
220
+ // One registration serves every profile, and each call names one in its
221
+ // `profile` argument — so two `--profile` values produced byte-identical
222
+ // harness commands. What kept the flag required was reading the endpoint's
223
+ // token, which is the workspace's now (ADR-068). `--profile` still picks
224
+ // whose port the local URL names.
225
+ 'mcp add': 'workspace',
226
+ // Matches `start`, which it is: the pipe serves every profile in the
227
+ // workspace and `--only` is what narrows that to one.
228
+ 'mcp stdio': 'workspace',
204
229
  memory: 'profile+workspace',
205
230
  tasks: 'profile+workspace',
206
231
  assets: 'profile+workspace',
@@ -230,7 +255,7 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
230
255
  target: ['list', 'use', 'show'],
231
256
  policy: ['list', 'allow', 'deny'],
232
257
  identity: ['add', 'list', 'remove'],
233
- token: ['show', 'rotate'],
258
+ token: ['show', 'rotate', 'issue', 'list', 'revoke'],
234
259
  audit: ['tail', 'verify'],
235
260
  config: ['show'],
236
261
  setup: ['plan'],
@@ -242,7 +267,12 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
242
267
  // No `list`: a bare `entities` is a listing, which is `find` with no
243
268
  // criteria. One concept, one word.
244
269
  entities: ['find', 'get', 'write', 'link', 'forget', 'reindex'],
245
- mcp: ['skill', 'add', 'stdio', 'list'],
270
+ // `install-instructions` was absent, which is not cosmetic: `dispatchWillRefuse`
271
+ // returns true for a second word it does not know, and both `assertKnownFlags`
272
+ // and `requireSelection` then return early — so `mcp install-instructions
273
+ // --bogus` was accepted and ignored, which is the defect this whole file
274
+ // exists to prevent.
275
+ mcp: ['skill', 'add', 'stdio', 'list', 'install-instructions'],
246
276
  secrets: ['push', 'set', 'list'],
247
277
  knowledge: ['show', 'use'],
248
278
  sync: ['targets', 'workspaces'],
package/src/cli/usage.ts CHANGED
@@ -62,10 +62,10 @@ ${style.bold('Profiles')}
62
62
  ${PROGRAM} profile list [--json]
63
63
  ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes] [--json]
64
64
  [--delete-data | --migrate-to <profile>]
65
- the profile and its own token. Say which of
66
- --delete-data or --migrate-to for its memory,
67
- tasks, assets and skills there is no default.
68
- Accounts outlive it; disconnect removes those.
65
+ Say which of --delete-data or --migrate-to for
66
+ its memory, tasks, assets and skills — there is
67
+ no default. Accounts outlive it; disconnect
68
+ removes those, and token revoke the tokens.
69
69
 
70
70
  ${style.bold('Workspaces')}
71
71
  ${PROGRAM} workspace list [--urls] every workspace this one knows
@@ -90,8 +90,13 @@ ${style.bold('Permissions')}
90
90
  ${PROGRAM} profile members list who may consume this profile, and who could
91
91
  ${PROGRAM} profile members add <subject>|--me [--role owner|member]
92
92
  ${PROGRAM} profile members remove <subject>
93
- ${PROGRAM} token show [--show|--raw] CI only: --raw prints only the token, for $(…)
94
- ${PROGRAM} token rotate [--show] also ends every session a member holds
93
+ ${PROGRAM} token list [--json] static tokens, and what each one reaches
94
+ ${PROGRAM} token issue --me|--subject <id> [--label <t>]
95
+ CI only: a token for one person. It reaches
96
+ every profile whose members list them
97
+ ${PROGRAM} token show [--id t] [--show|--raw] --raw prints only it, for $(…)
98
+ ${PROGRAM} token rotate [--id t] [--show]
99
+ ${PROGRAM} token revoke --id <t>
95
100
 
96
101
  ${style.bold('Your own context')}
97
102
  ${PROGRAM} memory list [--tag t] what you have stored
@@ -146,7 +151,7 @@ ${style.bold('Deploying')}
146
151
  set up, build, and roll one revision serving
147
152
  every profile that declares the target
148
153
  ${PROGRAM} deploy --workspace <name> --profile a --profile b
149
- only these; the first owns the endpoint token
154
+ only these; the first is the primary
150
155
  ${PROGRAM} deploy --non-interactive take the stored answers, never prompt
151
156
  ${PROGRAM} deploy --access iam|public who gets past the platform's own door
152
157
  ${PROGRAM} secrets list credential references in this target
@@ -1,4 +1,5 @@
1
1
  import { migrateToContract4, type Contract4Migration } from './contract4.ts';
2
+ import { migrateToContract5, type Contract5Migration } from './contract5.ts';
2
3
  import { migrateToContract3, needsContract3, type Contract3Migration } from './contract3.ts';
3
4
  import { readSession } from '#auth/lanes/session.ts';
4
5
  import { parseDocument } from 'yaml';
@@ -196,6 +197,8 @@ export interface ContractMigration {
196
197
  /** The contract 2 → 3 half, when this workspace needed one. */
197
198
  readonly contract3: Contract3Migration | null;
198
199
  readonly contract4: Contract4Migration | null;
200
+ /** The contract 4 → 5 half: the endpoint token becomes a person's (ADR-068). */
201
+ readonly contract5: Contract5Migration | null;
199
202
  /** Every profile either half rewrote, deduplicated. */
200
203
  readonly profiles: readonly string[];
201
204
  /** Targets written into the registry by the contract 1 → 2 half. */
@@ -250,10 +253,10 @@ export async function migrateToCurrentContract(
250
253
 
251
254
  const subject = options.subject ?? (await readSession().catch(() => null))?.subject;
252
255
 
253
- const contract3 = await migrateToContract3(workspaceRoot, {
254
- apply: options.apply,
255
- ...(subject === undefined ? {} : { subject }),
256
- });
256
+ // Contract 3 writes `members:` from it; contract 5 binds the token to it.
257
+ const signed = { apply: options.apply, ...(subject === undefined ? {} : { subject }) };
258
+
259
+ const contract3 = await migrateToContract3(workspaceRoot, signed);
257
260
 
258
261
  // In sequence, not in parallel: contract 4 moves what contract 3 produced, so
259
262
  // it has to run against the tree the previous step left. With `apply: false`
@@ -264,20 +267,23 @@ export async function migrateToCurrentContract(
264
267
  ...(options.target === undefined ? {} : { target: options.target }),
265
268
  });
266
269
 
270
+ // In sequence again, for contract 4's reason.
271
+ const contract5 = await migrateToContract5(workspaceRoot, signed);
272
+
273
+ // Collected, not spelled out per step: a step left out of `alreadyCurrent`
274
+ // reports a migration as finished when it is not.
275
+ const steps = [legacy, contract3, contract4, contract5].filter((s) => s !== null);
276
+
267
277
  return {
268
278
  workspaceRoot,
269
279
  legacy: legacy !== null && !legacy.alreadyCurrent ? legacy : null,
270
280
  contract3: contract3.alreadyCurrent ? null : contract3,
271
281
  contract4: contract4.alreadyCurrent ? null : contract4,
272
- profiles: [
273
- ...new Set([...(legacy?.profiles ?? []), ...contract3.profiles, ...contract4.profiles]),
274
- ],
282
+ contract5: contract5.alreadyCurrent ? null : contract5,
283
+ profiles: [...new Set(steps.flatMap((step) => step.profiles))],
275
284
  targets: legacy?.targets ?? [],
276
- changes: [...(legacy?.changes ?? []), ...contract3.changes, ...contract4.changes],
277
- alreadyCurrent:
278
- (legacy === null || legacy.alreadyCurrent) &&
279
- contract3.alreadyCurrent &&
280
- contract4.alreadyCurrent,
285
+ changes: steps.flatMap((step) => step.changes),
286
+ alreadyCurrent: steps.every((step) => step.alreadyCurrent),
281
287
  };
282
288
  }
283
289
 
@@ -53,6 +53,23 @@ export interface ConnectionInfo {
53
53
  */
54
54
  export interface ProviderContext {
55
55
  readonly connection: ConnectionInfo;
56
+ /**
57
+ * Every profile this caller may reach, this one included.
58
+ *
59
+ * The narrowest widening of this interface that could carry it, and worth
60
+ * saying why it belongs at all given the rule above. It is not a backend, a
61
+ * credential or a store: it is the same routing fact the client already holds
62
+ * in the `profile` enum on every tool it was shown, which `visibility.ts`
63
+ * filters by `mayReach`. A provider cannot *act* in any of these — dispatch
64
+ * still resolves one profile per call — so this grants no reach.
65
+ *
66
+ * It exists because a surface that describes what a caller can get to has to
67
+ * be given the caller's answer rather than the workspace's.
68
+ * `lanes_setup.overview` was handed every profile on disk and named them all,
69
+ * which told a delegated member about profiles `mayReach` deliberately hides
70
+ * from their enum (ADR-068).
71
+ */
72
+ readonly profiles: readonly string[];
56
73
  readonly state: ScopedStore;
57
74
  readonly storage: BlobStore;
58
75
  readonly credentials: ScopedSecrets;