@lanes-sh/link 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/instructions/agents/lanes-link-scout.md +3 -3
- package/instructions/skills/lanes-link/SKILL.md +95 -57
- 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 +63 -16
- 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/adapters/audit-blob.ts +22 -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
|
|
|
@@ -38,10 +38,10 @@ then a narrower fragment, before concluding something is not there.
|
|
|
38
38
|
Tasks are worth the second look rather than an afterthought: an open one is often
|
|
39
39
|
the actual answer to "what do we already know about X", and it carries something
|
|
40
40
|
memory cannot — that the matter is unfinished. Say so when it is, and say which
|
|
41
|
-
status: *blocked since June* is a different answer from *open*. `
|
|
41
|
+
status: *blocked since June* is a different answer from *open*. `lanes_tasks_list`
|
|
42
42
|
hides finished work by default, so ask for `done` when the question is historical.
|
|
43
43
|
|
|
44
|
-
**Assets are a listing, not a corpus.** `
|
|
44
|
+
**Assets are a listing, not a corpus.** `lanes_assets_list` tells you a file exists,
|
|
45
45
|
its type and its size; only a text one reads back. Report that a document is
|
|
46
46
|
there and let the main thread decide what to do with it — do not try to get at
|
|
47
47
|
the contents of a binary, and never ask for it as base64.
|
|
@@ -67,7 +67,7 @@ to every future session.
|
|
|
67
67
|
stops a write is policy on the endpoint:
|
|
68
68
|
|
|
69
69
|
```console
|
|
70
|
-
$ lanes link policy deny
|
|
70
|
+
$ lanes link policy deny lanes_memory.write --connection lanes_memory.lan1 --profile <name> --workspace <name>
|
|
71
71
|
$ lanes link policy list --profile <name> --workspace <name>
|
|
72
72
|
```
|
|
73
73
|
|
|
@@ -43,6 +43,12 @@ workspace*. A profile lives in exactly one, so `personal` on `local` and
|
|
|
43
43
|
`personal` on `cloud` are two profiles that share a name rather than one profile
|
|
44
44
|
in two places.
|
|
45
45
|
|
|
46
|
+
**A profile owns its data.** Two profiles granting the same connection — every
|
|
47
|
+
profile grants `lanes_memory.lan1` — still read and write different notes,
|
|
48
|
+
tasks, files and entities, because the profile is part of where they are kept.
|
|
49
|
+
So there is nothing to be found in one by asking another, and a note taken under
|
|
50
|
+
`work` is not available under `personal`.
|
|
51
|
+
|
|
46
52
|
**What a command must be told is never inferred from a profile — but the
|
|
47
53
|
workspace may have a default.** `lanes set-workspace <name>` writes one, every
|
|
48
54
|
command that uses it echoes the name it resolved, and the commands where being
|
|
@@ -74,12 +80,19 @@ disagree with it.
|
|
|
74
80
|
When you write a command out for the owner, fill in what that command needs or
|
|
75
81
|
leave it as `<name>` for them to complete — never drop a required one.
|
|
76
82
|
|
|
77
|
-
A `connection` names an account the profile grants
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
A `connection` names an account the profile grants, and it is a **fully
|
|
84
|
+
qualified** `<provider>.<id>` — `lanes_memory.lan1`, not `lan1`. The bare id is
|
|
85
|
+
refused, so take the value out of the enum rather than assembling one.
|
|
86
|
+
|
|
87
|
+
Ids are opaque: `con1`, `con2` for accounts and `lan1`, `lan2` for Lanes' own
|
|
88
|
+
surfaces. They carry no meaning and are not worth guessing at — the enum prints
|
|
89
|
+
each one's account and label beside it, and that is what tells `con1` from
|
|
90
|
+
`con2`. One profile may grant several of the same kind and govern each
|
|
91
|
+
differently, so `gmail.con1` may be readable where `gmail.con2` is writable.
|
|
92
|
+
|
|
93
|
+
Naming a connection the profile does not grant is refused rather than guessed
|
|
94
|
+
at, and one it does not grant is absent from the enum entirely: if you cannot
|
|
95
|
+
see it there, it was not withheld by accident.
|
|
83
96
|
|
|
84
97
|
## Which store a thing goes in
|
|
85
98
|
|
|
@@ -107,7 +120,7 @@ when Y", that is a skill they should write, not a memory entry describing it.
|
|
|
107
120
|
|
|
108
121
|
## Reach for memory before answering from nothing
|
|
109
122
|
|
|
110
|
-
`
|
|
123
|
+
`lanes_memory_search` before concluding you do not know something about this person or
|
|
111
124
|
their work. It is a substring search over their own notes, not a ranked index —
|
|
112
125
|
try more than one wording before deciding it is not there.
|
|
113
126
|
|
|
@@ -118,7 +131,7 @@ entries with `lanes link memory list --profile <name> --workspace <name>` and a
|
|
|
118
131
|
|
|
119
132
|
## Tasks have a status, so finish them rather than deleting them
|
|
120
133
|
|
|
121
|
-
`
|
|
134
|
+
`lanes_tasks_list` answers what is outstanding. It shows `in_progress`, `open` and
|
|
122
135
|
`blocked` and hides the rest, so a listing is what is left to do rather than
|
|
123
136
|
everything that ever was — name a status to see more.
|
|
124
137
|
|
|
@@ -133,7 +146,7 @@ Six of them, and the two that are easy to confuse are worth learning:
|
|
|
133
146
|
| `done` | finished |
|
|
134
147
|
| `dropped` | decided against, which is not the same as finished |
|
|
135
148
|
|
|
136
|
-
**Closing a task is `
|
|
149
|
+
**Closing a task is `lanes_tasks_update` with a status, never `lanes_tasks_remove`.** The
|
|
137
150
|
record of having done it is the useful half, and it is what stops the same thing
|
|
138
151
|
being suggested again next week. Remove is for something recorded by mistake.
|
|
139
152
|
|
|
@@ -333,9 +346,11 @@ changed, so a capability for a freshly connected account is not callable until
|
|
|
333
346
|
the client reconnects. Say that, rather than reporting the connection as missing
|
|
334
347
|
or asking them to connect again.
|
|
335
348
|
|
|
336
|
-
One exception
|
|
337
|
-
|
|
338
|
-
|
|
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.
|
|
339
354
|
|
|
340
355
|
## Operating the workspace
|
|
341
356
|
|
|
@@ -400,17 +415,35 @@ in the workspace and could be given it. Somebody with an unaccepted invitation
|
|
|
400
415
|
has no subject yet, so they are listed, marked, and refused; the answer there is
|
|
401
416
|
for them to accept, not for anyone to invent a subject.
|
|
402
417
|
|
|
403
|
-
Removing somebody does not end a session they already hold: membership
|
|
404
|
-
when
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
422
|
+
|
|
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.
|
|
407
429
|
|
|
408
|
-
**A client is not given a token any more.** `lanes link mcp add
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
writing a registration command for somebody, do not add
|
|
413
|
-
header: it is no longer how a client connects.
|
|
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.
|
|
414
447
|
|
|
415
448
|
## Deploying, and what it decides
|
|
416
449
|
|
|
@@ -424,14 +457,16 @@ resources that cost money, it implements no `--json` to inspect instead, and tha
|
|
|
424
457
|
plan is the only place the consequences are visible while they are still
|
|
425
458
|
avoidable.
|
|
426
459
|
|
|
427
|
-
|
|
460
|
+
One thing it refuses to guess, and it is the owner's to answer:
|
|
428
461
|
|
|
429
|
-
- **Whose bearer token opens the endpoint.** One token reaches every profile
|
|
430
|
-
behind that workspace, so this decides who gets in. With several candidates and
|
|
431
|
-
nothing recorded, it refuses and prints the command that names one.
|
|
432
462
|
- **A first deploy.** A workspace that does not exist yet has nothing to derive
|
|
433
463
|
a set from, so `--profile` is required there. It may be repeated, and the first
|
|
434
|
-
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.
|
|
435
470
|
|
|
436
471
|
**Never pass `--yes`, `--non-interactive`, `--access public` or
|
|
437
472
|
`--service-account` yourself.** Each settles a question about who can reach their
|
|
@@ -461,45 +496,47 @@ existed in two copies that could disagree, and there is one copy now.
|
|
|
461
496
|
|
|
462
497
|
## Registering it, and re-registering it
|
|
463
498
|
|
|
464
|
-
`lanes link mcp add --
|
|
465
|
-
this skill where that harness keeps them. With no argument
|
|
466
|
-
installed; name one (`claude`, `codex`) to be specific.
|
|
467
|
-
`lanes link token rotate --profile <name> --workspace <name>` — add `--force`, since Claude Code stores the token as
|
|
468
|
-
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.
|
|
469
502
|
|
|
470
|
-
**
|
|
471
|
-
|
|
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.
|
|
472
508
|
|
|
473
|
-
|
|
474
|
-
# RIGHT — the token goes from the CLI to the harness. You never see it.
|
|
475
|
-
claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \
|
|
476
|
-
--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:
|
|
477
510
|
|
|
478
|
-
|
|
479
|
-
|
|
511
|
+
```bash
|
|
512
|
+
claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp
|
|
480
513
|
```
|
|
481
514
|
|
|
482
|
-
The
|
|
483
|
-
|
|
484
|
-
|
|
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.
|
|
485
519
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
`lanes link outputs --profile <name> --workspace <name>` rather than writing it blind — that command checks whether
|
|
490
|
-
`lanes` resolves on this machine and prints a longer working form if it does
|
|
491
|
-
not, where guessing gives you an empty substitution, a `Bearer ` header, and a
|
|
492
|
-
401 that reads as a bad token.
|
|
493
|
-
|
|
494
|
-
If you register Codex, tell the user to export the token — Codex stores only the
|
|
495
|
-
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:
|
|
496
523
|
|
|
497
524
|
```bash
|
|
498
|
-
|
|
525
|
+
lanes link token issue --me --workspace <name>
|
|
526
|
+
export LANES_LINK_TOKEN="$(lanes link token show --raw --workspace <name>)"
|
|
499
527
|
```
|
|
500
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
|
+
|
|
501
538
|
One registration covers every profile. Do not add one per profile; they share a
|
|
502
|
-
URL
|
|
539
|
+
URL.
|
|
503
540
|
|
|
504
541
|
`lanes link mcp list` needs neither flag and reports whether the registration and
|
|
505
542
|
this document are current, out of date, or absent. That is the cheap first
|
|
@@ -508,8 +545,9 @@ copy means the rules you are reading are not the ones that shipped.
|
|
|
508
545
|
|
|
509
546
|
Claude Desktop cannot be handed a URL, so it spawns the endpoint over stdio
|
|
510
547
|
instead. That one is named in the client's own config file rather than registered
|
|
511
|
-
by a command, as `lanes link mcp stdio --
|
|
512
|
-
|
|
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.
|
|
513
551
|
|
|
514
552
|
## When it is not running
|
|
515
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
|
}),
|