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