@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
package/README.md CHANGED
@@ -55,7 +55,7 @@ $ bun install -g @lanes-sh/link # puts `lanes` on your PATH
55
55
  $ lanes auth login # opens a browser once
56
56
  $ lanes link profile add personal --workspace local
57
57
  $ lanes link profile members add --me --profile personal --workspace local
58
- $ lanes link start --profile personal --workspace local
58
+ $ lanes link start --workspace local
59
59
  ok serving http://127.0.0.1:7337/mcp
60
60
  profiles: personal
61
61
  ```
@@ -63,7 +63,7 @@ ok serving http://127.0.0.1:7337/mcp
63
63
  Then, in another shell:
64
64
 
65
65
  ```console
66
- $ lanes link mcp add --profile personal --workspace local # every agent installed; or name one: claude, codex
66
+ $ lanes link mcp add --workspace local # every agent installed; or name one: claude, codex
67
67
  ok registered lanes-link with Claude Code (user scope)
68
68
  ok registered lanes-link with Codex
69
69
  ```
@@ -75,8 +75,14 @@ Mail and calendar are the next step. **[Full quickstart →](https://lanes.sh/do
75
75
  **Why the sign-in.** A profile declares who may consume it, and there is nothing to check that
76
76
  against if the endpoint has no idea who is asking. That is a real dependency for a self-hostable
77
77
  tool and worth stating plainly; what it is not is a dependency per request. The network is needed
78
- to sign in and to refresh, and a machine offline for a day keeps serving. `lanes link token
79
- show` still mints a static token for CI, which has no browser to sign in with.
78
+ to sign in and to refresh, and a machine offline for a day keeps serving.
79
+
80
+ **Why `mcp add` names no profile.** One endpoint serves every profile in the workspace, and each
81
+ call names one in its `profile` argument — so registering is about the endpoint, not a profile.
82
+ Which profiles a client actually reaches is decided when its owner signs in: every profile whose
83
+ `members:` lists them, and no others. A credential is an identity here, not a selection. For a
84
+ runner with no browser, `lanes link token issue --me` mints a static token that reaches exactly
85
+ what its subject is a member of.
80
86
 
81
87
  ## What you keep in it
82
88
 
@@ -346,9 +346,11 @@ changed, so a capability for a freshly connected account is not callable until
346
346
  the client reconnects. Say that, rather than reporting the connection as missing
347
347
  or asking them to connect again.
348
348
 
349
- One exception, and it is the one that matters here: the authenticator is built
350
- once at boot and is not re-read, so a `lanes link token rotate` does need the
351
- endpoint restarted before the new token opens anything.
349
+ One exception used to be here that a `token rotate` needed the endpoint
350
+ restarted. It does not: the authenticator re-reads both the issued rows and
351
+ their values on a seconds-long cache window, so `token issue`, `token rotate`
352
+ and `token revoke` all take effect on a running endpoint. So does a change to a
353
+ profile's `members:`, which is what decides where a token reaches.
352
354
 
353
355
  ## Operating the workspace
354
356
 
@@ -413,17 +415,35 @@ in the workspace and could be given it. Somebody with an unaccepted invitation
413
415
  has no subject yet, so they are listed, marked, and refused; the answer there is
414
416
  for them to accept, not for anyone to invent a subject.
415
417
 
416
- Removing somebody does not end a session they already hold: membership is read
417
- when a token is minted. Rotating the endpoint token closes that
418
- window now, and it names both flags: `lanes link token rotate --profile <name>
419
- --workspace <name>`.
418
+ Removing somebody does not end a browser session they already hold: membership
419
+ is read when that token is minted. A **static** token is different — its
420
+ membership is resolved on every call, so removing somebody takes effect within
421
+ seconds for anything holding one.
420
422
 
421
- **A client is not given a token any more.** `lanes link mcp add` registers the
422
- bare URL, and the client discovers the endpoint, sends its owner to sign in, and
423
- comes back with a token of its own. The static token is for CI, which has no
424
- browser, and `--headless` is what writes it into a registration. If you are
425
- writing a registration command for somebody, do not add an `Authorization`
426
- header: it is no longer how a client connects.
423
+ **A credential is an identity, not a selection.** Whether it arrived through a
424
+ browser sign-in or is a static `llk_` token, it names a person, and what it
425
+ reaches is every profile whose `members:` lists that person. There is nothing to
426
+ scope at registration time and nothing to scope at deploy time; the member lists
427
+ are the whole mechanism. `lanes link token show`, `rotate` and `revoke` refuse
428
+ `--profile` outright rather than accept and ignore it.
429
+
430
+ **A client is not given a token any more.** `lanes link mcp add --workspace
431
+ <name>` registers the bare URL — no `--profile`, because one endpoint serves
432
+ every profile and each call names one in its `profile` argument. The client
433
+ discovers the endpoint, sends its owner to sign in, and comes back with a token
434
+ of its own. If you are writing a registration command for somebody, do not add
435
+ an `Authorization` header: it is no longer how a client connects.
436
+
437
+ **For a runner with no browser**, issue one and say who it is for:
438
+
439
+ ```
440
+ lanes link token issue --me --workspace <name>
441
+ lanes link token list --workspace <name>
442
+ ```
443
+
444
+ `--headless` on `mcp add` is what writes such a token into a registration. A
445
+ workspace that has issued none is the ordinary healthy state, and `start` and
446
+ `deploy` no longer mint or demand one.
427
447
 
428
448
  ## Deploying, and what it decides
429
449
 
@@ -437,14 +457,16 @@ resources that cost money, it implements no `--json` to inspect instead, and tha
437
457
  plan is the only place the consequences are visible while they are still
438
458
  avoidable.
439
459
 
440
- Two things it refuses to guess, and both are the owner's to answer:
460
+ One thing it refuses to guess, and it is the owner's to answer:
441
461
 
442
- - **Whose bearer token opens the endpoint.** One token reaches every profile
443
- behind that workspace, so this decides who gets in. With several candidates and
444
- nothing recorded, it refuses and prints the command that names one.
445
462
  - **A first deploy.** A workspace that does not exist yet has nothing to derive
446
463
  a set from, so `--profile` is required there. It may be repeated, and the first
447
- one named is the primary.
464
+ one named is the primary — which now decides only which profile's host and
465
+ port the endpoint binds, not who gets in.
466
+
467
+ It used to refuse a second thing — *whose bearer token opens the endpoint* — and
468
+ that question no longer has a subject. A token names a person and reaches what
469
+ they are a member of, so there is no per-deploy choice of who gets in.
448
470
 
449
471
  **Never pass `--yes`, `--non-interactive`, `--access public` or
450
472
  `--service-account` yourself.** Each settles a question about who can reach their
@@ -474,45 +496,47 @@ existed in two copies that could disagree, and there is one copy now.
474
496
 
475
497
  ## Registering it, and re-registering it
476
498
 
477
- `lanes link mcp add --profile <name> --workspace <name>` runs each harness's own registration command and installs
478
- this skill where that harness keeps them. With no argument it does every harness
479
- installed; name one (`claude`, `codex`) to be specific. Run it again after
480
- `lanes link token rotate --profile <name> --workspace <name>` — add `--force`, since Claude Code stores the token as
481
- a value rather than a command.
499
+ `lanes link mcp add --workspace <name>` runs each harness's own registration
500
+ command and installs this skill where that harness keeps them. With no argument
501
+ it does every harness installed; name one (`claude`, `codex`) to be specific.
482
502
 
483
- **Never paste the token.** There is a right way and a wrong way, and the
484
- difference matters:
503
+ **It names no profile, and adding one changes nothing about who gets in.** One
504
+ endpoint serves every profile in the workspace and each call names one in its
505
+ `profile` argument, so a registration was never per-profile. What a client
506
+ reaches is decided when its owner signs in: every profile whose `members:` lists
507
+ them.
485
508
 
486
- ```bash
487
- # RIGHT — the token goes from the CLI to the harness. You never see it.
488
- claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \
489
- --header "Authorization: Bearer $(lanes link token show --raw --profile <name> --workspace <name>)"
509
+ **Do not write an `Authorization` header.** The registration is a bare URL:
490
510
 
491
- # WRONG — the token is now in your context, and in the transcript, forever.
492
- lanes link token show --show # then copying the value into the command
511
+ ```bash
512
+ claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp
493
513
  ```
494
514
 
495
- The token reaches every account of every profile the endpoint serves. Use the
496
- substitution form. If you have already printed one by accident, say so and offer
497
- `lanes link token rotate --profile <name> --workspace <name>`.
515
+ The client reads this endpoint's protected-resource document, sends its owner to
516
+ sign in, and comes back holding a token of its own. That is why a re-registration
517
+ is not needed after a rotate, and why a harness config in a dotfiles repository
518
+ is not a leak.
498
519
 
499
- Prefer `lanes link mcp add --profile <name> --workspace <name>` to writing the command yourself: it checks the
500
- endpoint is reachable, refuses to silently shadow an existing registration, and
501
- cannot mistype the token. For a harness it does not know, take the command from
502
- `lanes link outputs --profile <name> --workspace <name>` rather than writing it blind — that command checks whether
503
- `lanes` resolves on this machine and prints a longer working form if it does
504
- not, where guessing gives you an empty substitution, a `Bearer ` header, and a
505
- 401 that reads as a bad token.
506
-
507
- If you register Codex, tell the user to export the token — Codex stores only the
508
- variable name, so nothing works until it is set:
520
+ **A static token is CI's, and it belongs to a person.** For a runner with no
521
+ browser, issue one and let the shell substitute it — never paste it, because a
522
+ pasted token passes through the agent's context and into the transcript:
509
523
 
510
524
  ```bash
511
- export LANES_LINK_TOKEN="$(lanes link token show --raw --profile <name> --workspace <name>)"
525
+ lanes link token issue --me --workspace <name>
526
+ export LANES_LINK_TOKEN="$(lanes link token show --raw --workspace <name>)"
512
527
  ```
513
528
 
529
+ `--headless` on `mcp add` writes such a token into a registration. If you have
530
+ printed one by accident, say so and offer `lanes link token rotate --workspace
531
+ <name>` — no `--profile`, which those commands refuse.
532
+
533
+ Prefer `lanes link mcp add --workspace <name>` to writing the command yourself:
534
+ it checks the endpoint is reachable and refuses to silently shadow an existing
535
+ registration. For a harness it does not know, take the command from `lanes link
536
+ outputs --workspace <name>` rather than writing it blind.
537
+
514
538
  One registration covers every profile. Do not add one per profile; they share a
515
- URL and a token.
539
+ URL.
516
540
 
517
541
  `lanes link mcp list` needs neither flag and reports whether the registration and
518
542
  this document are current, out of date, or absent. That is the cheap first
@@ -521,8 +545,9 @@ copy means the rules you are reading are not the ones that shipped.
521
545
 
522
546
  Claude Desktop cannot be handed a URL, so it spawns the endpoint over stdio
523
547
  instead. That one is named in the client's own config file rather than registered
524
- by a command, as `lanes link mcp stdio --profile <name> --workspace <name>`; both
525
- flags are required, and nothing may be written to stdout.
548
+ by a command, as `lanes link mcp stdio --workspace <name>`; the workspace is
549
+ required, `--only --profile <name>` narrows what it serves, and nothing may be
550
+ written to stdout.
526
551
 
527
552
  ## When it is not running
528
553
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
package/src/auth/index.ts CHANGED
@@ -32,10 +32,12 @@ export interface Principal {
32
32
  /**
33
33
  * Every profile this caller may reach, or `undefined` for "all of them".
34
34
  *
35
- * `undefined` is the machine token and the stdio pipe: neither is a person,
36
- * both reach the whole workspace, and saying so explicitly is better than
37
- * enumerating a list that would then need keeping in step. A `member` always
38
- * carries a list, because the list *is* the delegation (ADR-060).
35
+ * `undefined` is the stdio pipe and nothing else now (ADR-068). The pipe is
36
+ * its own proof a process that can write to it already has the operator's
37
+ * shell so there is no credential to carry a subject and no member list to
38
+ * match. Every token, static or issued, carries a list: a `member`'s because
39
+ * the list *is* the delegation (ADR-060), and a `machine`'s because a bearer
40
+ * token names the person it was issued to rather than opening everything.
39
41
  */
40
42
  readonly profiles?: readonly string[] | undefined;
41
43
  }
@@ -61,6 +63,25 @@ export function memberPrincipal(
61
63
  return { id: subject, profile, kind: 'member', profiles };
62
64
  }
63
65
 
66
+ /**
67
+ * A static token's holder, and the profiles whose `members:` name them.
68
+ *
69
+ * The same shape as `memberPrincipal` and deliberately so — `kind` is the only
70
+ * difference, and it exists for the audit log rather than for policy. ADR-060
71
+ * described this principal and nothing minted one: the static token resolved to
72
+ * `ownerPrincipal`, reaching every profile in the workspace, which made it the
73
+ * one credential here that never had to say who was holding it. A row in
74
+ * `tokens:` names a subject (ADR-068), so this resolves the same way an OAuth
75
+ * token does and `mayReach` gets no special case.
76
+ */
77
+ export function machinePrincipal(
78
+ subject: string,
79
+ profile: string,
80
+ profiles: readonly string[],
81
+ ): Principal {
82
+ return { id: subject, profile, kind: 'machine', profiles };
83
+ }
84
+
64
85
  /**
65
86
  * The same caller, acting within a different profile.
66
87
  *
@@ -161,10 +182,39 @@ export function tokensMatch(a: string, b: string): boolean {
161
182
  return timingSafeEqual(hash(a), hash(b));
162
183
  }
163
184
 
185
+ /**
186
+ * One issued token, as the authenticator needs it.
187
+ *
188
+ * Structurally what `connections.yaml` holds, declared here rather than
189
+ * imported: `auth` may not reach `#profile` (the architecture test enforces the
190
+ * direction), and the rows arrive as a closure for the same reason
191
+ * `profilesFor` does.
192
+ */
193
+ export interface IssuedToken {
194
+ readonly id: string;
195
+ readonly subject: string;
196
+ readonly ref: SecretRef;
197
+ }
198
+
164
199
  export interface AuthenticatorOptions {
200
+ /**
201
+ * The primary, which is what `principal.profile` starts as.
202
+ *
203
+ * Not what the token reaches — that is `profilesFor(subject)`. It is where
204
+ * the connection was opened, and every dispatch rewrites it with `forProfile`.
205
+ */
165
206
  readonly profile: string;
166
- readonly tokenRef: SecretRef;
207
+ /** The workspace's issued tokens. Re-read on every reload, so a revoke lands. */
208
+ readonly tokens: () => Promise<readonly IssuedToken[]>;
167
209
  readonly credentials: SecretStore;
210
+ /**
211
+ * Which profiles list this subject as a member.
212
+ *
213
+ * The same resolver the OAuth path is handed (`server/endpoint.ts`), passed in
214
+ * rather than reached for, so discovery and enforcement cannot disagree about
215
+ * a subject's reach.
216
+ */
217
+ readonly profilesFor: (subject: string) => Promise<readonly string[]>;
168
218
  /** Injectable for tests. Only the cache window reads it. */
169
219
  readonly now?: () => number;
170
220
  }
@@ -183,10 +233,16 @@ export interface AuthenticatorOptions {
183
233
  */
184
234
  const CACHE_TTL_MS = 5_000;
185
235
 
236
+ /** An issued row, with its value read out of the store. */
237
+ interface LoadedToken {
238
+ readonly subject: string;
239
+ readonly value: string;
240
+ }
241
+
186
242
  export class BearerAuthenticator implements Authenticator {
187
243
  readonly #options: AuthenticatorOptions;
188
244
  readonly #now: () => number;
189
- #cached: string | null = null;
245
+ #cached: readonly LoadedToken[] | null = null;
190
246
  #readAt = 0;
191
247
 
192
248
  constructor(options: AuthenticatorOptions) {
@@ -195,48 +251,79 @@ export class BearerAuthenticator implements Authenticator {
195
251
  }
196
252
 
197
253
  async authenticate(authorizationHeader: string | null | undefined): Promise<AuthOutcome> {
198
- const { profile } = this.#options;
199
-
200
254
  const presented = parseBearer(authorizationHeader);
201
255
  if (presented === null) {
202
256
  return { ok: false, reason: authorizationHeader ? 'malformed' : 'missing' };
203
257
  }
204
258
 
205
259
  const fresh = this.#cached !== null && this.#now() - this.#readAt < CACHE_TTL_MS;
206
- let expected = fresh ? this.#cached : await this.#reload();
260
+ let rows = fresh ? this.#cached! : await this.#reload();
261
+ let matched = find(presented, rows);
207
262
 
208
- // A mismatch against a *cached* value is ambiguous: either the credential is
263
+ // A miss against a *cached* set is ambiguous: either the credential is
209
264
  // wrong, or it is the right one and this process has not seen the rotation
210
- // that produced it. One re-read separates the two, and it is what makes a
211
- // rotated-in token work on its first call rather than after the window.
212
- // Only a cached comparison can be wrong this way, so a fresh read never
213
- // pays for a second one — which is what keeps a wrong token from costing a
214
- // store read per attempt.
215
- if (fresh && (expected === null || !tokensMatch(presented, expected))) {
216
- expected = await this.#reload();
265
+ // or the issue that produced it. One re-read separates the two, and it is
266
+ // what makes a rotated-in token work on its first call rather than after
267
+ // the window. Only a cached comparison can be wrong this way, so a fresh
268
+ // read never pays for a second one — which is what keeps a wrong token from
269
+ // costing a store read per attempt.
270
+ if (fresh && matched === null) {
271
+ rows = await this.#reload();
272
+ matched = find(presented, rows);
217
273
  }
218
274
 
219
- if (expected === null) {
220
- // The profile has no token yet. Fail closed and let `lanes link doctor` explain.
275
+ if (rows.length === 0) {
276
+ // No token has been issued. Fail closed, and distinctly from a wrong one:
277
+ // `lanes link doctor` reads this to say "issue one" rather than "check it".
221
278
  return { ok: false, reason: 'not_configured' };
222
279
  }
223
280
 
224
- return tokensMatch(presented, expected)
225
- ? { ok: true, principal: ownerPrincipal(profile) }
226
- : { ok: false, reason: 'invalid' };
281
+ if (matched === null) return { ok: false, reason: 'invalid' };
282
+
283
+ // **Resolved per request, not cached with the value.** Membership is read
284
+ // when a token is minted for an OAuth client (ADR-060) because there is a
285
+ // mint to read it at; a static token has none, so this is the only place
286
+ // the question can be asked. It is what makes `profile members remove`
287
+ // take effect on the next call rather than on the next rotation.
288
+ //
289
+ // A resolver that throws fails closed. The alternative — falling back to
290
+ // "every profile" — would restore exactly the behaviour ADR-068 removes,
291
+ // and would do it precisely when something is already wrong.
292
+ let profiles: readonly string[];
293
+ try {
294
+ profiles = await this.#options.profilesFor(matched.subject);
295
+ } catch {
296
+ return { ok: false, reason: 'invalid' };
297
+ }
298
+
299
+ return {
300
+ ok: true,
301
+ principal: machinePrincipal(matched.subject, this.#options.profile, profiles),
302
+ };
227
303
  }
228
304
 
229
- async #reload(): Promise<string | null> {
305
+ async #reload(): Promise<readonly LoadedToken[]> {
230
306
  // Both caches, or neither: the store holds its own decrypted copy, so
231
307
  // re-reading without dropping that first re-reads the same stale value.
232
308
  this.#options.credentials.refresh?.();
233
- this.#cached = await this.#options.credentials.get(this.#options.tokenRef);
309
+
310
+ const rows = await this.#options.tokens();
311
+ const loaded: LoadedToken[] = [];
312
+ for (const row of rows) {
313
+ const value = await this.#options.credentials.get(row.ref);
314
+ // A row whose credential is gone is not an error to report here. It is
315
+ // what a half-finished `secrets push` looks like, and the row simply
316
+ // matches nothing — `doctor` is where that is worth a sentence.
317
+ if (value) loaded.push({ subject: row.subject, value });
318
+ }
319
+
320
+ this.#cached = loaded;
234
321
  this.#readAt = this.#now();
235
- return this.#cached;
322
+ return loaded;
236
323
  }
237
324
 
238
325
  /**
239
- * Drop the cached value immediately.
326
+ * Drop the cached set immediately.
240
327
  *
241
328
  * The window above already bounds how long a rotation goes unnoticed, so this
242
329
  * is an optimisation rather than the mechanism — nothing's correctness may
@@ -247,6 +334,20 @@ export class BearerAuthenticator implements Authenticator {
247
334
  }
248
335
  }
249
336
 
337
+ /**
338
+ * The row a presented token matches, or null.
339
+ *
340
+ * Every row is compared even after one matches. Returning early would make the
341
+ * time taken describe *which* row answered, and the whole point of
342
+ * `tokensMatch` is that a comparison here leaks nothing about the value it is
343
+ * comparing against.
344
+ */
345
+ function find(presented: string, rows: readonly LoadedToken[]): LoadedToken | null {
346
+ let found: LoadedToken | null = null;
347
+ for (const row of rows) if (tokensMatch(presented, row.value)) found = row;
348
+ return found;
349
+ }
350
+
250
351
  /**
251
352
  * Mint a profile token: 32 random bytes, base64url, prefixed so it is
252
353
  * recognisable in a config file and greppable in a leak.
@@ -68,8 +68,14 @@ export const ACCEPTS: Record<string, readonly string[]> = {
68
68
  'target show': ['workspace'],
69
69
  'mcp install-instructions': ['client'],
70
70
  pair: ['print', 'rotate', 'yes'],
71
- 'token show': ['show', 'raw'],
72
- 'token rotate': ['show', 'raw', 'yes'],
71
+ // `--id` names which row, and is required once more than one is issued.
72
+ // `--subject`/`--me` say who a new one is for, which is the whole of ADR-068.
73
+ token: ['json'],
74
+ 'token list': ['json'],
75
+ 'token issue': ['show', 'subject', 'me', 'label'],
76
+ 'token show': ['show', 'raw', 'id'],
77
+ 'token rotate': ['show', 'raw', 'yes', 'id'],
78
+ 'token revoke': ['yes', 'id'],
73
79
  'audit tail': ['limit', 'denied-only', 'format'],
74
80
  'audit verify': ['limit', 'format'],
75
81
  attach: ['connection'],
@@ -77,7 +83,11 @@ export const ACCEPTS: Record<string, readonly string[]> = {
77
83
  start: ['port', 'only'],
78
84
  'mcp stdio': ['only'],
79
85
  'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill', 'headless'],
80
- 'mcp skill': ['print', 'force'],
86
+ // No `--force`: `mcp skill` prints a path or the document and writes nothing,
87
+ // so there was nothing for it to force. It was accepted and ignored, which is
88
+ // the defect `selection.ts` exists to prevent. `mcp add --force` is the flag
89
+ // that replaces a registration.
90
+ 'mcp skill': ['print'],
81
91
  'mcp list': ['name', 'scope'],
82
92
  // `--yes` because it installs the app when nothing answers the scheme, and
83
93
  // that is the one prompt in this CLI that puts an application on the machine.
@@ -22,6 +22,14 @@ export function declareConnection(input: {
22
22
  readonly connectionId: string;
23
23
  readonly account: string;
24
24
  readonly label: string;
25
+ /**
26
+ * What the row is called with nobody's word for it, from `settleIdentity`.
27
+ *
28
+ * The provider's name and the account composed by `defaultConnectionLabel`,
29
+ * which is what every reader falls back to. A label equal to it is a line
30
+ * saying what the two lines above it already say, so it is not written.
31
+ */
32
+ readonly defaultLabel: string;
25
33
  /** Which route in, where the provider offered a choice. */
26
34
  readonly method: string | undefined;
27
35
  /**
@@ -34,6 +42,7 @@ export function declareConnection(input: {
34
42
  readonly config: Readonly<Record<string, string>>;
35
43
  }): readonly string[] {
36
44
  const { document, connections, providerId, connectionId, account, label, method } = input;
45
+ const derived = input.defaultLabel;
37
46
  const config = input.config;
38
47
 
39
48
  const key = `${providerId}.${connectionId}`;
@@ -45,14 +54,14 @@ export function declareConnection(input: {
45
54
  // where the OAuth provider already looks. Writing it would add a line per
46
55
  // connection that can only ever agree or be a bug.
47
56
  //
48
- // No `label` either, when it is the account. Pressing Enter at the prompt is
49
- // the common answer, and a line repeating the address above it is a line to
50
- // read past forever.
57
+ // No `label` either, when it is the one every reader derives anyway.
58
+ // Pressing Enter at the prompt is the common answer, and a line repeating
59
+ // the provider and the address above it is a line to read past forever.
51
60
  document.addTo(['connections'], {
52
61
  id: connectionId,
53
62
  provider: providerId,
54
63
  account,
55
- ...(label === account ? {} : { label }),
64
+ ...(label === derived ? {} : { label }),
56
65
  ...(Object.keys(config).length > 0 ? { config } : {}),
57
66
  });
58
67
  changes.push(`connections += ${key} (${account})`);
@@ -69,10 +78,10 @@ export function declareConnection(input: {
69
78
  changes.push(`connections.${key}.account = ${account}`);
70
79
  }
71
80
 
72
- // Compared against what the row is *called*, which is the account until
81
+ // Compared against what the row is *called*, which is the derived name until
73
82
  // somebody names it otherwise. Without the fallback, every reconnect of an
74
- // unlabelled connection writes a label that says what the line above it says.
75
- if ((declared?.label ?? declared?.account) !== label) {
83
+ // unlabelled connection writes a label that says what the lines above it say.
84
+ if ((declared?.label ?? derived) !== label) {
76
85
  document.setIn(['connections', index, 'label'], label);
77
86
  changes.push(`connections.${key}.label = ${label}`);
78
87
  }
@@ -237,7 +237,7 @@ export async function runConnect(
237
237
  // adding a new one. Without it, a retried connect appends a second row
238
238
  // rather than repairing the first — which is how `main2` and `main3`
239
239
  // ended up in a config describing two mailboxes.
240
- const { connectionId, account, label } = await settleIdentity({
240
+ const settled = await settleIdentity({
241
241
  manifest,
242
242
  provisionalId,
243
243
  explicitId: named,
@@ -246,6 +246,7 @@ export async function runConnect(
246
246
  runtime: { ...runtime, connectorFor: address.connectorFor },
247
247
  prompter,
248
248
  });
249
+ const { connectionId, account, label } = settled;
249
250
 
250
251
  const connectionKey = `${providerId}.${connectionId}`;
251
252
 
@@ -277,9 +278,8 @@ export async function runConnect(
277
278
  document: connectionsDocument,
278
279
  connections: runtime.workspaceConnections,
279
280
  providerId,
280
- connectionId,
281
- account,
282
- label,
281
+ // The settled identity as one value: the row, the account, the name.
282
+ ...settled,
283
283
  method: method.id,
284
284
  config: address.values,
285
285
  }),
@@ -1,6 +1,7 @@
1
1
  import { createMcpConnector } from '#connectivity/transports';
2
2
  import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
3
3
  import type { SecretStore } from '#secrets';
4
+ import { defaultConnectionLabel } from '#profile';
4
5
  import type { ConnectionConfig, Config } from '#profile';
5
6
  import type { AnyConnector, ProviderManifest } from '#connectivity';
6
7
  import { nextConnectionId, resolveAccount } from '../../identity.ts';
@@ -46,7 +47,21 @@ export async function settleIdentity(input: {
46
47
  authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
47
48
  };
48
49
  prompter?: Prompter;
49
- }): Promise<{ connectionId: string; account: string; label: string }> {
50
+ }): Promise<{
51
+ connectionId: string;
52
+ account: string;
53
+ label: string;
54
+ /**
55
+ * What this row is called with nobody's word for it, carried to the writer.
56
+ *
57
+ * `declareConnection` writes no label equal to it, for the reason it never
58
+ * wrote one equal to the account: a line saying what the two lines above it
59
+ * say is a line to read past forever. Returned rather than derived twice, so
60
+ * the string the operator was offered and the string compared against it
61
+ * cannot come apart.
62
+ */
63
+ defaultLabel: string;
64
+ }> {
50
65
  const { manifest, provisionalId, explicitId, runtime } = input;
51
66
  const prompter = input.prompter ?? terminalPrompter;
52
67
 
@@ -169,17 +184,20 @@ export async function settleIdentity(input: {
169
184
  (candidate) => candidate.account.toLowerCase() === account!.toLowerCase(),
170
185
  )?.id ?? nextConnectionId(taken, false)));
171
186
 
187
+ const defaultLabel = defaultConnectionLabel(manifest.name, account);
188
+
172
189
  return {
173
190
  connectionId,
174
191
  account,
192
+ defaultLabel,
175
193
  label: await settleLabel({
176
194
  given: input.label,
195
+ fallback: defaultLabel,
177
196
  // What the row this is about to land on is already called. Looked up
178
197
  // across the whole vendor account rather than this provider alone, for the
179
198
  // reason `accountSiblings` exists: `connect icloud_calendar` adopts iCloud
180
199
  // Mail's id, and should adopt the name that goes with it too.
181
200
  declared: siblings.find((candidate) => candidate.id === connectionId)?.label,
182
- account,
183
201
  typed,
184
202
  prompter,
185
203
  }),
@@ -198,21 +216,29 @@ export async function settleIdentity(input: {
198
216
  * The suggestion is in the question and an empty answer takes it, so the cost of
199
217
  * always asking is one keystroke. Nothing addresses a connection by its label,
200
218
  * so there is no answer here that can break anything.
219
+ *
220
+ * **The suggestion is the provider and the account, not the account.** It was
221
+ * the address alone, which made the label a second copy of the field beside it
222
+ * — and left every surface that shows a name without one, because a default
223
+ * that only repeats another line is a default nothing writes down.
224
+ * `defaultConnectionLabel` is the whole rule and every reader derives the same
225
+ * string from it.
201
226
  */
202
227
  async function settleLabel(input: {
203
228
  given: string | undefined;
204
229
  declared: string | undefined;
205
- account: string;
230
+ /** What this row is called when nobody says otherwise. */
231
+ fallback: string;
206
232
  typed: boolean;
207
233
  prompter: Prompter;
208
234
  }): Promise<string> {
209
- const { given, declared, account, typed, prompter } = input;
235
+ const { given, declared, fallback, typed, prompter } = input;
210
236
 
211
237
  if (given) return given;
212
238
 
213
- // A label already chosen wins over the account, so re-authorising an expired
214
- // credential does not quietly undo the operator's own word for the row.
215
- const suggestion = declared ?? account;
239
+ // A label already chosen wins over the derived one, so re-authorising an
240
+ // expired credential does not quietly undo the operator's own word for the row.
241
+ const suggestion = declared ?? fallback;
216
242
 
217
243
  if (typed || !prompter.interactive) return suggestion;
218
244