@lanes-sh/link 0.9.1 → 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 (59) hide show
  1. package/README.md +10 -4
  2. package/instructions/agents/lanes-link-scout.md +3 -3
  3. package/instructions/skills/lanes-link/SKILL.md +95 -57
  4. package/package.json +1 -1
  5. package/src/auth/index.ts +127 -26
  6. package/src/cli/accepts.ts +13 -3
  7. package/src/cli/commands/connect/declare.ts +16 -7
  8. package/src/cli/commands/connect/index.ts +4 -4
  9. package/src/cli/commands/connect/settle.ts +33 -7
  10. package/src/cli/commands/connection-list.ts +25 -1
  11. package/src/cli/commands/mcp/harnesses.ts +36 -22
  12. package/src/cli/commands/mcp/register.ts +63 -16
  13. package/src/cli/commands/mcp/stdio.ts +0 -1
  14. package/src/cli/commands/operate/inspect.ts +21 -10
  15. package/src/cli/commands/operate/outputs.ts +94 -61
  16. package/src/cli/commands/operate/serve.ts +0 -4
  17. package/src/cli/commands/operate/token.ts +305 -35
  18. package/src/cli/commands/operate/tools.ts +28 -5
  19. package/src/cli/commands/operate.ts +7 -1
  20. package/src/cli/commands/profile/removal.ts +10 -9
  21. package/src/cli/config-repair-sweep.ts +18 -4
  22. package/src/cli/config-repair.ts +1 -1
  23. package/src/cli/config-templates.ts +14 -7
  24. package/src/cli/contract3-credentials.ts +8 -8
  25. package/src/cli/contract4.ts +7 -2
  26. package/src/cli/contract5.ts +234 -0
  27. package/src/cli/endpoint-url.ts +17 -3
  28. package/src/cli/main.ts +28 -4
  29. package/src/cli/publish.ts +13 -4
  30. package/src/cli/runtime/open.ts +19 -2
  31. package/src/cli/runtime/select.ts +0 -12
  32. package/src/cli/runtime.ts +0 -1
  33. package/src/cli/selection.ts +40 -10
  34. package/src/cli/usage.ts +12 -7
  35. package/src/cli/workspace-migrate.ts +18 -12
  36. package/src/connectivity/context.ts +17 -0
  37. package/src/connectivity/manifest/provider.ts +9 -1
  38. package/src/deployments/adapters/audit-blob.ts +22 -1
  39. package/src/deployments/prepare.ts +8 -33
  40. package/src/deployments/report.ts +6 -3
  41. package/src/dispatch/context.ts +3 -0
  42. package/src/dispatch/dispatch.ts +5 -0
  43. package/src/profile/connections.ts +32 -0
  44. package/src/profile/index.ts +9 -0
  45. package/src/profile/schema.ts +47 -3
  46. package/src/profile/tokens.ts +137 -0
  47. package/src/profile/workspace.ts +1 -1
  48. package/src/providers/harness.ts +1 -0
  49. package/src/providers/setup/plan.ts +16 -0
  50. package/src/providers/setup/provider.ts +39 -12
  51. package/src/server/container.ts +3 -3
  52. package/src/server/endpoint.ts +17 -29
  53. package/src/server/harness.ts +28 -3
  54. package/src/server/index.ts +8 -8
  55. package/src/server/mcp/visibility.ts +11 -3
  56. package/src/server/read/deployed.ts +4 -0
  57. package/src/server/read/open.ts +4 -0
  58. package/src/server/read/routes.ts +15 -2
  59. package/src/server/read/state.ts +30 -2
@@ -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
 
@@ -1,12 +1,15 @@
1
1
  import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
+ import { PROVIDER_MANIFESTS } from '#providers/index.ts';
2
3
  import {
3
4
  connectionRefOf,
5
+ defaultConnectionLabel,
4
6
  listProfiles,
5
7
  loadProfileConfig,
6
8
  readConnections,
7
9
  resolveTargetWorkspace,
8
10
  resolveWorkspaceRoot,
9
11
  } from '#profile';
12
+ import { RESERVED_SURFACES } from '../config-repair.ts';
10
13
  import { emit, heading, print, style, table } from '../output.ts';
11
14
  import type { GlobalFlags } from '../runtime.ts';
12
15
 
@@ -104,6 +107,20 @@ export async function connectionList(flags: GlobalFlags & { json?: boolean }): P
104
107
  });
105
108
  }
106
109
 
110
+ /**
111
+ * What each provider is called, by id.
112
+ *
113
+ * The catalogue's manifests plus the owner layer, which is registered separately
114
+ * and is deliberately absent from `PROVIDERS`. A workspace-local manifest is not
115
+ * here: this command reads files rather than building a registry, and one custom
116
+ * provider falling back to its account is a smaller price than a registry build
117
+ * on a listing.
118
+ */
119
+ const PROVIDER_NAMES = new Map<string, string>([
120
+ ...PROVIDER_MANIFESTS.map((manifest): [string, string] => [manifest.id, manifest.name]),
121
+ ...Object.entries(RESERVED_SURFACES),
122
+ ]);
123
+
107
124
  function row(one: ConnectionSummary): string[] {
108
125
  // "granted to nobody" is said rather than left blank, because a blank column
109
126
  // reads as "not loaded yet" and this is a fact about the config.
@@ -112,5 +129,12 @@ function row(one: ConnectionSummary): string[] {
112
129
  ? style.dim('no profile grants it')
113
130
  : one.grantedTo.join(', ');
114
131
 
115
- return [` ${style.bold(one.key)}`, one.label ?? one.account, reach];
132
+ // The account was this column's fallback, which meant an unlabelled row read
133
+ // as its address and said nothing about which service the address is at. The
134
+ // derived name says both, and is the one the dashboard and the connect prompt
135
+ // show for the same row.
136
+ const named = PROVIDER_NAMES.get(one.provider);
137
+ const label = one.label ?? (named ? defaultConnectionLabel(named, one.account) : one.account);
138
+
139
+ return [` ${style.bold(one.key)}`, label, reach];
116
140
  }
@@ -45,14 +45,17 @@ export interface AddInput {
45
45
  readonly tokenEnv: string;
46
46
  readonly scope: string;
47
47
  /**
48
- * Which selection this registration is for.
48
+ * Which workspace this registration is for.
49
49
  *
50
- * Not part of the URL — a deployed endpoint serves every profile in its bucket
51
- * and each call names one. It is here because the shell commands a harness is
52
- * told to run afterwards do need it, and a `lanes link token show` without it
53
- * substitutes to nothing.
50
+ * Not part of the URL — one endpoint serves every profile in the workspace and
51
+ * each call names one in its `profile` argument. It is here because the shell
52
+ * commands a harness is told to run afterwards do need it, and a `lanes link
53
+ * token show` without it substitutes to nothing.
54
+ *
55
+ * There is no `profile` beside it any more (ADR-068). It was here for exactly
56
+ * one reader — the `export` line below — and what that line names is a token,
57
+ * which is the workspace's.
54
58
  */
55
- readonly profile: string;
56
59
  readonly target: string;
57
60
  }
58
61
 
@@ -142,14 +145,18 @@ export const HARNESSES: readonly Harness[] = [
142
145
  scoped: false,
143
146
  storesToken: false,
144
147
  home: CODEX_HOME,
145
- add: ({ name, url, tokenEnv }) => [
148
+ // The env var only under `--headless`, which is what the header comment
149
+ // above claims and this did not do: it was passed unconditionally, so every
150
+ // Codex registration was a token registration while `storesToken: false`
151
+ // said otherwise and `afterAdd` told the operator to export a variable an
152
+ // OAuth-capable client would never read.
153
+ add: ({ name, url, tokenEnv, token }) => [
146
154
  'mcp',
147
155
  'add',
148
156
  name,
149
157
  '--url',
150
158
  url,
151
- '--bearer-token-env-var',
152
- tokenEnv,
159
+ ...(token ? ['--bearer-token-env-var', tokenEnv] : []),
153
160
  ],
154
161
  get: (name) => ['mcp', 'get', name],
155
162
  remove: (name) => ['mcp', 'remove', name],
@@ -157,19 +164,26 @@ export const HARNESSES: readonly Harness[] = [
157
164
  // installs to both unchanged. Codex has no subagent directory, so it gets
158
165
  // the skill and not the scout — and `mcp add` says which it did.
159
166
  skills: () => join(CODEX_HOME(), 'skills'),
160
- afterAdd: ({ tokenEnv, profile, target }) => [
161
- `Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
162
- '',
163
- // Both flags, and this line is the reason they matter more here than
164
- // anywhere else: an unresolvable substitution yields the empty string, the
165
- // header becomes "Bearer ", and the only symptom is a 401 that reads as a
166
- // bad token rather than a command that refused.
167
- ` export ${tokenEnv}="$(lanes link token show --raw --profile ${profile} --workspace ${target})"`,
168
- '',
169
- 'Add that to your shell profile. This is the better half of the bargain: the token never',
170
- 'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
171
- 'with no re-registration.',
172
- ],
167
+ afterAdd: ({ tokenEnv, target, token }) =>
168
+ // Nothing to say unless a token is actually in play. Without `--headless`
169
+ // this registration is a bare URL like Claude Code's, and telling somebody
170
+ // to export a variable nothing reads was the instruction that made
171
+ // `storesToken: false` read as a contradiction.
172
+ token === undefined
173
+ ? []
174
+ : [
175
+ `Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
176
+ '',
177
+ // `--workspace`, and this line is the reason it matters more here
178
+ // than anywhere else: an unresolvable substitution yields the empty
179
+ // string, the header becomes "Bearer ", and the only symptom is a
180
+ // 401 that reads as a bad token rather than a command that refused.
181
+ ` export ${tokenEnv}="$(lanes link token show --raw --workspace ${target})"`,
182
+ '',
183
+ 'Add that to your shell profile. This is the better half of the bargain: the token never',
184
+ 'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
185
+ 'with no re-registration.',
186
+ ],
173
187
  },
174
188
  ];
175
189
 
@@ -1,6 +1,7 @@
1
+ import { anyIssuedToken } from '#profile';
1
2
  import { endpointUrl } from '../../endpoint-url.ts';
2
3
  import { fail, ok, print, style, warn } from '../../output.ts';
3
- import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
4
+ import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
4
5
  import { installFor } from './assets.ts';
5
6
  import { HARNESSES, type AddInput, type Harness } from './harnesses.ts';
6
7
 
@@ -45,6 +46,41 @@ export interface McpAddOptions extends GlobalFlags {
45
46
  readonly headless?: boolean | undefined;
46
47
  }
47
48
 
49
+ /**
50
+ * What to say about the token, which depends on whether one was actually stored.
51
+ *
52
+ * **`token`, not `harness.storesToken`.** The harness property says the client
53
+ * *can* hold a token; whether one was passed is a different question, and only
54
+ * the headless path passes one — the ordinary path registers the bare URL and
55
+ * lets the client discover the protected-resource document and run the
56
+ * authorization itself. Keyed on the property, this told an operator who had
57
+ * just registered against a deployed endpoint that "the token was stored" and
58
+ * that a rotate meant re-registering, when nothing had been stored and a rotate
59
+ * would not touch that entry at all.
60
+ *
61
+ * A separate function so the decision can be tested without spawning a client
62
+ * binary, which is the only reason the branch above it cannot be.
63
+ */
64
+ export function tokenNote(
65
+ harness: { readonly storesToken: boolean; readonly label: string },
66
+ token: string | undefined,
67
+ ): readonly string[] {
68
+ if (!harness.storesToken) return [];
69
+
70
+ if (token) {
71
+ return [
72
+ 'The token was stored as a value, not a command, so "lanes link token rotate"',
73
+ 'means running this again with --force.',
74
+ ];
75
+ }
76
+
77
+ return [
78
+ `No token was stored: ${harness.label} reads the endpoint's own`,
79
+ 'protected-resource document and signs you in. A "lanes link token rotate"',
80
+ 'does not affect this registration.',
81
+ ];
82
+ }
83
+
48
84
  export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
49
85
  // No harness named: every one that is actually installed. Registering with
50
86
  // whatever is present is what someone means by "add my mcp", and naming one
@@ -79,13 +115,32 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
79
115
  const scope = options.scope ?? 'user';
80
116
  const tokenEnv = options.tokenEnv ?? 'LANES_LINK_TOKEN';
81
117
 
82
- const runtime = await openRuntime(options);
118
+ // **The workspace, not a profile** (ADR-068). One endpoint serves every
119
+ // profile in the workspace and each call names one in its `profile` argument,
120
+ // so a registration was never per-profile: two different `--profile` values
121
+ // produced byte-identical harness commands. What kept the flag required was
122
+ // that the endpoint's token lived at `auth.token_ref` on a profile — a ref
123
+ // whose default was the same constant for all of them — and reading it meant
124
+ // resolving one. The token is the workspace's now, so there is nothing left
125
+ // to ask. `--profile` is still accepted, and narrows nothing here.
126
+ const runtime = await openWorkspaceRuntime(options);
83
127
 
84
128
  try {
85
- // Minted either way. The endpoint needs one to serve at all, and `outputs`
86
- // prints it; what `--headless` decides is whether it is written into
87
- // somebody's agent config.
88
- const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
129
+ // **Only for `--headless`, and only if one has been issued.** The ordinary
130
+ // path registers a bare URL and the client authorises itself (ADR-062).
131
+ // Nothing is minted here: a token names the person it was issued to, and
132
+ // `mcp add` does not know who — which is what `token issue` is for.
133
+ const token = options.headless === true
134
+ ? (await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials))?.value
135
+ : undefined;
136
+
137
+ if (options.headless === true && token === undefined) {
138
+ throw new Error(
139
+ 'No static token is issued in this workspace, so --headless has nothing to write.\n' +
140
+ ` Issue one: lanes link token issue --me --workspace ${runtime.target}\n` +
141
+ ' Without --headless the client signs in for itself and needs none.',
142
+ );
143
+ }
89
144
 
90
145
  // The target's own address, not the local one. This built
91
146
  // `http://<host>:<port>/mcp` unconditionally, so `mcp add --workspace cloud`
@@ -95,10 +150,9 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
95
150
  const input: AddInput = {
96
151
  name,
97
152
  url,
98
- ...(options.headless === true ? { token } : {}),
153
+ ...(token === undefined ? {} : { token }),
99
154
  tokenEnv,
100
155
  scope,
101
- profile: runtime.resolution.profile,
102
156
  target: runtime.resolution.target,
103
157
  };
104
158
 
@@ -174,14 +228,7 @@ async function register(
174
228
 
175
229
  if (!options.noSkill) await installFor(harness, input.scope, {});
176
230
 
177
- if (harness.storesToken) {
178
- print(
179
- style.dim(
180
- ' The token was stored as a value, not a command, so "lanes link token rotate"\n' +
181
- ' means running this again with --force.',
182
- ),
183
- );
184
- }
231
+ for (const line of tokenNote(harness, input.token)) print(style.dim(` ${line}`));
185
232
 
186
233
  const after = harness.afterAdd?.(input);
187
234
  if (after) {
@@ -48,7 +48,6 @@ export async function mcpStdio(
48
48
  reconciled: ({ profile, plan, ofMany }) =>
49
49
  printErr(`${ofMany ? `${profile}\n` : ''}${plan}`),
50
50
  // Unreachable: this path never mints a token, because it never needs one.
51
- tokenMinted: () => {},
52
51
  },
53
52
  log: {
54
53
  debug() {},
@@ -1,5 +1,5 @@
1
1
  import { formatPlan, planIsNoop, planReconcile } from '#registry';
2
- import type { ConnectionConfig, SelectedConnection } from '#profile';
2
+ import { readEndpointTokens, type ConnectionConfig, type SelectedConnection } from '#profile';
3
3
  import { DEFAULT_SURFACES } from '../../config-repair.ts';
4
4
  import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
5
5
  import { staleNudge } from '../../release.ts';
@@ -120,16 +120,27 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
120
120
  checks.push('config is valid');
121
121
  checks.push('state store is reachable');
122
122
 
123
- const token = await runtime.credentials.get(runtime.config.auth.token_ref);
124
- if (token) {
125
- checks.push(`profile token present (${runtime.config.auth.token_ref})`);
126
- } else {
127
- // Not a failure: `lanes link start` mints one. Reporting it as a problem would
128
- // make every fresh profile fail doctor for something that fixes itself.
123
+ // **A row whose value is missing, not a missing token** (ADR-068). Having
124
+ // issued none is the healthy default — a client signs in for itself — so
125
+ // reporting that as a problem would make every working workspace fail
126
+ // doctor. What is genuinely broken is a row pointing at nothing: it matches
127
+ // no credential, and from the client's side reads exactly like a wrong
128
+ // token. That is what a half-finished `secrets push` leaves behind.
129
+ const issued = await readEndpointTokens(runtime.resolution.workspaceRoot);
130
+ const orphaned: string[] = [];
131
+ for (const row of issued) {
132
+ if ((await runtime.credentials.get(row.ref)) === null) orphaned.push(row.id);
133
+ }
134
+
135
+ if (issued.length > 0 && orphaned.length === 0) {
136
+ checks.push(`${issued.length} endpoint token(s) present`);
137
+ }
138
+
139
+ for (const id of orphaned) {
129
140
  warnings.push({
130
- kind: 'no_profile_token',
131
- message: 'no profile token yet lanes link start will mint one, or run: lanes link token rotate',
132
- fix: forSelection('lanes link token rotate'),
141
+ kind: 'orphaned_endpoint_token',
142
+ message: `token "${id}" has a row but no value in this workspace's store it matches nothing`,
143
+ fix: `lanes link token rotate --id ${id} --workspace ${runtime.resolution.target}`,
133
144
  });
134
145
  }
135
146
 
@@ -1,8 +1,8 @@
1
- import { listProfiles } from '#profile';
1
+ import { anyIssuedToken, listProfiles } from '#profile';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
4
- import { announce, heading, print, style, warn } from '../../output.ts';
5
- import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
4
+ import { announceWorkspace, heading, print, style, warn } from '../../output.ts';
5
+ import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
6
6
 
7
7
  export interface OutputsFlags extends GlobalFlags {
8
8
  readonly show?: boolean | undefined;
@@ -23,18 +23,27 @@ export interface OutputsFlags extends GlobalFlags {
23
23
  * the harness's business.
24
24
  */
25
25
  export async function outputs(flags: OutputsFlags): Promise<void> {
26
- const runtime = await openRuntime(flags);
26
+ // The workspace, matching what this command's own subject has always been
27
+ // (ADR-068). It asked for a profile only to find the endpoint's token, and
28
+ // that token is the workspace's.
29
+ const runtime = await openWorkspaceRuntime(flags);
27
30
 
28
31
  try {
29
- const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
32
+ // Whatever has been issued, or nothing — which is the ordinary state.
33
+ // Nothing is minted: `outputs` reports, and a token names a person.
34
+ const held = await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials);
35
+ const token = held?.value;
30
36
  const declared = runtime.declared.deploy;
31
37
  const deployed = await deployedUrl(declared);
32
38
  // Not `endpointUrl`, which would ask the platform a second time for an
33
39
  // answer this line already has.
34
40
  const url = deployed ?? localUrl(runtime.config);
35
41
 
42
+ // Unauthenticated when nothing is issued. `/health` answers either way; what
43
+ // it withholds without a credential is the profile list, so `mine` below is
44
+ // then decided by the endpoint answering at all.
36
45
  const live = await endpointHealth(url, token);
37
- const mine = live?.profile === runtime.resolution.profile;
46
+ const mine = live !== null;
38
47
 
39
48
  // Live if it is up, otherwise every profile in this target's workspace.
40
49
  // Those are the same set now: a profile lives in exactly one target
@@ -51,9 +60,8 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
51
60
  running: mine,
52
61
  deployed: deployed !== null,
53
62
  target: runtime.target,
54
- primary: runtime.resolution.profile,
55
63
  profiles,
56
- ...(flags.show ? { token } : {}),
64
+ ...(flags.show && token !== undefined ? { token } : {}),
57
65
  },
58
66
  null,
59
67
  2,
@@ -62,7 +70,7 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
62
70
  return;
63
71
  }
64
72
 
65
- announce(runtime.resolution);
73
+ announceWorkspace(runtime.resolution);
66
74
 
67
75
  heading('Endpoint');
68
76
  print(
@@ -75,21 +83,19 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
75
83
  print(style.dim(` ${declared.platform} service "${declared.service}" for target "${runtime.target}".`));
76
84
  }
77
85
 
78
- if (live && !mine) {
79
- // Two workspaces can assign the same port. Saying so beats reporting an
80
- // endpoint as up when it belongs to something else entirely.
81
- print(warn(`something else is serving this port: profile "${live.profile}"`));
82
- }
83
-
84
- heading(`Profiles reachable through it (${profiles.length})`);
85
- for (const profile of profiles) {
86
- print(` ${profile}${profile === runtime.resolution.profile ? style.dim(' (endpoint owner)') : ''}`);
87
- }
88
- print(style.dim(' Each call names one, in its `profile` argument.'));
86
+ heading(`Profiles served by it (${profiles.length})`);
87
+ for (const profile of profiles) print(` ${profile}`);
88
+ print(
89
+ style.dim(
90
+ ' Each call names one, in its `profile` argument. Which of these a client\n' +
91
+ ' actually reaches is decided by who signs in — every profile whose members\n' +
92
+ ' list them, and no others.',
93
+ ),
94
+ );
89
95
 
90
- if (flags.show) {
96
+ if (flags.show && token !== undefined) {
91
97
  heading('Token');
92
- print(` ${token}`);
98
+ print(` ${token} ${style.dim(`(${held?.id})`)}`);
93
99
  }
94
100
 
95
101
  heading('Register with your agent');
@@ -100,36 +106,59 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
100
106
  );
101
107
  print('');
102
108
 
103
- const invocation = await tokenInvocation(
104
- token,
105
- runtime.resolution.profile,
106
- runtime.resolution.target,
107
- );
108
-
109
- print(
110
- ` claude mcp add --transport http lanes-link ${url} \\\n` +
111
- ` --header "Authorization: Bearer $(${invocation.command})"`,
112
- );
109
+ // **The bare URL, and no header** (ADR-062). This printed the
110
+ // `Authorization: Bearer $(…)` form unconditionally, which was the shape
111
+ // before every endpoint ran the authorization flow — a registration that
112
+ // carries a credential, bypasses consent and expiry, and leaves a long-lived
113
+ // token in a harness config. The client discovers
114
+ // `/.well-known/oauth-protected-resource` from the 401 and signs its owner
115
+ // in instead.
116
+ print(` claude mcp add --transport http lanes-link ${url}`);
113
117
  print('');
114
-
115
- if (!invocation.onPath) {
116
- // The failure this prevents is nasty: an unresolvable command substitutes
117
- // to the empty string, the header becomes "Bearer ", and the only symptom
118
- // is a 401 that looks like a bad token rather than a missing binary.
119
- print(warn('lanes is not on your PATH, so the short form would substitute to nothing.'));
120
- print(style.dim(' The command above uses this checkout instead. To shorten it permanently:'));
121
- print(` cd ${process.cwd()} && bun link`);
122
- print('');
123
- }
124
-
125
118
  print(
126
119
  style.dim(
127
- ' One registration covers every profile above. The $(…) keeps the token out of your\n' +
128
- ' agent\'s context and out of the transcript but note it is resolved once, when you\n' +
129
- ' run the command, and stored as a literal. After "lanes link token rotate" you have to\n' +
130
- ' register again. Other harnesses take the same two facts URL and bearer token.',
120
+ ' No credential goes into that command. The client reads this endpoint\'s\n' +
121
+ ' protected-resource document, sends its owner to sign in, and comes back\n' +
122
+ ' holding a token of its own so a config file synced to a dotfiles repo\n' +
123
+ ' is not a leak, and rotating a static token does not invalidate it.',
131
124
  ),
132
125
  );
126
+
127
+ heading('For a machine with no browser');
128
+ if (token === undefined) {
129
+ print(style.dim(' No static token is issued in this workspace.'));
130
+ print(
131
+ style.dim(
132
+ ` lanes link token issue --me --workspace ${runtime.target}\n` +
133
+ ' It reaches the profiles that list your subject as a member, and nothing else.',
134
+ ),
135
+ );
136
+ } else {
137
+ const invocation = await tokenInvocation(runtime.resolution.target);
138
+ print(
139
+ ` claude mcp add --transport http lanes-link ${url} \\\n` +
140
+ ` --header "Authorization: Bearer $(${invocation.command})"`,
141
+ );
142
+ print('');
143
+ if (!invocation.onPath) {
144
+ // The failure this prevents is nasty: an unresolvable command
145
+ // substitutes to the empty string, the header becomes "Bearer ", and
146
+ // the only symptom is a 401 that looks like a bad token rather than a
147
+ // missing binary.
148
+ print(warn('lanes is not on your PATH, so the short form would substitute to nothing.'));
149
+ print(style.dim(' The command above uses this checkout instead. To shorten it permanently:'));
150
+ print(` cd ${process.cwd()} && bun link`);
151
+ print('');
152
+ }
153
+ print(
154
+ style.dim(
155
+ ' CI only, and it is narrower than it looks: the token reaches the profiles\n' +
156
+ ' its subject is a member of. The $(…) keeps it out of your agent\'s context\n' +
157
+ ' and out of the transcript, but it resolves once and is stored as a literal —\n' +
158
+ ' so a rotate means registering again.',
159
+ ),
160
+ );
161
+ }
133
162
  } finally {
134
163
  await runtime.close();
135
164
  }
@@ -146,28 +175,32 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
146
175
  * checkout, which always works.
147
176
  */
148
177
  export async function tokenInvocation(
149
- expected: string,
150
- profile: string,
151
178
  target: string,
152
179
  ): Promise<{ command: string; onPath: boolean }> {
153
- // Both, always, and from the *resolved* selection rather than the flags. A
154
- // token is per-target, so `outputs --workspace cloud` printing a bare
155
- // `token show --raw` hands over the local one beside a deployed URL — a
156
- // credential that looks like an answer and fails as a wrong password. Naming
157
- // the profile as well makes the line pasteable into any shell rather than
158
- // only into one where the same default happens to resolve.
159
- const selection = ` --profile ${profile} --workspace ${target}`;
180
+ // `--workspace`, always, and from the *resolved* selection rather than the
181
+ // flags. A token is per-workspace, so `outputs --workspace cloud` printing a
182
+ // bare `token show --raw` hands over the local one beside a deployed URL — a
183
+ // credential that looks like an answer and fails as a wrong password.
184
+ //
185
+ // No `--profile` any more (ADR-068): `token show` refuses one, so printing it
186
+ // here would emit a line that cannot be pasted.
187
+ const selection = ` --workspace ${target}`;
160
188
  const short = `lanes link token show --raw${selection}`;
161
- const argv = ['link', 'token', 'show', '--raw', '--profile', profile, '--workspace', target];
189
+ const argv = ['link', 'token', 'show', '--raw', '--workspace', target];
162
190
 
163
191
  const resolved = Bun.which('lanes');
164
192
  if (resolved) {
165
193
  try {
166
194
  const result = Bun.spawnSync([resolved, ...argv]);
167
- // Compared against the token rather than merely checking it exited zero:
168
- // a `lanes` on PATH could belong to a different workspace entirely,
169
- // and would hand the harness a token this endpoint rejects.
170
- if (result.success && new TextDecoder().decode(result.stdout).trim() === expected) {
195
+ // Exit status and a plausible token, rather than a comparison against a
196
+ // known value. This used to be handed the expected token and check for
197
+ // equality, which caught a `lanes` on PATH belonging to a different
198
+ // workspace. It cannot now: with several rows issued, `token show`
199
+ // refuses without `--id` — so demanding one value back would reject a
200
+ // correctly-installed binary. What survives is the check that matters for
201
+ // the failure this function exists to prevent, which is a substitution
202
+ // that yields nothing at all.
203
+ if (result.success && new TextDecoder().decode(result.stdout).trim().startsWith('llk_')) {
171
204
  return { command: short, onPath: true };
172
205
  }
173
206
  } catch {
@@ -61,7 +61,6 @@ export async function start(
61
61
  flags: resolved,
62
62
  port: flags.port,
63
63
  only: flags.only,
64
- mintToken: true,
65
64
  // Stderr, not stdout: `--json` and `--raw` callers parse the other stream.
66
65
  // A refused credential is the event worth seeing while this runs in the
67
66
  // foreground, and until now nothing printed it.
@@ -72,9 +71,6 @@ export async function start(
72
71
  print(plan);
73
72
  print(ok(`reconciled ${ofMany ? profile : ''}`.trim()));
74
73
  },
75
- tokenMinted({ target }) {
76
- print(warn(`minted a token — run: lanes link outputs --show --workspace ${target}`));
77
- },
78
74
  },
79
75
  });
80
76