@lanes-sh/link 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,324 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ ConfigError,
4
+ PAIR_CERT_REF,
5
+ PAIR_KEY_REF,
6
+ PAIR_TOKEN_REF,
7
+ loadWorkspaceProfiles,
8
+ openTarget,
9
+ type LoadedProfile,
10
+ resolveWorkspaceRoot,
11
+ } from '#profile';
12
+ import type { ResolvedTarget } from '#profile';
13
+ import { deployedUrl } from '../../endpoint-url.ts';
14
+ import { ensureCertificate } from './pair-certificate.ts';
15
+ import { recordConfigChange } from '../../audit-change.ts';
16
+ import { ok, print, style } from '../../output.ts';
17
+ import type { SecretStore } from '#secrets';
18
+ import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
19
+
20
+ /**
21
+ * `lanes link pair` — let the Lanes dashboard read this machine (ADR-063).
22
+ *
23
+ * Three things, and each is the operator's to decline. It installs a locally
24
+ * trusted certificate, mints a credential that reads the whole workspace, and
25
+ * hands the browser a link carrying it. None of that happens without being
26
+ * asked for, and none of it is implied by `start`.
27
+ *
28
+ * **The certificate is the largest side effect any command in this CLI has.**
29
+ * It is a persistent change to the machine's trust store, made by a CLI, and
30
+ * ADR-053's precedent applies unchanged: offer it, name what it is, and stop if
31
+ * declined. A run with nobody at the terminal is refused rather than assumed.
32
+ *
33
+ * **The token travels in a fragment.** `#pair=` is never sent to a server, so a
34
+ * credential for a surface whose entire point is that Lanes cannot see it does
35
+ * not end up in a Lanes access log, a proxy, or a referrer header.
36
+ *
37
+ * **A deployed workspace pairs too, and skips all of that** (ADR-064). The
38
+ * certificate was the whole of the old refusal — installing one for an address
39
+ * this machine does not answer on is meaningless — and it is the one piece a
40
+ * deployed endpoint does not need, because the platform terminates TLS with a
41
+ * certificate a browser already trusts. What remains is a token and an address.
42
+ *
43
+ * **It names a workspace, not a profile**, because that is what it pairs. The
44
+ * surface it opens lists every connection and every profile the workspace holds,
45
+ * and the credential it mints reads all of them — so asking which profile was
46
+ * asking a question with no answer, and implying a per-profile pairing that does
47
+ * not exist. `--profile` is still accepted, and picks the port when profiles
48
+ * disagree about one.
49
+ */
50
+
51
+ /**
52
+ * Where the three pieces live in the credential store.
53
+ *
54
+ * Declared in `#profile` rather than here, because three components read these
55
+ * names now and only one of them is the CLI — the server opens the read surface
56
+ * with them and a deploy binds the token so the revision may read it. Importing
57
+ * a *command* module for a string constant pulled the CLI's output and prompt
58
+ * handling into the container's runtime graph. Re-exported because a year of
59
+ * callers spell them from here.
60
+ */
61
+ export { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF };
62
+
63
+ /** Where the dashboard lives, overridable so `lanes dev` can pair against it. */
64
+ const DASHBOARD_URL = process.env['LANES_WEB_URL'] ?? 'https://lanes.sh';
65
+
66
+ export interface PairFlags extends GlobalFlags {
67
+ /** Print the link for an existing pairing and change nothing. */
68
+ readonly print?: boolean | undefined;
69
+ /** Mint a fresh token, invalidating whatever a browser already holds. */
70
+ readonly rotate?: boolean | undefined;
71
+ /** Do not ask before installing mkcert. */
72
+ readonly yes?: boolean | undefined;
73
+ }
74
+
75
+ export interface PairDeps {
76
+ readonly which?: (binary: string) => string | null;
77
+ readonly run?: (command: readonly string[]) => Promise<string | null>;
78
+ readonly confirm?: (question: string) => Promise<boolean>;
79
+ readonly interactive?: boolean;
80
+ }
81
+
82
+ export async function pair(flags: PairFlags, deps: PairDeps = {}): Promise<void> {
83
+ const target = flags.target!;
84
+ const root = resolveWorkspaceRoot();
85
+ const resolved = await openTarget(root, target);
86
+ const { loaded: profiles } = await loadWorkspaceProfiles(resolved.workspaceRoot);
87
+
88
+ if (profiles.length === 0) {
89
+ throw new ConfigError(
90
+ `Workspace "${target}" holds no profiles, so there is no endpoint to pair.\n` +
91
+ ` Create one with: lanes link profile add <name> --workspace ${target}`,
92
+ );
93
+ }
94
+
95
+ // One endpoint serves every profile in a workspace, so they normally agree on
96
+ // a port and the choice is not a choice. Where they do not, the ambiguity is
97
+ // real and `--profile` is how it is settled — refused rather than guessed,
98
+ // because pairing the wrong port produces a dashboard that says "not
99
+ // connected" with everything working.
100
+ const named = flags.profile
101
+ ? profiles.find((one: LoadedProfile) => one.profile === flags.profile)
102
+ : undefined;
103
+
104
+ if (flags.profile && !named) {
105
+ throw new ConfigError(
106
+ `Workspace "${target}" has no profile "${flags.profile}".\n` +
107
+ ` It holds: ${profiles.map((one: LoadedProfile) => one.profile).join(', ')}`,
108
+ );
109
+ }
110
+
111
+ const ports = new Set(profiles.map((one: LoadedProfile) => one.config.instance.port));
112
+ if (!named && ports.size > 1) {
113
+ throw new ConfigError(
114
+ `The profiles in "${target}" do not agree on a port, so it is not clear which endpoint to pair.\n` +
115
+ profiles
116
+ .map((one: LoadedProfile) => ` ${one.profile} ${one.config.instance.port}`)
117
+ .join('\n') +
118
+ '\n Name one: lanes link pair --profile <name>',
119
+ );
120
+ }
121
+
122
+ const chosen = named ?? profiles[0]!;
123
+ const host = chosen.config.instance.host;
124
+ const credentials = await openSecretStoreFor(root, target);
125
+
126
+ // A workspace that declares a deployment is paired over the address the
127
+ // platform gave it, not over loopback — which is what `declared.deploy`
128
+ // answers and what `instance.host` does not: a deployed revision takes its
129
+ // host from the container's environment, so a profile bound to `127.0.0.1`
130
+ // in config is still serving `0.0.0.0` on Cloud Run.
131
+ if (resolved.declared.deploy) {
132
+ await pairDeployed({ flags, target, chosen, root, credentials, deploy: resolved.declared.deploy });
133
+ return;
134
+ }
135
+
136
+ if (!isLoopback(host)) {
137
+ // Not deployed, and not on this machine either. There is no certificate
138
+ // this command could install for an address this machine does not answer
139
+ // on, and no platform URL to hand the browser instead.
140
+ throw new ConfigError(
141
+ `"${target}" is bound to ${host}, which is neither loopback nor a deployment.\n` +
142
+ ' Pairing reaches an endpoint on *this* machine, or one `lanes link deploy` put\n' +
143
+ ' somewhere with an address of its own.',
144
+ );
145
+ }
146
+
147
+ const readPort = chosen.config.instance.port + 1;
148
+
149
+ // `127.0.0.1` rather than `instance.host`, even though `localhost` and `::1`
150
+ // are equally loopback and equally covered by the certificate. It is one
151
+ // address for one machine, and the browser has to agree with `open.ts` about
152
+ // which spelling it is: two pairings of the same endpoint under two names
153
+ // would be two entries in the switcher, both working, neither wrong.
154
+ const address = `https://127.0.0.1:${readPort}`;
155
+
156
+ if (flags.print === true) {
157
+ const existing = await credentials.get(PAIR_TOKEN_REF);
158
+ if (existing === null) throw new ConfigError('Not paired yet. Run: lanes link pair');
159
+ print(pairingLink(existing, address));
160
+ return;
161
+ }
162
+
163
+ const certificate = await ensureCertificate(credentials, flags, deps);
164
+
165
+ const rotating = flags.rotate === true;
166
+ const existing = rotating ? null : await credentials.get(PAIR_TOKEN_REF);
167
+ const token = existing ?? `llp_${randomBytes(32).toString('base64url')}`;
168
+ if (existing === null) await credentials.set(PAIR_TOKEN_REF, token);
169
+
170
+ // The token itself never goes in, obviously. What is worth recording is that
171
+ // a credential reading the whole workspace now exists, and when — and for a
172
+ // rotation, that whatever a browser was holding stopped working at that
173
+ // moment.
174
+ if (existing === null) {
175
+ await recordConfigChange(chosen.config, root, target, {
176
+ capability: rotating ? 'config.pair.rotate' : 'config.pair.mint',
177
+ scope: target,
178
+ arguments: { readPort, certificate },
179
+ });
180
+ }
181
+
182
+ print(ok(certificate === 'reused' ? 'certificate already installed' : 'certificate installed'));
183
+ if (rotating) {
184
+ print(style.dim(' The previous pairing link no longer works. Re-open the new one.'));
185
+ }
186
+ print('');
187
+ print(ok(`the dashboard may now read ${style.bold(address)}`));
188
+ print('');
189
+ print(pairingLink(token, address));
190
+ print('');
191
+ print(
192
+ style.dim(
193
+ ' Open that in a browser on this machine. The token is in the URL fragment,\n' +
194
+ ' so it never reaches a Lanes server.\n' +
195
+ ' It reads every connection, profile and audit entry in this workspace, and\n' +
196
+ ' can change nothing. Take it back with: lanes link pair --rotate\n' +
197
+ '\n' +
198
+ ` The endpoint has to be running: lanes link start --workspace ${target}`,
199
+ ),
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Pairing a workspace that lives somewhere with an address of its own (ADR-064).
205
+ *
206
+ * The half of `pair` that is *not* shared with loopback is the certificate, and
207
+ * that was always the whole of the old refusal: installing one for an address
208
+ * this machine does not answer on is meaningless, and it is still meaningless.
209
+ * What was never the thing being refused is the credential and the address —
210
+ * the endpoint terminates TLS with a certificate a browser already trusts, so
211
+ * the two pieces that remain are a token and a URL.
212
+ *
213
+ * So there is no `mkcert` here, nothing is installed, and nothing is asked. The
214
+ * command writes one secret and prints a link.
215
+ */
216
+ async function pairDeployed(input: {
217
+ flags: PairFlags;
218
+ target: string;
219
+ chosen: LoadedProfile;
220
+ root: string;
221
+ credentials: SecretStore;
222
+ deploy: NonNullable<ResolvedTarget['declared']['deploy']>;
223
+ }): Promise<void> {
224
+ const { flags, target, chosen, root, credentials } = input;
225
+
226
+ // `deployedUrl` asks the platform where the service ended up and degrades to
227
+ // null for every reason that is not this command's business — no driver, not
228
+ // deployed yet, no credentials for the project. A link with no address in it
229
+ // reads nothing, so this refuses rather than printing half of one.
230
+ const mcpUrl = await deployedUrl(input.deploy);
231
+ if (mcpUrl === null) {
232
+ throw new ConfigError(
233
+ `Could not find the address of "${target}".\n` +
234
+ ' The service may not be deployed yet, or the platform CLI may not be signed in.\n' +
235
+ ` Check it with: lanes link outputs --workspace ${target}`,
236
+ );
237
+ }
238
+
239
+ // The read surface answers on the endpoint's own origin, beside `/mcp` rather
240
+ // than on a port of its own — Cloud Run routes exactly one.
241
+ const endpoint = mcpUrl.replace(/\/mcp$/, '');
242
+
243
+ if (flags.print === true) {
244
+ const existing = await credentials.get(PAIR_TOKEN_REF);
245
+ if (existing === null || existing === '') {
246
+ throw new ConfigError(`Not paired yet. Run: lanes link pair --workspace ${target}`);
247
+ }
248
+ print(pairingLink(existing, endpoint));
249
+ return;
250
+ }
251
+
252
+ const rotating = flags.rotate === true;
253
+ const held = rotating ? null : await credentials.get(PAIR_TOKEN_REF);
254
+
255
+ // An empty string, not just a missing ref: `lanes link deploy` creates this
256
+ // secret with no version so the revision's IAM binding has something to
257
+ // attach to, and a secret that exists with no version reads back as null
258
+ // here and as `unpaired` there. Either shape means nobody has paired yet.
259
+ const existing = held === '' ? null : held;
260
+ const token = existing ?? `llp_${randomBytes(32).toString('base64url')}`;
261
+ if (existing === null) await credentials.set(PAIR_TOKEN_REF, token);
262
+
263
+ if (existing === null) {
264
+ await recordConfigChange(chosen.config, root, target, {
265
+ capability: rotating ? 'config.pair.rotate' : 'config.pair.mint',
266
+ scope: target,
267
+ arguments: { endpoint },
268
+ });
269
+ }
270
+
271
+ print(ok('no certificate needed — this endpoint already has one a browser trusts'));
272
+ if (rotating) {
273
+ print(style.dim(' The previous pairing link no longer works. Re-open the new one.'));
274
+ }
275
+ print('');
276
+ print(ok(`the dashboard may now read ${style.bold(endpoint)}`));
277
+ print('');
278
+ print(pairingLink(token, endpoint));
279
+ print('');
280
+ print(
281
+ style.dim(
282
+ ' Open that in any browser, on any machine. The token is in the URL fragment,\n' +
283
+ ' so it never reaches a Lanes server.\n' +
284
+ ' It reads every connection, profile and audit entry in this workspace, and\n' +
285
+ ' can change nothing. Take it back with:\n' +
286
+ ` lanes link pair --workspace ${target} --rotate\n` +
287
+ '\n' +
288
+ ' A rotation takes up to five seconds to be refused, because the endpoint\n' +
289
+ ' caches what it read rather than calling Secret Manager per request.',
290
+ ),
291
+ );
292
+ }
293
+
294
+ /**
295
+ * The link the browser opens.
296
+ *
297
+ * The token rides in the fragment, which is never sent to a server — so a
298
+ * credential for a surface whose entire point is that Lanes cannot see this
299
+ * data does not land in a Lanes access log, a proxy, or a referrer header. The
300
+ * address rides beside it for the same reason and one more: it is the only
301
+ * thing telling the page which of several paired endpoints this link is for,
302
+ * and a query parameter would put a workspace's public address in that log.
303
+ *
304
+ * **A loopback link carries its address too**, and the parameter is required so
305
+ * that it cannot quietly stop. It used to be omitted here on the reasoning that
306
+ * loopback is derivable — and it is not: the read listener sits one port above
307
+ * whatever `instance.port` says, so an endpoint on any port but the default
308
+ * printed a link the dashboard then read at `7338`, reported unreachable, and
309
+ * gave no way to correct. The page still treats a link with no `at=` as
310
+ * loopback on the default port, because every link minted before this is that
311
+ * shape.
312
+ *
313
+ * Exported for `pair.test.ts` and for nothing else. The whole of the defect
314
+ * above was a shape nothing asserted on, in a command whose output no test
315
+ * reads, so the fix is not worth much without something that fails when the
316
+ * address goes missing again.
317
+ */
318
+ export function pairingLink(token: string, endpoint: string): string {
319
+ return `${DASHBOARD_URL}/dashboard/link#pair=${token}&at=${encodeURIComponent(endpoint)}`;
320
+ }
321
+
322
+ function isLoopback(host: string): boolean {
323
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
324
+ }
@@ -1,6 +1,7 @@
1
1
  import { loadConfigFile } from '#profile';
2
+ import { recordConfigChange } from '../../audit-change.ts';
2
3
  import { ConfigDocument } from '../../config-edit.ts';
3
- import { announce, announceProfile, heading, ok, print, style, table } from '../../output.ts';
4
+ import { announce, announceProfile, heading, ok, print, style, table, warn } from '../../output.ts';
4
5
  import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
5
6
  import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
6
7
 
@@ -15,56 +16,106 @@ export async function policyList(flags: GlobalFlags): Promise<void> {
15
16
  const { selection, config } = await resolveProfileOnly(flags);
16
17
  announceProfile(selection);
17
18
 
18
- if (config.policy.allow.length === 0 && config.policy.deny.length === 0) {
19
- print(style.dim('No rules. Default deny is in effect: nothing is reachable.'));
19
+ if (config.grants.length === 0) {
20
+ print(style.dim('No grants. Default deny is in effect: nothing is reachable.'));
20
21
  return;
21
22
  }
22
23
 
23
24
  const expiry = (rule: { capability: string; expires_at?: string | undefined }) =>
24
25
  rule.expires_at ? style.dim(`until ${rule.expires_at}`) : '';
25
26
 
26
- if (config.policy.allow.length > 0) {
27
- heading('Allow');
28
- table(config.policy.allow.map((rule) => [` ${style.green('+')}`, rule.capability, expiry(rule)]));
29
- }
27
+ // Grouped by connection rather than by effect, which reverses how this used to
28
+ // read. A rule governs one account now (ADR-058), so "what may be done with
29
+ // the work mailbox" is the question the listing should answer in one place —
30
+ // and the old shape, two lists of capabilities with the account nowhere in
31
+ // them, could not answer it at all.
32
+ for (const grant of config.grants) {
33
+ heading(grant.connection);
34
+
35
+ if (grant.allow.length === 0 && grant.deny.length === 0) {
36
+ print(style.dim(' Granted nothing. The connection is reachable and no capability is.'));
37
+ continue;
38
+ }
30
39
 
31
- if (config.policy.deny.length > 0) {
32
- heading('Deny');
33
- print(style.dim(' A deny beats any allow, whatever the order in the file.'));
34
- table(config.policy.deny.map((rule) => [` ${style.red('-')}`, rule.capability, expiry(rule)]));
40
+ table([
41
+ ...grant.allow.map((rule) => [` ${style.green('+')}`, rule.capability, expiry(rule)]),
42
+ ...grant.deny.map((rule) => [` ${style.red('-')}`, rule.capability, expiry(rule)]),
43
+ ]);
35
44
  }
36
45
 
37
46
  print('');
47
+ print(style.dim('A deny beats any allow on the same connection, whatever the order in the file.'));
38
48
  print(
39
- style.dim('Rules cover every account of a provider. For different grants, use a second profile.'),
49
+ style.dim('A connection with no row above is not reachable at all, and is never advertised.'),
40
50
  );
41
51
  }
42
52
 
53
+ export interface PolicyFlags extends GlobalFlags {
54
+ readonly connection?: string | undefined;
55
+ }
56
+
43
57
  export async function policyRule(
44
58
  effect: 'allow' | 'deny',
45
59
  capability: string,
46
- flags: GlobalFlags,
60
+ flags: PolicyFlags,
47
61
  ): Promise<void> {
48
62
  const { resolution, config, target } = await resolveProfile(flags);
49
- const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
50
63
 
51
- if (config.policy[effect].some((rule) => rule.capability === capability)) {
52
- print(style.dim(`${effect} ${capability} is already declared.`));
64
+ // Required, and refused rather than guessed. A rule has to land in a row, and
65
+ // with two mailboxes granted there is no answer to "which one" that is not a
66
+ // guess about which account the operator meant to widen.
67
+ const key = flags.connection;
68
+ if (key === undefined) {
69
+ throw new Error(
70
+ `--connection is required. A rule governs one connection (ADR-058).\n` +
71
+ (config.grants.length > 0
72
+ ? ` This profile grants: ${config.grants.map((grant) => grant.connection).join(', ')}`
73
+ : ` This profile grants nothing yet. Run: lanes link status --profile ${resolution.profile}`),
74
+ );
75
+ }
76
+
77
+ const index = config.grants.findIndex((grant) => grant.connection === key);
78
+ if (index === -1) {
79
+ throw new Error(
80
+ `Profile "${resolution.profile}" does not grant "${key}".\n` +
81
+ ` Grant it first, then narrow it:\n` +
82
+ ` lanes link grant ${key} --profile ${resolution.profile}`,
83
+ );
84
+ }
85
+
86
+ const grant = config.grants[index]!;
87
+ if (grant[effect].some((rule) => rule.capability === capability)) {
88
+ print(style.dim(`${effect} ${capability} on ${key} is already declared.`));
53
89
  return;
54
90
  }
55
91
 
92
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
93
+
56
94
  // A bare string, because that is what the file should read like. The object
57
95
  // form exists for `expires_at` and is not worth writing by default.
58
- document.addTo(['policy', effect], capability, { inline: true });
59
- // Validation runs before the write, so a rule naming a provider with no
60
- // connection fails here rather than silently granting nothing at runtime.
96
+ document.addTo(['grants', index, effect], capability, { inline: true });
97
+ // Validation runs before the write, so a rule naming a provider other than the
98
+ // row's own fails here rather than matching nothing at runtime.
61
99
  await document.save();
62
100
 
101
+ await recordConfigChange(
102
+ config,
103
+ resolution.workspaceRoot,
104
+ target,
105
+ {
106
+ capability: effect === 'allow' ? 'config.policy.allow' : 'config.policy.deny',
107
+ scope: resolution.profile,
108
+ connection: key,
109
+ arguments: { capability },
110
+ },
111
+ (note) => print(warn(note)),
112
+ );
113
+
63
114
  announce(resolution);
64
- print(ok(`${effect} ${style.bold(capability)}`));
115
+ print(ok(`${effect} ${style.bold(capability)} on ${style.bold(key)}`));
65
116
 
66
- if (effect === 'deny' && config.policy.allow.some((rule) => rule.capability === '*')) {
67
- print(style.dim(' This narrows the catch-all allow; a deny always wins.'));
117
+ if (effect === 'deny' && grant.allow.some((rule) => rule.capability === '*')) {
118
+ print(style.dim(" This narrows that row's catch-all allow; a deny always wins."));
68
119
  }
69
120
 
70
121
  // A deny the endpoint has not heard about is still granting what it names, so
@@ -1,9 +1,11 @@
1
+ import { readSession } from '#auth/lanes/session.ts';
2
+ import { ConfigError } from '#profile';
1
3
  import { startEndpoint } from '#server/endpoint.ts';
2
4
  import { streamLogger } from '#server/logging.ts';
3
- import { repairOwnerLayer } from '../../config-repair.ts';
5
+ import { repairOwnerLayer } from '../../config-repair-sweep.ts';
4
6
  import { announce, ok, print, style, warn } from '../../output.ts';
5
7
  import { staleNudge } from '../../release.ts';
6
- import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
8
+ import { primaryProfile, resolveProfile, type GlobalFlags } from '../../runtime.ts';
7
9
 
8
10
  /** `lanes link start` — reconcile, then serve every profile on one endpoint. */
9
11
 
@@ -14,9 +16,24 @@ export async function start(
14
16
  only?: boolean | undefined;
15
17
  },
16
18
  ): Promise<void> {
17
- const { resolution } = await resolveProfile(flags);
19
+ // One endpoint serves every profile in the workspace, so the workspace is the
20
+ // subject and a profile is not required. What `--profile` picks is the
21
+ // *primary*: whose token opens the endpoint and whose port it binds (ADR-009).
22
+ // `--only` is the flag that narrows what is served, and it has nothing to
23
+ // narrow unless a profile was named.
24
+ if (flags.only === true && flags.profile === undefined) {
25
+ throw new ConfigError(
26
+ '--only serves one profile, so it needs to be told which.\n' +
27
+ ' Add --profile <name>, or drop --only to serve every profile in the workspace.',
28
+ );
29
+ }
30
+
31
+ const resolved = { ...flags, profile: await primaryProfile(flags) };
32
+ const { resolution } = await resolveProfile(resolved);
18
33
  announce(resolution);
19
34
 
35
+ await requireSignIn();
36
+
20
37
  // Before the bootstrap, and only here.
21
38
  //
22
39
  // This is the one command an existing install runs without being told to, so
@@ -41,7 +58,7 @@ export async function start(
41
58
  // entrypoint. What stays here is what a terminal wants: the plan, printed as
42
59
  // it is applied, and the endpoint at the end.
43
60
  const endpoint = await startEndpoint({
44
- flags,
61
+ flags: resolved,
45
62
  port: flags.port,
46
63
  only: flags.only,
47
64
  mintToken: true,
@@ -56,7 +73,7 @@ export async function start(
56
73
  print(ok(`reconciled ${ofMany ? profile : ''}`.trim()));
57
74
  },
58
75
  tokenMinted({ target }) {
59
- print(warn(`minted a token — run: lanes link outputs --show --target ${target}`));
76
+ print(warn(`minted a token — run: lanes link outputs --show --workspace ${target}`));
60
77
  },
61
78
  },
62
79
  });
@@ -81,3 +98,34 @@ export async function start(
81
98
 
82
99
  await new Promise(() => {});
83
100
  }
101
+
102
+ /**
103
+ * Refuse to serve without a Lanes session.
104
+ *
105
+ * A local endpoint requires this as much as a deployed one does, which is the
106
+ * uncomfortable half of ADR-060 and worth stating plainly: **a self-hostable
107
+ * tool now needs an account to start.** The alternative was worse. A profile
108
+ * declares who may consume it, and there is no way to check that against
109
+ * anybody if the endpoint has no idea who is asking — so "local is exempt"
110
+ * means local profiles cannot express delegation at all, and the model would be
111
+ * two models.
112
+ *
113
+ * What is *not* required is the network per request. A machine offline for a
114
+ * day keeps serving; the session is needed to sign in and to refresh, and
115
+ * `lanes auth status` says how long that has left to run.
116
+ *
117
+ * The CI token is the exception and stays one (ADR-009): a headless runner has
118
+ * no browser, presents `llk_…`, and reaches the whole workspace.
119
+ */
120
+ async function requireSignIn(): Promise<void> {
121
+ if ((await readSession()) !== null) return;
122
+
123
+ throw new ConfigError(
124
+ 'Not signed in, so there is nobody for this endpoint to serve.\n' +
125
+ ' Run: lanes auth login\n' +
126
+ '\n' +
127
+ ' A profile reaches you only where its members list your subject, which is why\n' +
128
+ ' this is required for a local endpoint too. See:\n' +
129
+ ' lanes link profile members add --me --profile <name>',
130
+ );
131
+ }
@@ -1,3 +1,4 @@
1
+ import { wasDefaulted } from '../../selection-require.ts';
1
2
  import {
2
3
  isPointer,
3
4
  listProfiles,
@@ -11,7 +12,7 @@ import {
11
12
  import { oneProfile, visibleCapabilities } from '#server/mcp';
12
13
  import { toPolicyDocument } from '#registry';
13
14
  import { announce, emit, heading, print, style, table } from '../../output.ts';
14
- import { openRuntime, ownerPrincipal, type GlobalFlags } from '../../runtime.ts';
15
+ import { grantedConnections, openRuntime, ownerPrincipal, type GlobalFlags } from '../../runtime.ts';
15
16
  import { deploymentIdentity } from '../../endpoint-url.ts';
16
17
 
17
18
  /** `lanes link status` — connections, what is reachable through them, and where. */
@@ -29,7 +30,7 @@ export interface StatusFlags extends GlobalFlags {
29
30
  *
30
31
  * Which is why a deployed target prints its *identity* rather than its address.
31
32
  * This used to print `http://<host>:<port>/mcp` unconditionally, so
32
- * `status --target cloud` named a loopback port with nothing behind it — the
33
+ * `status --workspace cloud` named a loopback port with nothing behind it — the
33
34
  * bug `endpoint-url.ts` records having already fixed in `mcp add`. Reaching for
34
35
  * `endpointUrl` here would fix the lie by surrendering the property above: it
35
36
  * shells out to `gcloud`, costs seconds, needs the CLI installed and
@@ -48,7 +49,7 @@ export async function status(flags: StatusFlags): Promise<void> {
48
49
  const records = await runtime.state.connections.list();
49
50
  const byKey = new Map(records.map((record) => [`${record.provider}.${record.id}`, record]));
50
51
 
51
- const connections = runtime.config.connections.map((connection) => {
52
+ const connections = grantedConnections(runtime).map((connection) => {
52
53
  const key = `${connection.provider}.${connection.id}`;
53
54
  return {
54
55
  key,
@@ -142,7 +143,7 @@ export async function status(flags: StatusFlags): Promise<void> {
142
143
  print(
143
144
  style.dim(
144
145
  ' the address is the platform\'s to assign — run: ' +
145
- `lanes link outputs --target ${runtime.target}`,
146
+ `lanes link outputs --workspace ${runtime.target}`,
146
147
  ),
147
148
  );
148
149
  }
@@ -197,12 +198,12 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
197
198
  unreachable = error instanceof Error ? error.message : String(error);
198
199
  }
199
200
 
200
- const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.workspace : localRoot);
201
+ const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.at : localRoot);
201
202
  const workspace = resolved ? await loadWorkspaceProfiles(root) : undefined;
202
203
 
203
204
  const profiles = (workspace?.loaded ?? []).map((loaded) => ({
204
205
  name: loaded.profile,
205
- connections: loaded.config.connections.length,
206
+ grants: loaded.config.grants.length,
206
207
  }));
207
208
 
208
209
  const deployment = deploymentIdentity(resolved?.declared.deploy);
@@ -220,7 +221,14 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
220
221
  unreadable: workspace?.unreadable ?? [],
221
222
  },
222
223
  () => {
223
- print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
224
+ // The same shape `announce` prints, including whether the workspace was
225
+ // typed or defaulted — a command that reported it differently would make
226
+ // ADR-061's echo a thing an operator has to learn twice.
227
+ print(
228
+ style.dim(
229
+ `workspace ${style.bold(target)}${wasDefaulted(target) ? ' (default)' : ''} ${root}`,
230
+ ),
231
+ );
224
232
 
225
233
  if (unreachable !== undefined) {
226
234
  heading('Unreachable');
@@ -234,12 +242,12 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
234
242
  heading('Profiles');
235
243
  if (profiles.length === 0) {
236
244
  print(style.dim(' None yet.'));
237
- print(style.dim(` Create one with: lanes link profile add <name> --target ${target}`));
245
+ print(style.dim(` Create one with: lanes link profile add <name> --workspace ${target}`));
238
246
  } else {
239
247
  table(
240
248
  profiles.map((profile) => [
241
249
  ` ${style.bold(profile.name)}`,
242
- style.dim(`${profile.connections} connection(s)`),
250
+ style.dim(`${profile.grants} grant(s)`),
243
251
  ]),
244
252
  );
245
253
  }
@@ -257,7 +265,7 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
257
265
  print(
258
266
  style.dim(
259
267
  " the address is the platform's to assign — run: " +
260
- `lanes link outputs --target ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
268
+ `lanes link outputs --workspace ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
261
269
  ),
262
270
  );
263
271
  },
@@ -50,7 +50,7 @@ export async function tools(flags: ToolsFlags): Promise<void> {
50
50
  {
51
51
  url,
52
52
  target: runtime.target,
53
- // Both, because `--target cloud` reaching loopback is indistinguishable
53
+ // Both, because `--workspace cloud` reaching loopback is indistinguishable
54
54
  // from success without them.
55
55
  deployed: deployed !== null,
56
56
  answering: mine,
@@ -297,7 +297,7 @@ export function parse(text: string): Record<string, unknown> {
297
297
  * `capabilityIdForToolName` answers it exactly against a set of known ids, and
298
298
  * falls back to that first-underscore split when it recognises none — which is
299
299
  * reachable here, because the registry is the *invoking profile's* while the
300
- * endpoint may serve several, and under `--target cloud` may run an image this
300
+ * endpoint may serve several, and under `--workspace cloud` may run an image this
301
301
  * checkout does not have. So the fallback is detected rather than trusted: a
302
302
  * name that resolves to nothing known is grouped as unattributed, because a
303
303
  * guessed heading with a confident count is worse than an honest "these did not