@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
@@ -18,7 +18,7 @@ import {
18
18
  planReconcile,
19
19
  toPolicyDocument,
20
20
  } from '#registry';
21
- import { ensureProfileToken, openRuntime, type GlobalFlags, type Runtime } from '#cli/runtime.ts';
21
+ import { openRuntime, type GlobalFlags, type Runtime } from '#cli/runtime.ts';
22
22
 
23
23
  /**
24
24
  * Bringing the endpoint up: open a runtime per profile, reconcile, and serve.
@@ -40,8 +40,6 @@ import { ensureProfileToken, openRuntime, type GlobalFlags, type Runtime } from
40
40
  export interface EndpointReporter {
41
41
  /** A profile whose runtime state differed from its config, and what was applied. */
42
42
  reconciled(input: { profile: string; plan: string; ofMany: boolean }): void;
43
- /** No profile token existed and one was minted. */
44
- tokenMinted(minted: { target: string }): void;
45
43
  /**
46
44
  * A sibling profile that could not be opened against this target.
47
45
  *
@@ -51,7 +49,7 @@ export interface EndpointReporter {
51
49
  skipped?(input: { profile: string; reason: string }): void;
52
50
  }
53
51
 
54
- const SILENT: EndpointReporter = { reconciled() {}, tokenMinted() {} };
52
+ const SILENT: EndpointReporter = { reconciled() {} };
55
53
 
56
54
  /** The first line of an error, which is the part fit to print beside a name. */
57
55
  function message(error: unknown): string {
@@ -64,11 +62,6 @@ export interface EndpointOptions {
64
62
  readonly host?: string | undefined;
65
63
  /** Serve just the resolved profile, rather than every profile in the workspace. */
66
64
  readonly only?: boolean | undefined;
67
- /**
68
- * Mint a profile token when the store has none. True for `lanes link start`,
69
- * false in a container — see the note above.
70
- */
71
- readonly mintToken?: boolean | undefined;
72
65
  readonly reporter?: EndpointReporter | undefined;
73
66
  /** Operational events. Silent when absent, which is what the tests want. */
74
67
  readonly log?: Logger | undefined;
@@ -203,26 +196,21 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
203
196
  const { primary, runtimes } = await openReconciled(options);
204
197
 
205
198
  try {
206
- if (options.mintToken) {
207
- const { created } = await ensureProfileToken(
208
- primary.credentials,
209
- primary.config.auth.token_ref,
210
- );
211
- if (created) reporter.tokenMinted({ target: primary.target });
212
- } else {
213
- // Deployed, the token is written by the operator's CLI into the target's
214
- // credential store and read back here. Refusing to start beats serving an
215
- // endpoint whose token nobody holds.
216
- const token = await primary.credentials.get(primary.config.auth.token_ref);
217
- if (!token) {
218
- throw new Error(
219
- `No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
220
- 'A deployed instance never mints its own run `lanes link token rotate --workspace <name>` ' +
221
- 'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
222
- );
223
- }
224
- }
225
-
199
+ // **No token is minted or required here any more** (ADR-068). Both halves of
200
+ // what used to be at this point are gone, and for the same reason.
201
+ //
202
+ // Minting: `start` did it because there was one token per endpoint and it
203
+ // had to exist for anything to connect. A token names the person it was
204
+ // issued to now, and `start` does not know who is about to connect — so
205
+ // inventing one would be binding a credential to a subject nobody chose.
206
+ //
207
+ // Refusing: a deployed revision used to fail to boot without one, on the
208
+ // reasoning that an endpoint whose token nobody holds is no use. Since
209
+ // ADR-062 that is backwards. A client discovers the protected-resource
210
+ // document, signs its owner in, and comes back with a token of its own; a
211
+ // static row is what CI uses because it has no browser. Zero rows is the
212
+ // ordinary state of a healthy endpoint, and refusing to serve was refusing
213
+ // the case the endpoint is now built for.
226
214
  // Read through a holder rather than closed over `runtimes`, because a
227
215
  // reload replaces that map and the gate is deliberately built once
228
216
  // (ADR-029). Without the indirection, a member added after start would
@@ -53,6 +53,16 @@ function harnessConnections(config: Config): ConnectionConfig[] {
53
53
 
54
54
  export const TEST_TOKEN = 'llk_test_token_value';
55
55
 
56
+ /**
57
+ * The subject the harness's one token row is issued to.
58
+ *
59
+ * A real subject shape rather than a placeholder, because `subjectRef` validates
60
+ * it wherever a config carries one — and the harness's `profilesFor` returns
61
+ * every wired profile for it, which is what the static token used to reach by
62
+ * having no list at all.
63
+ */
64
+ export const HARNESS_SUBJECT = 'lanes:harness0000';
65
+
56
66
  /** A signed-in person no profile lists. See the federation stub below. */
57
67
  export const STRANGER = 'NOBODY_LISTS_THIS_PERSON';
58
68
 
@@ -75,7 +85,7 @@ export function configFor(profile: string, port: number, policy: string): Config
75
85
  ` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
76
86
 
77
87
  return parseConfig(`
78
- contract: 4
88
+ contract: 5
79
89
  instance:
80
90
  profile: ${profile}
81
91
  port: ${port}
@@ -133,6 +143,15 @@ export interface HarnessOptions {
133
143
  config?: Config;
134
144
  /** Extra profiles this one endpoint also serves, each with its own policy. */
135
145
  alsoServe?: ReadonlyArray<{ profile: string; policy: string }>;
146
+ /**
147
+ * The profiles the static token's subject is a member of.
148
+ *
149
+ * Absent means every profile wired, which is what a workspace owner's token
150
+ * reaches. Naming fewer is how a test expresses a delegated member — the
151
+ * caller ADR-068 exists for, and the one every disclosure surface has to be
152
+ * checked against.
153
+ */
154
+ reaches?: readonly string[];
136
155
  /**
137
156
  * What the endpoint calls to re-read skills before serving a request.
138
157
  *
@@ -192,7 +211,9 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
192
211
 
193
212
  const state = createMemoryState();
194
213
  const audit = createBlobAuditStore({ storage: createMemoryBlobStore() });
195
- const credentials = createMemoryCredentials({ 'profile/token': token, ...options.credentials });
214
+ // `tokens/tok1`, matching the row `startHarness` declares below. The old key
215
+ // was `profile/token`, the constant ADR-068 removed.
216
+ const credentials = createMemoryCredentials({ 'tokens/tok1': token, ...options.credentials });
196
217
  // `allowReserved` for the same reason `buildRegistry` passes it: the owner
197
218
  // layer claims `memory`, `skills`, and `vault`, and the guard still refuses
198
219
  // them everywhere else.
@@ -267,10 +288,14 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
267
288
  export function startHarness(options: HarnessOptions): Harness {
268
289
  const { profiles, state, audit, dispatcher, credentials, token } = wireProfiles(options);
269
290
 
291
+ // One row, bound to a subject the harness's profiles list, so the static
292
+ // token resolves the way it does in production (ADR-068) rather than through
293
+ // a special case that would stop the tests covering the real path.
270
294
  const bearer = new BearerAuthenticator({
271
295
  profile: options.profile,
272
- tokenRef: 'profile/token',
296
+ tokens: async () => [{ id: 'tok1', subject: HARNESS_SUBJECT, ref: 'tokens/tok1' }],
273
297
  credentials,
298
+ profilesFor: async () => options.reaches ?? [...profiles.keys()],
274
299
  });
275
300
 
276
301
  // The real wiring from `endpoint.ts`, not a stand-in: the flow under test is
@@ -1,4 +1,4 @@
1
- import type { Authenticator } from '#auth';
1
+ import { mayReach, type Authenticator } from '#auth';
2
2
  import type { Logger } from '#connectivity';
3
3
  import { capabilityIdForToolName } from '#server/mcp';
4
4
  import { ATTACHMENTS_PATH, handleAttachments } from './attachments.ts';
@@ -186,18 +186,18 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
186
186
  if (url.pathname === HEALTH_PATH) {
187
187
  // `status` is unauthenticated because the platform's own probe reads it
188
188
  // and a deploy waits on it. The profile *names* are not: on a public URL
189
- // that is a list of what this endpoint holds, handed to anyone who asks,
190
- // and `outputs` and `mcp add` which are the reason it was ever
191
- // published both hold the token already.
189
+ // that is a list of what this endpoint holds, handed to anyone who asks.
190
+ // And they are the *caller's* since ADR-068: this listed every profile
191
+ // served to anybody holding a credential, so a delegated member read the
192
+ // ones `mayReach` keeps out of their own enum.
192
193
  const named = await options.authenticator.authenticate(
193
194
  request.headers.get('authorization'),
194
195
  );
195
-
196
+ const who = named.ok ? named.principal : null;
197
+ const mine = options.generations.current.names().filter((n) => who && mayReach(who, n));
196
198
  return Response.json({
197
199
  status: 'ok',
198
- ...(named.ok
199
- ? { profile: options.primary, profiles: options.generations.current.names() }
200
- : {}),
200
+ ...(named.ok ? { profile: options.primary, profiles: mine } : {}),
201
201
  });
202
202
  }
203
203
 
@@ -185,7 +185,15 @@ export function visibleToolCount(options: BuildServerOptions): number {
185
185
  * entities: ordering is not selection, and a caller that cannot tell two
186
186
  * candidates apart must be given what tells them apart.
187
187
  *
188
- * The enum stays bare ids, because the id is what the caller passes.
188
+ * **The key is the grant ref, not the bare id.** `connectionsOf` fills the enum
189
+ * from the grant rows (ADR-058), so `reachable` carries `gmail.con1`, and
190
+ * `accountsByProfile` keys on that same `ref` — one string from one source, so
191
+ * the two sides cannot drift. Keyed on `connection.id` instead it missed every
192
+ * lookup, and no served description carried an account at all, while
193
+ * `connection-choice.test.ts` passed bare ids in by hand and stayed green.
194
+ * Contract 4 is what turned that from cosmetic into a real loss: the ids used to
195
+ * carry the account and are `con1`, `con2` now, so the id says nothing about
196
+ * which mailbox it is and this annotation is the only thing that does.
189
197
  */
190
198
  export function describeWithConnections(
191
199
  description: string,
@@ -215,9 +223,9 @@ export function accountsByProfile(
215
223
 
216
224
  for (const [name, runtime] of options.profiles) {
217
225
  const rows = new Map<string, string>();
218
- for (const { connection } of runtime.connections ?? []) {
226
+ for (const { ref, connection } of runtime.connections ?? []) {
219
227
  rows.set(
220
- connection.id,
228
+ ref,
221
229
  connection.label === undefined
222
230
  ? connection.account
223
231
  : `${connection.account} (${connection.label})`,
@@ -40,6 +40,10 @@ export function deployedReadDeps(input: {
40
40
  audit: primary.audit,
41
41
  connections: async () =>
42
42
  (await readConnections(primary.resolution.workspaceRoot)).connections,
43
+ // The names the dashboard shows for a row nobody has labelled. From the
44
+ // registry rather than a catalogue, so the owner layer, the vendors and a
45
+ // workspace's own manifests are all named the same way.
46
+ providerName: (id) => primary.registry.manifest(id)?.name,
43
47
  // Cached, unlike loopback's. `GcpSecretManagerStore` holds nothing between
44
48
  // calls, so a dashboard polling `/state` would be one network round trip per
45
49
  // poll and a stranger sending a wrong token one per request — which is the
@@ -62,6 +62,10 @@ export async function openReadListener(
62
62
  audit: primary.audit,
63
63
  connections: async () =>
64
64
  (await readConnections(primary.resolution.workspaceRoot)).connections,
65
+ // The names the dashboard shows for a row nobody has labelled. From the
66
+ // registry rather than a catalogue, so the owner layer, the vendors and a
67
+ // workspace's own manifests are all named the same way.
68
+ providerName: (id) => primary.registry.manifest(id)?.name,
65
69
  // Read on every presentation, so `pair --rotate` takes effect on a
66
70
  // running endpoint. Affordable here because the store is a local file;
67
71
  // the deployed bind caches for exactly this reason. `refresh()` drops the
@@ -1,7 +1,12 @@
1
1
  import type { Logger } from '#connectivity';
2
2
  import type { ProfileRuntime } from '../mcp/visibility.ts';
3
3
  import type { PairingCredential } from './credential.ts';
4
- import { readState, type ConnectionRow, type ReadEndpoint } from './state.ts';
4
+ import {
5
+ readState,
6
+ type ConnectionRow,
7
+ type ProviderNames,
8
+ type ReadEndpoint,
9
+ } from './state.ts';
5
10
 
6
11
  /**
7
12
  * The two routes a browser origin may read, and everything that guards them.
@@ -100,6 +105,14 @@ export interface ReadDeps {
100
105
  */
101
106
  readonly connections: () => Promise<readonly ConnectionRow[]>;
102
107
  readonly credential: PairingCredential;
108
+ /**
109
+ * What each provider is called, for the row nobody has labelled.
110
+ *
111
+ * Optional so a harness can omit it: absent, an unlabelled row reports a null
112
+ * label and its reader falls back to the id, which is what every reader did
113
+ * before. Both real binds pass their runtime's registry.
114
+ */
115
+ readonly providerName?: ProviderNames | undefined;
103
116
  /** What this endpoint says about itself. Fixed for the life of the bind. */
104
117
  readonly endpoint: ReadEndpoint;
105
118
  readonly allowedOrigins?: readonly string[] | undefined;
@@ -166,7 +179,7 @@ export async function readRoutes(request: Request, deps: ReadDeps): Promise<Resp
166
179
  if (url.pathname === STATE_PATH) {
167
180
  const rows = await deps.connections().catch(() => []);
168
181
  return json(
169
- readState(deps.workspace, deps.profiles(), rows, deps.endpoint),
182
+ readState(deps.workspace, deps.profiles(), rows, deps.endpoint, deps.providerName),
170
183
  200,
171
184
  cors(origin, allowed),
172
185
  );
@@ -1,4 +1,5 @@
1
1
  import { allowedConnections } from '#policy';
2
+ import { defaultConnectionLabel } from '#profile';
2
3
  import type { ProfileRuntime } from '../mcp/visibility.ts';
3
4
 
4
5
  /**
@@ -19,11 +20,20 @@ export interface ReadConnection {
19
20
  readonly provider: string;
20
21
  readonly id: string;
21
22
  /**
22
- * The operator's own word for it, and what a reader should be shown.
23
+ * What a reader should be shown: the operator's own word for it, or the name
24
+ * derived from the provider and the account when they never gave one.
23
25
  *
24
26
  * `gmail.ada_lovelace` is an address, not a name. A dashboard listing refs is
25
27
  * asking somebody to read identifiers when they gave the thing a label
26
28
  * precisely so they would not have to.
29
+ *
30
+ * **Filled rather than left null**, which reverses what this said. Null meant
31
+ * "they never gave one" and every consumer answered it the same way, by
32
+ * showing the id — so a dashboard's whole Label column read `con8`, `lan7`,
33
+ * `con5`. `connect` writes no label that only repeats the lines above it
34
+ * (`declareConnection`), so an unlabelled row is the ordinary case and not an
35
+ * omission worth reporting. `null` survives for the row nothing can name: a
36
+ * grant pointing at a connection the workspace no longer holds.
27
37
  */
28
38
  readonly label: string | null;
29
39
  /** The identity the provider reported at connect time. */
@@ -32,6 +42,20 @@ export interface ReadConnection {
32
42
  readonly profiles: readonly string[];
33
43
  }
34
44
 
45
+ /**
46
+ * What a provider is called, asked of whoever built the registry.
47
+ *
48
+ * A function rather than a catalogue, because `server` may not import
49
+ * `#providers` — the read surface has no business knowing the vendor list, and
50
+ * the rule that says so is `architecture.test.ts`. Both binds already hold a
51
+ * `Runtime`, whose registry names the owner layer, the catalogue and a
52
+ * workspace's own manifests alike, so the caller passes a closure over that.
53
+ *
54
+ * Defaulted to naming nothing. A caller that supplies none gets `label: null`
55
+ * on an unlabelled row, which is what every reader saw before this existed.
56
+ */
57
+ export type ProviderNames = (provider: string) => string | undefined;
58
+
35
59
  /** The connection rows as `connections.yaml` holds them. */
36
60
  export interface ConnectionRow {
37
61
  readonly provider: string;
@@ -97,6 +121,7 @@ export function readState(
97
121
  profiles: ReadonlyMap<string, ProfileRuntime>,
98
122
  rows: readonly ConnectionRow[],
99
123
  endpoint: ReadEndpoint,
124
+ providerName: ProviderNames = () => undefined,
100
125
  ): ReadState {
101
126
  // The workspace's own list is the source of truth for *which connections
102
127
  // exist*. Deriving it from the grants instead made an account that no profile
@@ -138,11 +163,14 @@ export function readState(
138
163
 
139
164
  const connections: ReadConnection[] = rows.map((row) => {
140
165
  const ref = `${row.provider}.${row.id}`;
166
+ const named = providerName(row.provider);
141
167
  return {
142
168
  ref,
143
169
  provider: row.provider,
144
170
  id: row.id,
145
- label: row.label ?? null,
171
+ // The same rule `connect` offers and `connection list` prints, so one
172
+ // connection is called one thing wherever somebody reads it.
173
+ label: row.label ?? (named ? defaultConnectionLabel(named, row.account) : null),
146
174
  account: row.account ?? null,
147
175
  profiles: grantedBy.get(ref) ?? [],
148
176
  };