@lanes-sh/link 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The long form of what this endpoint is, served as a resource.
|
|
5
|
+
*
|
|
6
|
+
* `initialize.instructions` has a budget it pays on every request forever, so it
|
|
7
|
+
* carries only the habits that must arrive everywhere. This is the document a
|
|
8
|
+
* client reads once when it wants the whole account, and it is served always,
|
|
9
|
+
* without a tool grant, because it describes the surface rather than exposing
|
|
10
|
+
* any of it.
|
|
11
|
+
*
|
|
12
|
+
* **Why a resource rather than a longer `instructions`.** A client caches
|
|
13
|
+
* `initialize` for the life of its registration, so guidance improved after
|
|
14
|
+
* somebody connected never reaches them. A resource is fetched when it is read,
|
|
15
|
+
* so this can be corrected without anybody reconnecting.
|
|
16
|
+
*
|
|
17
|
+
* **No vendor may be named here.** `src/architecture.test.ts` forbids it
|
|
18
|
+
* anywhere under `server/`, and rightly: what an owner has connected is theirs,
|
|
19
|
+
* and prose naming one provider would be wrong for everybody else.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const GUIDE_URI = 'lanes://instructions';
|
|
23
|
+
|
|
24
|
+
export const GUIDE_TITLE = 'How this endpoint works';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The document.
|
|
28
|
+
*
|
|
29
|
+
* Fixed prose, deliberately: it describes the model rather than this workspace,
|
|
30
|
+
* and the per-principal facts are in `initialize.instructions`, which is
|
|
31
|
+
* computed. Two documents with different lifetimes, and mixing them would make
|
|
32
|
+
* this one wrong the moment a connection was added.
|
|
33
|
+
*/
|
|
34
|
+
export function guideDocument(): string {
|
|
35
|
+
return `# Lanes Link
|
|
36
|
+
|
|
37
|
+
One endpoint in front of everything its owner has chosen to expose. It
|
|
38
|
+
authenticates, applies permissions per call, and records what happened.
|
|
39
|
+
|
|
40
|
+
## The three words
|
|
41
|
+
|
|
42
|
+
**Connection.** One authorised account or store, named \`<provider>.<id>\`. It
|
|
43
|
+
belongs to the workspace, so the same account can be reached from more than one
|
|
44
|
+
profile without being authorised twice.
|
|
45
|
+
|
|
46
|
+
**Profile.** A selection of connections, with the capabilities allowed on each,
|
|
47
|
+
and the people who may consume it. This is how someone keeps work and personal
|
|
48
|
+
apart, and how they hand you one mailbox read-only while another is writable.
|
|
49
|
+
|
|
50
|
+
**Workspace.** Where connections and profiles live, and which credential store
|
|
51
|
+
opens them. You never name one; the endpoint you are talking to is already in it.
|
|
52
|
+
|
|
53
|
+
## Routing
|
|
54
|
+
|
|
55
|
+
Every tool takes \`profile\` and \`connection\`. Both are enums, and both are
|
|
56
|
+
already narrowed to what you may reach, so anything offered is something you are
|
|
57
|
+
allowed to use.
|
|
58
|
+
|
|
59
|
+
**When it is ambiguous which profile is meant, ask.** Do not default to whichever
|
|
60
|
+
is listed first. A profile is a boundary somebody drew on purpose, and crossing
|
|
61
|
+
it is the failure this design exists to prevent.
|
|
62
|
+
|
|
63
|
+
A connection belongs to a profile. Naming one from a different profile is
|
|
64
|
+
refused, and the refusal lists what is available where you asked.
|
|
65
|
+
|
|
66
|
+
## The owner's own material
|
|
67
|
+
|
|
68
|
+
${RESERVED_PROVIDER_IDS.join(', ')} are not third-party services. They are the
|
|
69
|
+
owner's own stores, and each is a connection like any other, so a profile decides
|
|
70
|
+
which instances it can reach and what it may do with them.
|
|
71
|
+
|
|
72
|
+
**Memory and tasks are different stores.** Search memory before concluding you do
|
|
73
|
+
not know something about this person or their work. A thing to *do* is a task,
|
|
74
|
+
and it has a status; "remember to..." is a task, not a memory. Both are served
|
|
75
|
+
back to every later session, so write when asked rather than by habit.
|
|
76
|
+
|
|
77
|
+
**Skills are the owner's procedures**, surfaced as prompts rather than tools.
|
|
78
|
+
That is deliberate: a procedure is selected by the person, not chosen by the
|
|
79
|
+
model, and you cannot read one's body. If a task has a skill for it, say so and
|
|
80
|
+
let them invoke it rather than improvising your own version.
|
|
81
|
+
|
|
82
|
+
**Vault values are credentials.** Use one to do the thing that needs it. Do not
|
|
83
|
+
quote it back, summarise it, or write it anywhere.
|
|
84
|
+
|
|
85
|
+
**Entities are the people, companies and projects this owner deals with.** Before
|
|
86
|
+
using anyone's address or handle, look them up. A lookup returns every match and
|
|
87
|
+
never chooses: more than one means ask which is meant, not take the first.
|
|
88
|
+
|
|
89
|
+
## What is set up is answerable
|
|
90
|
+
|
|
91
|
+
Before saying something cannot be reached, or that an account must be added, ask
|
|
92
|
+
the setup surface. It reports what exists and what is missing, and it gives the
|
|
93
|
+
exact command for the missing thing.
|
|
94
|
+
|
|
95
|
+
**Running that command is the owner's to do, and inventing one is not.** Every
|
|
96
|
+
change to what exists here is a command a person runs; nothing on this surface
|
|
97
|
+
adds a connection, edits a profile, or changes who may consume it. That is not an
|
|
98
|
+
omission to work around.
|
|
99
|
+
|
|
100
|
+
## When a call is refused
|
|
101
|
+
|
|
102
|
+
A refusal is information, not an obstacle. Three kinds, and they mean different
|
|
103
|
+
things:
|
|
104
|
+
|
|
105
|
+
- **The profile is not available.** You are not a member of it. Nothing you can
|
|
106
|
+
do; its owner adds you.
|
|
107
|
+
- **The connection is not part of this profile.** Look at what is, in the enum.
|
|
108
|
+
- **The capability is denied.** The profile allows a narrower set than the tool
|
|
109
|
+
suggests. Say what was refused and let the owner decide whether to widen it.
|
|
110
|
+
|
|
111
|
+
Retrying a refusal unchanged produces the same refusal and one more line in the
|
|
112
|
+
owner's audit log.
|
|
113
|
+
|
|
114
|
+
## Files are named, not carried
|
|
115
|
+
|
|
116
|
+
Where a tool takes attachments, give a path, an HTTPS URL, or an attachment
|
|
117
|
+
already on another message. Do not base64 a file into an argument: it is recorded
|
|
118
|
+
in the audit log, and the log is something the owner reads.
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
@@ -53,7 +53,7 @@ import type { MergedCapability } from './visibility.ts';
|
|
|
53
53
|
*/
|
|
54
54
|
const OPENING = `This endpoint is one place to reach what its owner has chosen to expose. It
|
|
55
55
|
authenticates, applies permissions, and records what happened, so you do not
|
|
56
|
-
have to.`;
|
|
56
|
+
have to. Read \`lanes://instructions\` for the whole account of how it works.`;
|
|
57
57
|
|
|
58
58
|
const ROUTING = `**Routing.** Every tool takes \`profile\` and \`connection\`. A profile is how
|
|
59
59
|
someone separates work from personal — when it is ambiguous which one is meant,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { forProfile } from '#auth';
|
|
2
|
+
import { clientLabelFrom } from './client-info.ts';
|
|
1
3
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
4
|
import { z } from 'zod';
|
|
3
5
|
import { isPromptResult } from '#connectivity';
|
|
@@ -48,17 +50,19 @@ export function registerPrompt(
|
|
|
48
50
|
description: describeWithConnections(capability.description, entry.reachable),
|
|
49
51
|
argsSchema: z.object(shape),
|
|
50
52
|
},
|
|
51
|
-
async (args: Record<string, unknown
|
|
53
|
+
async (args: Record<string, unknown>, extra?: unknown) => {
|
|
52
54
|
const { profile, connection, ...rest } = args;
|
|
53
55
|
const scope = resolveScope(entry, profile, connection);
|
|
54
56
|
if ('error' in scope) throw new Error(scope.error);
|
|
55
57
|
|
|
58
|
+
const label = clientLabelFrom(extra) ?? options.clientLabel;
|
|
59
|
+
|
|
56
60
|
const outcome = await options.profiles.get(scope.profile)!.dispatcher.invoke({
|
|
57
|
-
principal: options.principal,
|
|
61
|
+
principal: forProfile(options.principal, scope.profile),
|
|
58
62
|
capabilityId: id,
|
|
59
63
|
connectionKey: scope.connectionKey,
|
|
60
64
|
arguments: rest,
|
|
61
|
-
clientLabel:
|
|
65
|
+
...(label ? { clientLabel: label } : {}),
|
|
62
66
|
});
|
|
63
67
|
|
|
64
68
|
// A prompt has no `isError` to carry a refusal in, so a denial is a
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { forProfile } from '#auth';
|
|
2
|
+
import { clientLabelFrom } from './client-info.ts';
|
|
1
3
|
import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/server';
|
|
2
4
|
import { isResourceListResult, isResourceResult } from '#connectivity';
|
|
3
5
|
import type { DispatchOutcome } from '#dispatch';
|
|
@@ -28,14 +30,20 @@ export function registerResource(
|
|
|
28
30
|
const scope = { profile, connectionId };
|
|
29
31
|
const scoped = scopeResourceUri(capability.uriTemplate, scope);
|
|
30
32
|
|
|
31
|
-
const dispatch = (
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const dispatch = (
|
|
34
|
+
args: Record<string, unknown>,
|
|
35
|
+
extra?: unknown,
|
|
36
|
+
): Promise<DispatchOutcome> => {
|
|
37
|
+
const label = clientLabelFrom(extra) ?? options.clientLabel;
|
|
38
|
+
|
|
39
|
+
return runtime.dispatcher.invoke({
|
|
40
|
+
principal: forProfile(options.principal, profile),
|
|
34
41
|
capabilityId: id,
|
|
35
42
|
connectionKey,
|
|
36
43
|
arguments: args,
|
|
37
|
-
clientLabel:
|
|
44
|
+
...(label ? { clientLabel: label } : {}),
|
|
38
45
|
});
|
|
46
|
+
};
|
|
39
47
|
|
|
40
48
|
const metadata = {
|
|
41
49
|
description: `${capability.description} (${profile}: ${connectionKey})`,
|
|
@@ -44,8 +52,8 @@ export function registerResource(
|
|
|
44
52
|
|
|
45
53
|
// `uri` is the whole argument — the provider recovers its own template
|
|
46
54
|
// variables from it and never sees the routing segments core prepended.
|
|
47
|
-
const read = async (uri: URL) => {
|
|
48
|
-
const outcome = await dispatch({ uri: uri.href });
|
|
55
|
+
const read = async (uri: URL, _variables?: unknown, extra?: unknown) => {
|
|
56
|
+
const outcome = await dispatch({ uri: uri.href }, extra);
|
|
49
57
|
if (!outcome.ok) throw new Error(outcome.message);
|
|
50
58
|
if (!isResourceResult(outcome.result)) {
|
|
51
59
|
throw new Error(`${id} did not return resource contents`);
|
|
@@ -83,8 +91,8 @@ export function registerResource(
|
|
|
83
91
|
// rather than an oversight. A provider omits `list` when its resource
|
|
84
92
|
// space is unbounded.
|
|
85
93
|
list: capability.list
|
|
86
|
-
? async () => {
|
|
87
|
-
const outcome = await dispatch({});
|
|
94
|
+
? async (extra?: unknown) => {
|
|
95
|
+
const outcome = await dispatch({}, extra);
|
|
88
96
|
if (!outcome.ok) throw new Error(outcome.message);
|
|
89
97
|
if (!isResourceListResult(outcome.result)) return { resources: [] };
|
|
90
98
|
|
package/src/server/mcp/tools.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { forProfile } from '#auth';
|
|
1
2
|
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { isToolResult } from '#connectivity';
|
|
5
|
+
import { clientLabelFrom } from './client-info.ts';
|
|
4
6
|
import { toolNameFor } from './naming.ts';
|
|
5
7
|
import { resourceLinkRouter } from './routing.ts';
|
|
6
8
|
import { sanitizeSchema } from './schema.ts';
|
|
@@ -111,9 +113,13 @@ export function registerLocalTool(
|
|
|
111
113
|
function makeHandler(capabilityId: string, entry: MergedCapability, options: BuildServerOptions) {
|
|
112
114
|
// `unknown` because the JSON-Schema overload types it that way; the schema
|
|
113
115
|
// has already validated the shape by the time this runs.
|
|
114
|
-
return async (args: unknown) => {
|
|
116
|
+
return async (args: unknown, extra?: unknown) => {
|
|
115
117
|
const { profile, connection, ...rest } = (args ?? {}) as Record<string, unknown>;
|
|
116
118
|
|
|
119
|
+
// The request first, then whatever the transport knew before the call —
|
|
120
|
+
// which on a stateless POST is nothing, and is why the request is asked.
|
|
121
|
+
const label = clientLabelFrom(extra) ?? options.clientLabel;
|
|
122
|
+
|
|
117
123
|
const name = String(profile);
|
|
118
124
|
const runtime = options.profiles.get(name);
|
|
119
125
|
const reachable = entry.reachable.get(name);
|
|
@@ -147,11 +153,11 @@ function makeHandler(capabilityId: string, entry: MergedCapability, options: Bui
|
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
const outcome = await runtime.dispatcher.invoke({
|
|
150
|
-
principal: options.principal,
|
|
156
|
+
principal: forProfile(options.principal, name),
|
|
151
157
|
capabilityId,
|
|
152
158
|
connectionKey: String(connection),
|
|
153
159
|
arguments: rest,
|
|
154
|
-
clientLabel:
|
|
160
|
+
...(label ? { clientLabel: label } : {}),
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
if (!outcome.ok) {
|
|
@@ -3,8 +3,9 @@ import type { Principal } from '#auth';
|
|
|
3
3
|
import type { Config } from '#profile';
|
|
4
4
|
import type { ProviderRegistry } from '#registry';
|
|
5
5
|
import type { Dispatcher } from '#dispatch';
|
|
6
|
-
import type { PolicyDocument } from '#policy';
|
|
6
|
+
import type { PolicyDocument, ProfilePolicy } from '#policy';
|
|
7
7
|
import { allowedConnections } from '#policy';
|
|
8
|
+
import { mayReach } from '#auth';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* What this principal can see, and therefore what gets registered at all.
|
|
@@ -25,7 +26,7 @@ export interface ProfileRuntime {
|
|
|
25
26
|
readonly config: Config;
|
|
26
27
|
readonly registry: ProviderRegistry;
|
|
27
28
|
readonly dispatcher: Dispatcher;
|
|
28
|
-
readonly policy:
|
|
29
|
+
readonly policy: ProfilePolicy;
|
|
29
30
|
readonly floor?: PolicyDocument | undefined;
|
|
30
31
|
/**
|
|
31
32
|
* Re-read the skills into `registry`, if they have changed on the store.
|
|
@@ -72,8 +73,16 @@ export function oneProfile(
|
|
|
72
73
|
return new Map([[name, runtime]]);
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The connections this profile can reach at all, before policy narrows further.
|
|
78
|
+
*
|
|
79
|
+
* The grant rows *are* the answer (ADR-058). A profile reaches what it grants
|
|
80
|
+
* and nothing else, so this needs no view of the workspace's connections — which
|
|
81
|
+
* is the useful half of decoupling them: what a profile can see is written in
|
|
82
|
+
* the profile, and cannot widen when somebody connects a new account.
|
|
83
|
+
*/
|
|
75
84
|
function connectionsOf(runtime: ProfileRuntime): string[] {
|
|
76
|
-
return runtime.config.
|
|
85
|
+
return runtime.config.grants.map((grant) => grant.connection);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/**
|
|
@@ -94,6 +103,12 @@ export function mergeCapabilities(options: BuildServerOptions): Map<string, Merg
|
|
|
94
103
|
const merged = new Map<string, MergedCapability>();
|
|
95
104
|
|
|
96
105
|
for (const [name, runtime] of options.profiles) {
|
|
106
|
+
// The same list the dispatcher enforces with. A member does not merely fail
|
|
107
|
+
// to call a profile they are not on — it is absent from the `profile` enum,
|
|
108
|
+
// so they never learn it exists (ADR-060). Discovery and enforcement share
|
|
109
|
+
// one answer here for the same reason they share `allowedConnections`.
|
|
110
|
+
if (!mayReach(options.principal, name)) continue;
|
|
111
|
+
|
|
97
112
|
const connections = connectionsOf(runtime);
|
|
98
113
|
|
|
99
114
|
for (const { id, capability, discovered } of runtime.registry.capabilities()) {
|
package/src/server/oauth.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
authorizationServerMetadata,
|
|
3
3
|
protectedResourceMetadata,
|
|
4
|
-
type AuthorizeRequest,
|
|
5
4
|
type OAuthResult,
|
|
6
5
|
type OAuthServer,
|
|
7
6
|
} from '#auth';
|
|
8
|
-
import {
|
|
7
|
+
import { noticePage } from '#cli/callback-page.ts';
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* The HTTP surface of the authorization flow.
|
|
@@ -24,6 +23,7 @@ export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
|
|
|
24
23
|
export const AUTHORIZATION_SERVER_PATH = '/.well-known/oauth-authorization-server';
|
|
25
24
|
const REGISTER_PATH = '/register';
|
|
26
25
|
const AUTHORIZE_PATH = '/authorize';
|
|
26
|
+
const CALLBACK_PATH = '/authorize/callback';
|
|
27
27
|
const TOKEN_PATH = '/token';
|
|
28
28
|
|
|
29
29
|
export interface AuthorizationSurface {
|
|
@@ -50,6 +50,7 @@ export function isAuthorizationPath(pathname: string): boolean {
|
|
|
50
50
|
pathname === AUTHORIZATION_SERVER_PATH ||
|
|
51
51
|
pathname === REGISTER_PATH ||
|
|
52
52
|
pathname === AUTHORIZE_PATH ||
|
|
53
|
+
pathname === CALLBACK_PATH ||
|
|
53
54
|
pathname === TOKEN_PATH
|
|
54
55
|
);
|
|
55
56
|
}
|
|
@@ -125,35 +126,37 @@ export async function handleAuthorization(
|
|
|
125
126
|
if (!server) return new Response('Not found', { status: 404 });
|
|
126
127
|
|
|
127
128
|
if (path === REGISTER_PATH && request.method === 'POST') {
|
|
128
|
-
return render(await server.register(await safeJson(request)), request
|
|
129
|
+
return render(await server.register(await safeJson(request)), request);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
132
|
+
// Who this endpoint is, from the point of view of *this* request. Derived
|
|
133
|
+
// from `Host` rather than config for the reason `publicOrigin` gives: a
|
|
134
|
+
// deployed instance's hostname is assigned at deploy time, and an assertion
|
|
135
|
+
// whose audience does not match exactly is refused.
|
|
136
|
+
const endpoint = {
|
|
137
|
+
resource: `${origin}${surface.mcpPath}`,
|
|
138
|
+
callbackUrl: `${origin}${CALLBACK_PATH}`,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (path === AUTHORIZE_PATH && request.method === 'GET') {
|
|
142
|
+
return render(await server.authorize(url.searchParams, endpoint), request);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The browser returning from lanes.sh. A GET, because it arrives as a
|
|
146
|
+
// top-level navigation from a 302 — which is also why nothing here reads
|
|
147
|
+
// `Origin`: a navigation carries none. See `rebinding.ts`.
|
|
148
|
+
if (path === CALLBACK_PATH && request.method === 'GET') {
|
|
149
|
+
return render(await server.callback(url.searchParams, endpoint), request);
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
if (path === TOKEN_PATH && request.method === 'POST') {
|
|
146
|
-
return render(
|
|
147
|
-
await server.token(new URLSearchParams(await request.text())),
|
|
148
|
-
request,
|
|
149
|
-
surface.target,
|
|
150
|
-
);
|
|
153
|
+
return render(await server.token(new URLSearchParams(await request.text())), request);
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
return new Response('Method not allowed', { status: 405 });
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
function render(result: OAuthResult, request: Request
|
|
159
|
+
function render(result: OAuthResult, request: Request): Response {
|
|
157
160
|
switch (result.kind) {
|
|
158
161
|
case 'json':
|
|
159
162
|
return json(result.body, result.status);
|
|
@@ -161,95 +164,12 @@ function render(result: OAuthResult, request: Request, target: string): Response
|
|
|
161
164
|
case 'redirect':
|
|
162
165
|
return new Response(null, { status: 302, headers: { location: result.location } });
|
|
163
166
|
|
|
164
|
-
case 'consent':
|
|
165
|
-
return approvalPage({
|
|
166
|
-
// The name if it gave one, the identifier if not. Either way the
|
|
167
|
-
// redirect host goes on the screen beside it — a client may call itself
|
|
168
|
-
// anything, but it cannot change where the code is sent.
|
|
169
|
-
client: result.clientName ?? result.request.clientId,
|
|
170
|
-
redirectHost: hostOf(result.request.redirectUri),
|
|
171
|
-
// The page's policy has to admit the redirect the page's own approval
|
|
172
|
-
// ends in, or the browser blocks it. See `formActionFor`.
|
|
173
|
-
...formActionFor(result.request.redirectUri),
|
|
174
|
-
action: `${publicOrigin(request)}${AUTHORIZE_PATH}`,
|
|
175
|
-
fields: formFromRequest(result.request),
|
|
176
|
-
retry: result.retry,
|
|
177
|
-
target,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
167
|
case 'error':
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
* The authorization request, carried through the approval form.
|
|
187
|
-
*
|
|
188
|
-
* Round-tripped through hidden fields rather than held in a server-side session:
|
|
189
|
-
* the deployed endpoint replaces instances between requests, so a session begun
|
|
190
|
-
* on one and submitted to another would be gone. Nothing here is a secret — the
|
|
191
|
-
* client sent all of it in the query string — and none of it is trusted on the
|
|
192
|
-
* way back, because `approve` re-checks the client and the redirect URI against
|
|
193
|
-
* what is registered before it mints anything.
|
|
194
|
-
*/
|
|
195
|
-
function formFromRequest(request: AuthorizeRequest): Record<string, string> {
|
|
196
|
-
return {
|
|
197
|
-
client_id: request.clientId,
|
|
198
|
-
redirect_uri: request.redirectUri,
|
|
199
|
-
code_challenge: request.codeChallenge,
|
|
200
|
-
scope: request.scope,
|
|
201
|
-
...(request.state !== undefined ? { state: request.state } : {}),
|
|
202
|
-
...(request.resource !== undefined ? { resource: request.resource } : {}),
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function requestFromForm(form: URLSearchParams): AuthorizeRequest {
|
|
207
|
-
return {
|
|
208
|
-
clientId: form.get('client_id') ?? '',
|
|
209
|
-
redirectUri: form.get('redirect_uri') ?? '',
|
|
210
|
-
codeChallenge: form.get('code_challenge') ?? '',
|
|
211
|
-
scope: form.get('scope') ?? '',
|
|
212
|
-
state: form.get('state') ?? undefined,
|
|
213
|
-
resource: form.get('resource') ?? undefined,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function hostOf(uri: string): string {
|
|
218
|
-
try {
|
|
219
|
-
return new URL(uri).host;
|
|
220
|
-
} catch {
|
|
221
|
-
return uri;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* The redirect target as a CSP source, for the consent page's `form-action`.
|
|
227
|
-
*
|
|
228
|
-
* Chrome and Safari check that directive against the redirect a form submission
|
|
229
|
-
* produces, so the page has to name where its own approval is about to send the
|
|
230
|
-
* browser — `'self'` alone mints the code and then blocks its delivery.
|
|
231
|
-
*
|
|
232
|
-
* Taken from the request being approved rather than from what the client
|
|
233
|
-
* registered, because the two legitimately differ: a native client registers
|
|
234
|
-
* `http://localhost/callback` and binds whatever port it got (RFC 8252), and
|
|
235
|
-
* the origin the browser navigates to is the one carrying that port. It is
|
|
236
|
-
* already checked against the registration — by `authorize` before this page is
|
|
237
|
-
* rendered, and again by `approve` before anything is minted — and it cannot
|
|
238
|
-
* move the token, because the form's `action` is built here rather than read
|
|
239
|
-
* from the request.
|
|
240
|
-
*
|
|
241
|
-
* An origin and nothing else, because `isSafeRedirect` registers nothing else:
|
|
242
|
-
* https, or http on loopback. A private-use scheme — `vscode:`, the other shape
|
|
243
|
-
* RFC 8252 allows — would need a scheme-source here, and is refused two steps
|
|
244
|
-
* earlier, so a branch for it would be a branch nothing can reach.
|
|
245
|
-
*/
|
|
246
|
-
function formActionFor(uri: string): { formAction?: string } {
|
|
247
|
-
try {
|
|
248
|
-
const { protocol, origin } = new URL(uri);
|
|
249
|
-
if (protocol !== 'http:' && protocol !== 'https:') return {};
|
|
250
|
-
return { formAction: origin };
|
|
251
|
-
} catch {
|
|
252
|
-
return {};
|
|
168
|
+
// A page rather than a bare string, because the audience changed. These
|
|
169
|
+
// used to be read by a client following a redirect; now the interesting
|
|
170
|
+
// ones — "no profile lists you" — are read by a person in a browser who
|
|
171
|
+
// has just signed in and needs to know what to do next.
|
|
172
|
+
return noticePage(result.message, result.status);
|
|
253
173
|
}
|
|
254
174
|
}
|
|
255
175
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a presented credential is this workspace's pairing token.
|
|
5
|
+
*
|
|
6
|
+
* A verifier rather than the token itself, because the two binds pay very
|
|
7
|
+
* different prices for the answer and only the verifier can know that. On
|
|
8
|
+
* loopback the credential store holds a decrypted copy and a read is a map
|
|
9
|
+
* lookup; on a deployed workspace `GcpSecretManagerStore` has no cache at all
|
|
10
|
+
* and every `get()` is a network round trip. A shared `token: () => Promise`
|
|
11
|
+
* thunk hid that difference behind one signature, and the comment that used to
|
|
12
|
+
* sit above it — "this is a map lookup in the ordinary case" — was true of one
|
|
13
|
+
* store and false of the other.
|
|
14
|
+
*/
|
|
15
|
+
export interface PairingCredential {
|
|
16
|
+
/** Never throws. A store that failed is a refusal, not a `500`. */
|
|
17
|
+
verify(presented: string): Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Constant-time, after a length check.
|
|
22
|
+
*
|
|
23
|
+
* The length is compared first and separately because `timingSafeEqual` throws
|
|
24
|
+
* on a mismatch rather than returning false. The length of a token is not the
|
|
25
|
+
* secret; its contents are.
|
|
26
|
+
*/
|
|
27
|
+
function matches(presented: string, expected: string): boolean {
|
|
28
|
+
const a = Buffer.from(presented);
|
|
29
|
+
const b = Buffer.from(expected);
|
|
30
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CredentialSource {
|
|
34
|
+
/** `null` for a secret that exists with no version — the never-paired case. */
|
|
35
|
+
readonly read: () => Promise<string | null>;
|
|
36
|
+
/** Drops a cached decrypted copy, because the rotation was written elsewhere. */
|
|
37
|
+
readonly refresh?: (() => void) | undefined;
|
|
38
|
+
/** Why a read failed, for an operator. Never the credential itself. */
|
|
39
|
+
readonly onError?: ((reason: string) => void) | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function reasonOf(error: unknown): string {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Loopback: read on every presentation, so a rotation lands immediately.
|
|
48
|
+
*
|
|
49
|
+
* `pair --rotate` says "the previous pairing link no longer works", and captured
|
|
50
|
+
* at boot that was false — the live listener went on accepting the old token
|
|
51
|
+
* until the endpoint restarted. Reading per request is what makes the command
|
|
52
|
+
* tell the truth, and it is affordable here because the store is a local file.
|
|
53
|
+
*/
|
|
54
|
+
export function directPairingCredential(source: CredentialSource): PairingCredential {
|
|
55
|
+
return {
|
|
56
|
+
async verify(presented) {
|
|
57
|
+
try {
|
|
58
|
+
source.refresh?.();
|
|
59
|
+
const expected = await source.read();
|
|
60
|
+
return expected !== null && expected !== '' && matches(presented, expected);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
source.onError?.(reasonOf(error));
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** How long a deployed endpoint may keep an answer. Matches the bearer's own window. */
|
|
70
|
+
const CACHE_TTL_MS = 5_000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deployed: one cached read, and a mismatch buys exactly one more.
|
|
74
|
+
*
|
|
75
|
+
* The same trade `BearerAuthenticator` already takes for the MCP bearer, over a
|
|
76
|
+
* strictly weaker credential and with the same five seconds. Without it a
|
|
77
|
+
* dashboard polling `/state` is one Secret Manager call per poll for as long as
|
|
78
|
+
* the page is open, and a stranger sending a wrong token is one call per
|
|
79
|
+
* request — which is the ADR-054 hazard in its purest form, a costly read
|
|
80
|
+
* performed on behalf of a caller who has presented nothing valid.
|
|
81
|
+
*
|
|
82
|
+
* What the re-read on mismatch preserves is the property that mattered: a
|
|
83
|
+
* token rotated *in* works on its first presentation, because a mismatch
|
|
84
|
+
* against a cached value is ambiguous and exactly one re-read separates
|
|
85
|
+
* "rotated" from "wrong". What it costs is that a token rotated *away* keeps
|
|
86
|
+
* reading for up to the window rather than stopping at once — bounded, where
|
|
87
|
+
* the failure `open.ts` records was unbounded until a restart.
|
|
88
|
+
*/
|
|
89
|
+
export function cachedPairingCredential(
|
|
90
|
+
source: CredentialSource & { readonly ttlMs?: number; readonly now?: () => number },
|
|
91
|
+
): PairingCredential {
|
|
92
|
+
const ttl = source.ttlMs ?? CACHE_TTL_MS;
|
|
93
|
+
const now = source.now ?? (() => Date.now());
|
|
94
|
+
|
|
95
|
+
let cached: string | null = null;
|
|
96
|
+
// Both start "infinitely stale", so the first call reads and the first
|
|
97
|
+
// mismatch is always given its one re-read.
|
|
98
|
+
let readAt = -Infinity;
|
|
99
|
+
let missAt = -Infinity;
|
|
100
|
+
|
|
101
|
+
const reread = async (): Promise<void> => {
|
|
102
|
+
source.refresh?.();
|
|
103
|
+
cached = await source.read();
|
|
104
|
+
readAt = now();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const hit = (presented: string): boolean =>
|
|
108
|
+
cached !== null && cached !== '' && matches(presented, cached);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
async verify(presented) {
|
|
112
|
+
try {
|
|
113
|
+
if (now() - readAt >= ttl) await reread();
|
|
114
|
+
if (hit(presented)) return true;
|
|
115
|
+
|
|
116
|
+
// A miss against a cached value is ambiguous: the token may be wrong,
|
|
117
|
+
// or it may be the one a rotation has just written. One re-read tells
|
|
118
|
+
// them apart — and **one per window**, tracked separately from the
|
|
119
|
+
// ordinary refresh above. Keying it on `readAt` instead meant the
|
|
120
|
+
// re-read refreshed the very clock that decided whether to re-read, so
|
|
121
|
+
// every wrong guess bought its own Secret Manager call and the ceiling
|
|
122
|
+
// this cache exists to impose was not there at all.
|
|
123
|
+
if (now() - missAt < ttl) return false;
|
|
124
|
+
missAt = now();
|
|
125
|
+
await reread();
|
|
126
|
+
|
|
127
|
+
return hit(presented);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
source.onError?.(reasonOf(error));
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|