@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
@@ -109,7 +109,15 @@ export const RESERVED_PROVIDER_IDS: readonly string[] = [
109
109
  'lanes_entities',
110
110
  ];
111
111
 
112
- /** Old id to new, for the contract-4 migration and for a refusal that names it. */
112
+ /**
113
+ * Old id to new, for a refusal that can name what a stale client is asking for.
114
+ *
115
+ * Nothing consumes it yet. The migration builds its own map from
116
+ * `C3_OWNER_PROVIDERS` (`src/cli/contract4-rename.ts`), and a `tools/call` on a
117
+ * pre-0.9.0 name is answered by the SDK's exact-match lookup before anything
118
+ * here sees it — so the refusal this exists for is still unwritten. ADR-066
119
+ * records the failure it would address.
120
+ */
113
121
  export const RENAMED_OWNER_PROVIDERS: ReadonlyMap<string, string> = new Map(
114
122
  RESERVED_PROVIDER_IDS.map((id) => [id.slice('lanes_'.length), id]),
115
123
  );
@@ -4,7 +4,7 @@ import {
4
4
  readConnections, listProfiles, loadProfileConfig, vaultRef, type Config, type TargetConfig } from '#profile';
5
5
  import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
6
6
  import { ok, print, style, warn } from '#cli/output.ts';
7
- import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts';
7
+ import { buildRegistryWithWorkspace } from '#cli/runtime.ts';
8
8
 
9
9
  /**
10
10
  * Getting the target's credential store to the state a revision can boot from.
@@ -198,7 +198,6 @@ export async function readableRefs(
198
198
  continue;
199
199
  }
200
200
 
201
- refs.add(config.auth.token_ref);
202
201
  if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
203
202
  // The OIDC audience check reads this on every verify (`server/endpoint.ts`).
204
203
  if (config.auth.authorization?.mode === 'oidc') {
@@ -209,6 +208,13 @@ export async function readableRefs(
209
208
 
210
209
  // Once, for the reason `rotatableRefs` gives.
211
210
  const connectionsFile = await readConnections(root);
211
+
212
+ // The endpoint's own tokens, which are the workspace's rather than any
213
+ // profile's since ADR-068. Pushed so a deployed revision can authenticate a
214
+ // headless caller; the *rows* travel with `connections.yaml`, so a ref here
215
+ // without its value is a row that matches nothing and reads to the client
216
+ // exactly like a wrong token.
217
+ for (const issued of connectionsFile.tokens) refs.add(issued.ref);
212
218
  const registry = await buildRegistryWithWorkspace(root);
213
219
 
214
220
  for (const connection of connectionsFile.connections) {
@@ -227,7 +233,6 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
227
233
  const blocking: string[] = [];
228
234
  const warnings: string[] = [];
229
235
 
230
- await seedProfileToken({ config, credentials, readOnly, blocking });
231
236
  await seedVaultKey({ declared: input.declared, credentials, readOnly });
232
237
 
233
238
  // Connection credentials are written by `connect`, against a real account, in
@@ -280,34 +285,4 @@ async function seedVaultKey(input: {
280
285
  print(ok(`minted the vault key at "${VAULT_KEY_REF}" — the revision reads it from there`));
281
286
  }
282
287
 
283
- /**
284
- * The endpoint's own bearer token.
285
- *
286
- * Minted rather than demanded, and not even asked about: it is a random string
287
- * this process generates correctly and the operator cannot usefully choose, so a
288
- * prompt would be a question with one answer. `ensureProfileToken` is the same
289
- * call `outputs` makes for a local target, which is what keeps "the token" one
290
- * thing rather than a per-command notion.
291
- *
292
- * The deployed container deliberately will *not* do this — a token invented
293
- * inside something that scales to zero is a token nobody can read back, and the
294
- * endpoint would come up healthy while rejecting every agent.
295
- */
296
- async function seedProfileToken(input: {
297
- config: Config;
298
- credentials: SecretStore;
299
- readOnly: boolean;
300
- blocking: string[];
301
- }): Promise<void> {
302
- const ref = input.config.auth.token_ref;
303
- if (await input.credentials.has(ref)) return;
304
-
305
- if (input.readOnly) {
306
- input.blocking.push(`the endpoint bearer token — nothing at "${ref}"`);
307
- return;
308
- }
309
-
310
- const { created } = await ensureProfileToken(input.credentials, ref);
311
- if (created) print(ok(`minted an endpoint token at "${ref}" — read it with lanes link token show`));
312
- }
313
288
 
@@ -22,14 +22,14 @@ import { heading, ok, print, style, warn } from '#cli/output.ts';
22
22
  * `tools/list` when it connects and keeps it: this endpoint is stateless, so
23
23
  * there is no stream on which to send `notifications/tools/list_changed`, and
24
24
  * `buildMcpServer` no longer pretends otherwise. A first deploy necessarily
25
- * publishes a profile whose only connection is `setup.main` — the accounts come
25
+ * publishes a profile whose only connection is `lanes_setup.lan1` — the accounts come
26
26
  * after — so a connector registered in that window captures a two-tool surface
27
27
  * and holds it. The endpoint is right, every reload lands, and the client shows
28
28
  * two tools until someone removes and re-adds it.
29
29
  *
30
30
  * Unconditional, and that is the correction that matters. This was gated on
31
31
  * `prepared.warnings.length`, which is zero in precisely the case it describes:
32
- * a fresh profile declares only `setup.main`, `setup` is a local provider with
32
+ * a fresh profile declares only `lanes_setup.lan1`, and it is a local provider with
33
33
  * no credential, so `prepareSecrets` has nothing to warn about. The advice
34
34
  * appeared only on a later re-deploy, by which point the connector is usually
35
35
  * registered and the ordering is no longer available to get right.
@@ -39,7 +39,10 @@ export function registerLine(profile: string, target: string): string {
39
39
  ` Connect your accounts first, then register with:\n` +
40
40
  ` lanes link outputs --profile ${profile} --workspace ${target}\n` +
41
41
  ' A client keeps the tool list it fetched when it connected, so one registered\n' +
42
- ' before the accounts holds a surface without them until it is re-added.',
42
+ ' before the accounts holds a surface without them until it is re-added.\n' +
43
+ ' One registered before this deploy holds the list from before it, so a version\n' +
44
+ ' that renamed a provider or an id leaves it calling names that are gone:\n' +
45
+ ' lanes link mcp add',
43
46
  );
44
47
  }
45
48
 
@@ -131,6 +131,8 @@ export interface BuildContextOptions {
131
131
  readonly authorize?: ((request: Request) => Promise<Request>) | undefined;
132
132
  /** `oauth_apps` entries this profile declares. See `resolveSecretRefs`. */
133
133
  readonly ownClients?: readonly string[] | undefined;
134
+ /** Every profile the caller may reach. See `ProviderContext.profiles`. */
135
+ readonly profiles: readonly string[];
134
136
  }
135
137
 
136
138
  export function buildProviderContext(options: BuildContextOptions): ProviderContext {
@@ -146,6 +148,7 @@ export function buildProviderContext(options: BuildContextOptions): ProviderCont
146
148
 
147
149
  return {
148
150
  connection: info,
151
+ profiles: options.profiles,
149
152
  state: createScopedStore(options.state, namespace),
150
153
  storage: scopeBlobStore(options.storage, namespace),
151
154
  credentials: scopeSecrets(
@@ -271,6 +271,11 @@ export class Dispatcher {
271
271
  // provider that could be brokered but is not reads its client from the
272
272
  // store, so it has to be able to.
273
273
  ownClients: this.#deps.oauthApps,
274
+ // The *caller's* set, not the workspace's. `undefined` means
275
+ // unrestricted — the stdio pipe — and the profile in play is the only
276
+ // honest answer a dispatcher can give for it without knowing what the
277
+ // endpoint is serving.
278
+ profiles: request.principal.profiles ?? [request.principal.profile],
274
279
  ...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
275
280
  });
276
281
 
@@ -20,6 +20,38 @@ export function connectionRefOf(connection: ConnectionConfig): string {
20
20
  return `${connection.provider}.${connection.id}`;
21
21
  }
22
22
 
23
+ /**
24
+ * What to call a connection nobody has named.
25
+ *
26
+ * `label` is the operator's own word for a row and is usually unset: `connect`
27
+ * offers a default and does not write one that only repeats a line above it, so
28
+ * most rows are an id, a provider and an account and nothing else. Every reader
29
+ * then has to decide what to show, and each of them picked the id — which is the
30
+ * one field that says nothing. It is opaque on purpose (`nextConnectionId`), so
31
+ * `con8` tells a reader strictly less about a row than any other line of it.
32
+ *
33
+ * The provider is what somebody is asking about, and the account is what tells
34
+ * two rows of the same provider apart, so the name is both: `Gmail (ada)`. The
35
+ * local part only — every address a person holds at one domain repeats it, and
36
+ * the account itself is on the line beside this everywhere this is shown.
37
+ *
38
+ * A connection with no account behind it is its provider and nothing else. That
39
+ * is the owner layer, whose rows carry the proper noun in `account` already, so
40
+ * composing the two would read `Memory (Memory)`.
41
+ */
42
+ export function defaultConnectionLabel(
43
+ providerName: string,
44
+ account: string | null | undefined,
45
+ ): string {
46
+ if (!account || account === providerName) return providerName;
47
+
48
+ // Split on `@` and nothing else. A handle, an IBAN or a workspace name has no
49
+ // "first part" that is safe to guess at: `Lanes HQ` cut at the space is
50
+ // `Lanes`, which is a different workspace's name as often as not.
51
+ const local = account.split('@')[0];
52
+ return `${providerName} (${local || account})`;
53
+ }
54
+
23
55
  /**
24
56
  * One account this profile reaches, with the rules that govern it.
25
57
  *
@@ -46,6 +46,7 @@ export {
46
46
  assertGrantsResolve,
47
47
  assertNoRenamedProviders,
48
48
  connectionRefOf,
49
+ defaultConnectionLabel,
49
50
  selectConnections,
50
51
  soleGrantFor,
51
52
  vaultRef,
@@ -121,6 +122,14 @@ export {
121
122
  type ResolvedTarget,
122
123
  } from './registry.ts';
123
124
  export { recordTarget, removeTarget } from './deployments.ts';
125
+ export {
126
+ anyIssuedToken,
127
+ membersResolver,
128
+ nextTokenId,
129
+ readEndpointTokens,
130
+ tokenRef,
131
+ type EndpointToken,
132
+ } from './tokens.ts';
124
133
  export {
125
134
  isRemoteWorkspace,
126
135
  readWorkspaceFile,
@@ -59,11 +59,22 @@ import { knowledgeTargetSchema } from './knowledge.ts';
59
59
  * declaration moved inside it. No file's *shape* changed: `grants:`,
60
60
  * `members:` and `connections.yaml` are untouched, and only the paths moved.
61
61
  *
62
+ * **5 made the endpoint token a person's rather than a profile's** (ADR-068).
63
+ * `auth.token_ref` defaulted to the constant `profile/token` for every profile
64
+ * out of a credential store that is one per workspace, so the "profile's token"
65
+ * was the workspace's wearing a per-profile name — which is why removing one
66
+ * profile once deleted the token its siblings were served by. It moves to
67
+ * `tokens:` in `connections.yaml`, one row per issued token, each naming the
68
+ * Lanes subject it was issued to. What that buys is the thing a bearer token
69
+ * could not do: it resolves through `members:` like an OAuth token does, so a
70
+ * credential says who you are and the profiles follow from that rather than
71
+ * from which profile happened to mint it.
72
+ *
62
73
  * A hard cut each time, and only the newest is read here. `./legacy.ts`
63
74
  * understands the older shapes and only the migration uses it — a runtime that
64
75
  * loaded either would be the two-sources-of-truth problem again, one level up.
65
76
  */
66
- export const SUPPORTED_CONTRACT = 4;
77
+ export const SUPPORTED_CONTRACT = 5;
67
78
 
68
79
  /**
69
80
  * There is no `database:` block any more.
@@ -447,6 +458,29 @@ export const oauthAppSchema = z.object({
447
458
  client_secret_ref: credentialRef,
448
459
  });
449
460
 
461
+ /**
462
+ * One static endpoint token, and the person it was issued to.
463
+ *
464
+ * **`subject` is the whole point** (ADR-068). A bearer token used to resolve to
465
+ * the primary profile's owner and reach every profile in the workspace, which
466
+ * made it the one credential here that answered "what may I open" without ever
467
+ * answering "who are you". It names a Lanes subject now, and the profiles it
468
+ * reaches are the ones whose `members:` list that subject — the same resolution
469
+ * an OAuth token has gone through since ADR-060.
470
+ *
471
+ * The value is never here. `ref` points into the workspace's credential store,
472
+ * which is what keeps `findSecrets` from having anything to find in this file.
473
+ */
474
+ export const endpointTokenSchema = z.object({
475
+ id: identifier,
476
+ subject: subjectRef,
477
+ ref: credentialRef,
478
+ /** What it is for, in a word — `ci`, `runner`. Free text, shown in listings. */
479
+ label: z.string().min(1).optional(),
480
+ /** ISO 8601, written when the row is created. Reported, never compared. */
481
+ issued_at: z.string().min(1).optional(),
482
+ });
483
+
450
484
 
451
485
  /**
452
486
  * One profile: who it is, what it reaches, and what it may do.
@@ -473,7 +507,6 @@ export const configSchema = z.object({
473
507
  auth: z
474
508
  .object({
475
509
  mode: z.literal('bearer').default('bearer'),
476
- token_ref: credentialRef.default('profile/token'),
477
510
  /**
478
511
  * Additive. `mode` above still describes what the endpoint accepts on the
479
512
  * wire — a bearer token — and this describes where a *remote* client can
@@ -490,7 +523,7 @@ export const configSchema = z.object({
490
523
  */
491
524
  allowed_origins: z.array(browserOrigin).optional(),
492
525
  })
493
- .default({ mode: 'bearer', token_ref: 'profile/token' }),
526
+ .default({ mode: 'bearer' }),
494
527
 
495
528
  limits: z
496
529
  .object({
@@ -575,12 +608,23 @@ export const connectionsFileSchema = z.object({
575
608
  contract: z.number().int().positive(),
576
609
  connections: z.array(connectionSchema).default([]),
577
610
  oauth_apps: z.record(identifier, oauthAppSchema).default({}),
611
+ /**
612
+ * The static tokens this workspace has issued (ADR-068).
613
+ *
614
+ * Here rather than in a file of their own because this is already where the
615
+ * workspace keeps what is not a profile's and not an account's — `oauth_apps`
616
+ * is the precedent, and it is credential-referencing in the same way. A
617
+ * workspace that has issued none has no key, which is what lets an untouched
618
+ * file parse.
619
+ */
620
+ tokens: z.array(endpointTokenSchema).default([]),
578
621
  });
579
622
 
580
623
  export type ConnectionsFile = z.infer<typeof connectionsFileSchema>;
581
624
 
582
625
  export type Config = z.infer<typeof configSchema>;
583
626
  export type ConnectionConfig = z.infer<typeof connectionSchema>;
627
+ export type EndpointToken = z.infer<typeof endpointTokenSchema>;
584
628
  export type GrantConfig = z.infer<typeof grantSchema>;
585
629
  export type MemberConfig = z.infer<typeof memberSchema>;
586
630
  export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
@@ -0,0 +1,137 @@
1
+ import { loadWorkspaceProfiles, readConnections } from './workspace.ts';
2
+ import type { EndpointToken } from './schema.ts';
3
+
4
+ /**
5
+ * The endpoint's static tokens, which belong to the workspace (ADR-068).
6
+ *
7
+ * **Why this is not a field on a profile any more.** `auth.token_ref` defaulted
8
+ * to the constant `profile/token` for every profile, out of a credential store
9
+ * that has been one per workspace since ADR-057 — so naming a profile to find
10
+ * the endpoint's token asked a question with one answer, and every command whose
11
+ * subject is the endpoint had to ask it. `profile/removal.ts` records the
12
+ * sharper consequence: removing one profile deleted the token its siblings were
13
+ * being served by, and the deployed revision then refused every request.
14
+ *
15
+ * **A row names a person.** That is the part that changes behaviour rather than
16
+ * merely moving a file. A bearer token used to resolve to `ownerPrincipal` of
17
+ * whichever profile was primary, with `profiles: undefined` — "all of them" —
18
+ * so it was the one credential on this endpoint that answered *what may I open*
19
+ * without ever answering *who are you*. A row carries a Lanes subject, so the
20
+ * token resolves through the profile's `members:` exactly as an OAuth token has
21
+ * since ADR-060, and `mayReach` needs no special case for it.
22
+ *
23
+ * The value is never in the file. A row holds a `ref` into the workspace's
24
+ * credential store, which is what keeps `findSecrets` with nothing to find.
25
+ */
26
+
27
+ /** Where a row's value lives in the workspace credential store. */
28
+ export function tokenRef(id: string): string {
29
+ return `tokens/${id}`;
30
+ }
31
+
32
+ /**
33
+ * The next free row id, `tok1` upward.
34
+ *
35
+ * Same shape as `nextConnectionId` and opaque for the same reason: the id ends
36
+ * up in a listing and in a credential path, and an id derived from the label or
37
+ * the subject would either collide or leak. `label` is the field for saying what
38
+ * a token is for.
39
+ */
40
+ export function nextTokenId(taken: readonly string[]): string {
41
+ let highest = 0;
42
+ for (const id of taken) {
43
+ const match = /^tok([0-9]+)$/.exec(id);
44
+ if (match) highest = Math.max(highest, Number(match[1]));
45
+ }
46
+ return `tok${highest + 1}`;
47
+ }
48
+
49
+ /**
50
+ * Every token this workspace has issued.
51
+ *
52
+ * Read through `readConnections` rather than the filesystem, so a deployed
53
+ * revision whose root is a bucket URL loads them (ADR-049). A workspace that has
54
+ * issued none returns empty, which is not an error: it is what a fresh install
55
+ * looks like, and what a workspace looks like after `token revoke` removes the
56
+ * last row.
57
+ */
58
+ export async function readEndpointTokens(
59
+ workspaceRoot: string,
60
+ ): Promise<readonly EndpointToken[]> {
61
+ return (await readConnections(workspaceRoot)).tokens;
62
+ }
63
+
64
+ export type { EndpointToken };
65
+
66
+ /**
67
+ * Which profiles in this workspace list a subject as a member.
68
+ *
69
+ * The rule is ADR-060's and is stated once: a caller reaches a profile if that
70
+ * profile's `members:` names them. `server/endpoint.ts` answers the same
71
+ * question from its live runtime map when an OAuth code is minted; this answers
72
+ * it from disk, which is what a static token needs because it has no mint to be
73
+ * resolved at. Both are bounded by what the endpoint is actually serving —
74
+ * `visibility.ts` iterates the served profiles, so a name returned here that is
75
+ * not being served is filtered out rather than reachable.
76
+ *
77
+ * **Cached for the same window the token values are.** Without it a deployed
78
+ * revision read every profile's YAML out of a bucket on every request. The
79
+ * window is why `profile members remove` takes effect in seconds rather than on
80
+ * the next rotation, which is the property worth keeping.
81
+ */
82
+ export function membersResolver(
83
+ workspaceRoot: string,
84
+ options: { readonly ttlMs?: number; readonly now?: () => number } = {},
85
+ ): (subject: string) => Promise<readonly string[]> {
86
+ const ttl = options.ttlMs ?? 5_000;
87
+ const now = options.now ?? Date.now;
88
+
89
+ let cached: ReadonlyMap<string, readonly string[]> | null = null;
90
+ let readAt = 0;
91
+
92
+ return async (subject) => {
93
+ if (cached === null || now() - readAt >= ttl) {
94
+ const { loaded } = await loadWorkspaceProfiles(workspaceRoot);
95
+ const bySubject = new Map<string, string[]>();
96
+ for (const entry of loaded) {
97
+ for (const member of entry.config.members) {
98
+ const known = bySubject.get(member.subject) ?? [];
99
+ known.push(entry.profile);
100
+ bySubject.set(member.subject, known);
101
+ }
102
+ }
103
+ cached = bySubject;
104
+ readAt = now();
105
+ }
106
+
107
+ return cached.get(subject) ?? [];
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Any token this workspace holds, for talking to its own endpoint.
113
+ *
114
+ * `outputs`, `tools`, `doctor` and the reload notification all need *a* valid
115
+ * credential to ask the endpoint something, and none of them cares whose: they
116
+ * are the operator's own commands run against the operator's own workspace.
117
+ * Which row answers is therefore not a choice worth making visible — unlike
118
+ * `token show`, where it is the whole subject of the command and `--id` is
119
+ * required once there is more than one.
120
+ *
121
+ * **Null is an ordinary answer, not a failure.** A workspace that has issued
122
+ * none is the common case after ADR-062: a person's client registers against
123
+ * the bare URL and signs in, so nothing needs a static token until something
124
+ * headless does. Every caller degrades — reporting what it could not ask rather
125
+ * than minting a credential nobody asked for, which is what the old
126
+ * `ensureProfileToken` did on six different commands.
127
+ */
128
+ export async function anyIssuedToken(
129
+ workspaceRoot: string,
130
+ credentials: { get(ref: string): Promise<string | null> },
131
+ ): Promise<{ readonly id: string; readonly value: string } | null> {
132
+ for (const row of await readEndpointTokens(workspaceRoot)) {
133
+ const value = await credentials.get(row.ref);
134
+ if (value) return { id: row.id, value };
135
+ }
136
+ return null;
137
+ }
@@ -178,7 +178,7 @@ export const CONNECTIONS_FILE = 'connections.yaml';
178
178
  export async function readConnections(workspaceRoot: string): Promise<ConnectionsFile> {
179
179
  const path = join(workspaceRoot, CONNECTIONS_FILE);
180
180
  const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), CONNECTIONS_FILE);
181
- if (text === null) return { contract: SUPPORTED_CONTRACT, connections: [], oauth_apps: {} };
181
+ if (text === null) return { contract: SUPPORTED_CONTRACT, connections: [], oauth_apps: {}, tokens: [] };
182
182
 
183
183
  const raw = parseYaml(text);
184
184
 
@@ -36,6 +36,7 @@ export function harnessFor(
36
36
  manifest: definition.manifest,
37
37
  definition,
38
38
  connection: { id: connectionId, provider: definition.manifest.id, account: 'Owner' },
39
+ profiles: ['personal'],
39
40
  state: createMemoryState(),
40
41
  // Seeded deliberately: an owner provider must not be able to read any of
41
42
  // this, and the credential-boundary test says so by name.
@@ -188,3 +188,19 @@ export function planAll(
188
188
  .sort((a, b) => a.id.localeCompare(b.id))
189
189
  .map((manifest) => planFor(manifest, context));
190
190
  }
191
+
192
+ /**
193
+ * One connection this profile can reach, as the overview renders it.
194
+ *
195
+ * Declared once rather than spelled structurally at both ends: the two literals
196
+ * had to agree about `providerName`, and the display name went missing on one
197
+ * side — which is how an owner row came to read "Memory (lanes_memory (Memory))".
198
+ */
199
+ export interface ReachableConnection {
200
+ readonly key: string;
201
+ readonly provider?: string;
202
+ /** The manifest's display name, which is what a label composes with. */
203
+ readonly providerName?: string;
204
+ readonly account: string;
205
+ readonly label?: string | undefined;
206
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { defineLocalProvider, keepKeys, type ProviderDefinition, type ProviderManifest } from '#connectivity';
3
- import { planAll, planFor, type ProviderPlan } from './plan.ts';
3
+ import { defaultConnectionLabel } from '#profile';
4
+ import { planAll, planFor, type ProviderPlan, type ReachableConnection } from './plan.ts';
4
5
 
5
6
  /**
6
7
  * `setup` — what is connected, and what connecting something else would take.
@@ -54,7 +55,11 @@ export interface SetupProviderOptions {
54
55
  * further than the commands this provider emits — nothing here opens a store.
55
56
  */
56
57
  readonly target: string;
57
- /** Sibling profile names on this endpoint. Names only; already at `/health`. */
58
+ /**
59
+ * Every profile on disk. **Not what gets rendered:** the caller's own set
60
+ * arrives at handler time in `ProviderContext.profiles`; this only says how
61
+ * many are withheld — "one profile" versus "one of four".
62
+ */
58
63
  readonly profiles?: readonly string[];
59
64
  /**
60
65
  * `oauth_apps` entries this profile declares.
@@ -77,7 +82,7 @@ export interface SetupProviderOptions {
77
82
  * enum from. Computing it separately here is how discovery and enforcement
78
83
  * drift, and a leak in discovery is still a leak.
79
84
  */
80
- readonly reachable?: () => ReadonlyArray<{ key: string; provider: string; account: string }>;
85
+ readonly reachable?: () => ReadonlyArray<ReachableConnection>;
81
86
  }
82
87
 
83
88
  export function createSetupProvider(options: SetupProviderOptions): ProviderDefinition {
@@ -132,7 +137,13 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
132
137
  content: [
133
138
  {
134
139
  type: 'text',
135
- text: renderOverview(options, connections, plans, handlerContext.connection.key),
140
+ // The caller's set, not the workspace's (ADR-068): rendering
141
+ // `options.profiles` named profiles `mayReach` hides from the
142
+ // same caller's `profile` enum.
143
+ text: renderOverview(options, connections, plans, handlerContext.connection.key, {
144
+ reachable: handlerContext.profiles,
145
+ total: options.profiles?.length ?? handlerContext.profiles.length,
146
+ }),
136
147
  },
137
148
  ],
138
149
  };
@@ -185,9 +196,10 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
185
196
 
186
197
  function renderOverview(
187
198
  options: SetupProviderOptions,
188
- connections: ReadonlyArray<{ key: string; account: string; label?: string | undefined }>,
199
+ connections: ReadonlyArray<ReachableConnection>,
189
200
  plans: readonly ProviderPlan[],
190
201
  self: string,
202
+ caller: { reachable: readonly string[]; total: number },
191
203
  ): string {
192
204
  const lines: string[] = [`Profile "${options.profile}".`, ''];
193
205
 
@@ -199,11 +211,16 @@ function renderOverview(
199
211
  // Account first, label in brackets. The other way round for a person
200
212
  // reading `status`, and deliberately not here: an agent choosing which
201
213
  // connection to call needs the identity, and "Work mail" is not one.
214
+ //
215
+ // `defaultConnectionLabel` when the row carries none, so an unnamed row is
216
+ // called what `connection list` and `/state` call it. The provider's
217
+ // *name*, never its id: `lanes_memory` with account `Memory` would read
218
+ // "lanes_memory (Memory)".
219
+ const provider = connection.providerName ?? connection.key.split('.')[0] ?? connection.key;
220
+ const named = connection.label ?? defaultConnectionLabel(provider, connection.account);
202
221
  lines.push(
203
222
  ` ${connection.key} — ${connection.account}` +
204
- (connection.label && connection.label !== connection.account
205
- ? ` (${connection.label})`
206
- : ''),
223
+ (named && named !== connection.account ? ` (${named})` : ''),
207
224
  );
208
225
  }
209
226
  }
@@ -241,11 +258,21 @@ function renderOverview(
241
258
  );
242
259
  }
243
260
 
244
- const siblings = (options.profiles ?? []).filter((name) => name !== options.profile);
245
- if (siblings.length > 0) {
261
+ // **Which profiles *this caller* can reach** the question an agent asks
262
+ // first. `initialize.instructions` names them until its budget is tight, then
263
+ // collapses to a count; this is the surface it already points at.
264
+ const others = caller.reachable.filter((name) => name !== options.profile);
265
+ lines.push('', `You can reach ${caller.reachable.length} profile(s) on this endpoint.`);
266
+ if (others.length > 0) {
267
+ lines.push(` Also: ${others.join(', ')}. Pass one as \`profile\` to see what it reaches.`);
268
+ }
269
+
270
+ // A count, never names: naming them is the leak this used to be. That some
271
+ // exist is not a secret, and an agent told "one of four" asks the owner.
272
+ const hidden = caller.total - caller.reachable.length;
273
+ if (hidden > 0) {
246
274
  lines.push(
247
- '',
248
- `This endpoint also serves: ${siblings.join(', ')}. Pass that profile to see what it reaches.`,
275
+ ` ${hidden} other profile(s) exist here that you are not a member of. Only the owner can change that.`,
249
276
  );
250
277
  }
251
278
 
@@ -72,8 +72,9 @@ try {
72
72
  },
73
73
  port,
74
74
  host,
75
- // A deployed instance never mints its own token: see `endpoint.ts`.
76
- mintToken: false,
75
+ // Nothing about tokens here any more (ADR-068): a deployed instance neither
76
+ // mints one nor needs one to boot. A client discovers the
77
+ // protected-resource document from the 401 and signs its owner in.
77
78
  // Stdout is where Cloud Run collects logs, and a rejected credential on a
78
79
  // public URL is the event this exists for.
79
80
  log: streamLogger((line) => process.stdout.write(`${line}\n`)),
@@ -84,7 +85,6 @@ try {
84
85
  // into an image that will be replaced.
85
86
  log(`reconciled ${profile}\n${plan}`);
86
87
  },
87
- tokenMinted() {},
88
88
  },
89
89
  });
90
90