@lanes-sh/link 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- 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 +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -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 +90 -88
- 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 +16 -6
- 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 +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -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 +33 -13
- 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 +53 -5
- 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/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- 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-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -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 +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- 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 +34 -13
- package/src/deployments/upload.ts +60 -27
- 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 +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- 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 +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -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 +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- 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
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { isTool } from '#connectivity';
|
|
2
2
|
import type { Principal } from '#auth';
|
|
3
|
-
import type { Config } from '#profile';
|
|
3
|
+
import type { Config, SelectedConnection } 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.
|
|
@@ -23,9 +24,18 @@ import { allowedConnections } from '#policy';
|
|
|
23
24
|
/** Everything one profile contributes to the endpoint. */
|
|
24
25
|
export interface ProfileRuntime {
|
|
25
26
|
readonly config: Config;
|
|
27
|
+
/**
|
|
28
|
+
* The accounts this profile selects, for the `connection` argument's
|
|
29
|
+
* description — the id alone cannot say which mailbox it is.
|
|
30
|
+
*
|
|
31
|
+
* Optional because a registry built to read manifests has no selection, the
|
|
32
|
+
* same reason `refreshSkills` is: a harness that omits it gets bare ids, which
|
|
33
|
+
* is what this listed for everybody before.
|
|
34
|
+
*/
|
|
35
|
+
readonly connections?: readonly SelectedConnection[];
|
|
26
36
|
readonly registry: ProviderRegistry;
|
|
27
37
|
readonly dispatcher: Dispatcher;
|
|
28
|
-
readonly policy:
|
|
38
|
+
readonly policy: ProfilePolicy;
|
|
29
39
|
readonly floor?: PolicyDocument | undefined;
|
|
30
40
|
/**
|
|
31
41
|
* Re-read the skills into `registry`, if they have changed on the store.
|
|
@@ -72,8 +82,16 @@ export function oneProfile(
|
|
|
72
82
|
return new Map([[name, runtime]]);
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The connections this profile can reach at all, before policy narrows further.
|
|
87
|
+
*
|
|
88
|
+
* The grant rows *are* the answer (ADR-058). A profile reaches what it grants
|
|
89
|
+
* and nothing else, so this needs no view of the workspace's connections — which
|
|
90
|
+
* is the useful half of decoupling them: what a profile can see is written in
|
|
91
|
+
* the profile, and cannot widen when somebody connects a new account.
|
|
92
|
+
*/
|
|
75
93
|
function connectionsOf(runtime: ProfileRuntime): string[] {
|
|
76
|
-
return runtime.config.
|
|
94
|
+
return runtime.config.grants.map((grant) => grant.connection);
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
/**
|
|
@@ -94,6 +112,12 @@ export function mergeCapabilities(options: BuildServerOptions): Map<string, Merg
|
|
|
94
112
|
const merged = new Map<string, MergedCapability>();
|
|
95
113
|
|
|
96
114
|
for (const [name, runtime] of options.profiles) {
|
|
115
|
+
// The same list the dispatcher enforces with. A member does not merely fail
|
|
116
|
+
// to call a profile they are not on — it is absent from the `profile` enum,
|
|
117
|
+
// so they never learn it exists (ADR-060). Discovery and enforcement share
|
|
118
|
+
// one answer here for the same reason they share `allowedConnections`.
|
|
119
|
+
if (!mayReach(options.principal, name)) continue;
|
|
120
|
+
|
|
97
121
|
const connections = connectionsOf(runtime);
|
|
98
122
|
|
|
99
123
|
for (const { id, capability, discovered } of runtime.registry.capabilities()) {
|
|
@@ -151,13 +175,56 @@ export function visibleToolCount(options: BuildServerOptions): number {
|
|
|
151
175
|
* Grouped rather than flattened because the two arguments are not independent:
|
|
152
176
|
* `profile: personal` with a connection belonging to `work` is refused, and a
|
|
153
177
|
* flat list would read as though any pairing were valid.
|
|
178
|
+
*
|
|
179
|
+
* **The account, not just the id.** This listed bare ids, and the id is the
|
|
180
|
+
* only thing a model has to choose on — so two accounts of one vendor were
|
|
181
|
+
* `ada_lovelace` and `ada_lovelace2` and nothing said which mailbox either was.
|
|
182
|
+
* `idFromAccount` takes only the local part, so that is what two addresses at
|
|
183
|
+
* different domains actually produce. Picking the wrong one sends mail as the
|
|
184
|
+
* wrong person, which is the same class of failure ADR-056 rules out for
|
|
185
|
+
* entities: ordering is not selection, and a caller that cannot tell two
|
|
186
|
+
* candidates apart must be given what tells them apart.
|
|
187
|
+
*
|
|
188
|
+
* The enum stays bare ids, because the id is what the caller passes.
|
|
154
189
|
*/
|
|
155
190
|
export function describeWithConnections(
|
|
156
191
|
description: string,
|
|
157
192
|
reachable: ReadonlyMap<string, readonly string[]>,
|
|
193
|
+
accounts: ReadonlyMap<string, ReadonlyMap<string, string>> = new Map(),
|
|
158
194
|
): string {
|
|
159
|
-
const lines = [...reachable].
|
|
160
|
-
|
|
161
|
-
|
|
195
|
+
const lines = [...reachable].flatMap(([profile, connections]) => {
|
|
196
|
+
const known = accounts.get(profile);
|
|
197
|
+
return [
|
|
198
|
+
` ${profile}:`,
|
|
199
|
+
...connections.map((id) => ` ${id}${known?.get(id) === undefined ? '' : ` — ${known.get(id)!}`}`),
|
|
200
|
+
];
|
|
201
|
+
});
|
|
162
202
|
return `${description}\n\nAvailable connections, by profile:\n${lines.join('\n')}`;
|
|
163
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* How each profile's connections should read to a caller choosing between them.
|
|
207
|
+
*
|
|
208
|
+
* `account` always, `label` where the operator set one — the two fields that
|
|
209
|
+
* exist because the id cannot carry meaning and must not pretend to.
|
|
210
|
+
*/
|
|
211
|
+
export function accountsByProfile(
|
|
212
|
+
options: BuildServerOptions,
|
|
213
|
+
): Map<string, Map<string, string>> {
|
|
214
|
+
const accounts = new Map<string, Map<string, string>>();
|
|
215
|
+
|
|
216
|
+
for (const [name, runtime] of options.profiles) {
|
|
217
|
+
const rows = new Map<string, string>();
|
|
218
|
+
for (const { connection } of runtime.connections ?? []) {
|
|
219
|
+
rows.set(
|
|
220
|
+
connection.id,
|
|
221
|
+
connection.label === undefined
|
|
222
|
+
? connection.account
|
|
223
|
+
: `${connection.account} (${connection.label})`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
accounts.set(name, rows);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return accounts;
|
|
230
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { PAIR_TOKEN_REF, readConnections } from '#profile';
|
|
2
|
+
import type { Runtime } from '#cli/runtime.ts';
|
|
3
|
+
import type { Logger } from '#connectivity';
|
|
4
|
+
import type { ProfileRuntime } from '../mcp/visibility.ts';
|
|
5
|
+
import { cachedPairingCredential } from './credential.ts';
|
|
6
|
+
import type { ReadDeps } from './routes.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The same read surface, on a deployed endpoint's own port (ADR-064).
|
|
10
|
+
*
|
|
11
|
+
* Its sibling `./open.ts` binds a second TLS listener and cannot be used here:
|
|
12
|
+
* Cloud Run routes exactly one port. So this returns dependencies rather than a
|
|
13
|
+
* socket, and the router serves the two paths in front of its own bearer gate —
|
|
14
|
+
* the pairing token never passes through the endpoint's authenticator, because
|
|
15
|
+
* one shared check would make each credential able to do the other's job.
|
|
16
|
+
*
|
|
17
|
+
* **Nothing here reads a credential.** That is the structural half of the fix
|
|
18
|
+
* `./open.ts` describes: the pairing token is read per request, behind a
|
|
19
|
+
* verifier, so a Secret Manager rejection can fail a request and can no longer
|
|
20
|
+
* fail a boot. Adding `PAIR_TOKEN_REF` to `readableRefs` is the other half, and
|
|
21
|
+
* it stops the rejection happening at all — a bound secret with no version
|
|
22
|
+
* answers 404 and reads back as `null`, which is the never-paired case and
|
|
23
|
+
* renders as an ordinary `401`.
|
|
24
|
+
*
|
|
25
|
+
* Handed to `serve()`, which discards it on a loopback bind — beside `cors`,
|
|
26
|
+
* `allowedHostnames` and `meterUnauthenticated`, because it is the same kind of
|
|
27
|
+
* fact about the same address.
|
|
28
|
+
*/
|
|
29
|
+
export function deployedReadDeps(input: {
|
|
30
|
+
readonly primary: Runtime;
|
|
31
|
+
readonly profiles: () => ReadonlyMap<string, ProfileRuntime>;
|
|
32
|
+
readonly log: Logger;
|
|
33
|
+
readonly version: string;
|
|
34
|
+
}): ReadDeps {
|
|
35
|
+
const { primary, log } = input;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
workspace: primary.target,
|
|
39
|
+
profiles: input.profiles,
|
|
40
|
+
audit: primary.audit,
|
|
41
|
+
connections: async () =>
|
|
42
|
+
(await readConnections(primary.resolution.workspaceRoot)).connections,
|
|
43
|
+
// Cached, unlike loopback's. `GcpSecretManagerStore` holds nothing between
|
|
44
|
+
// calls, so a dashboard polling `/state` would be one network round trip per
|
|
45
|
+
// poll and a stranger sending a wrong token one per request — which is the
|
|
46
|
+
// ADR-054 hazard exactly: a costly read performed for a caller who has
|
|
47
|
+
// presented nothing valid.
|
|
48
|
+
credential: cachedPairingCredential({
|
|
49
|
+
read: () => primary.credentials.get(PAIR_TOKEN_REF),
|
|
50
|
+
refresh: () => primary.credentials.refresh?.(),
|
|
51
|
+
onError: (reason) => log.warn('could not read the pairing credential', { reason }),
|
|
52
|
+
}),
|
|
53
|
+
endpoint: { kind: 'deployed', version: input.version, certificateExpiresAt: null },
|
|
54
|
+
log,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readRoutes, type ReadDeps } from './routes.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The read surface on loopback: its own port, over TLS (ADR-063).
|
|
5
|
+
*
|
|
6
|
+
* The routes themselves live in `./routes.ts`, shared with the deployed bind,
|
|
7
|
+
* so the four properties they enforce cannot come to differ between the two.
|
|
8
|
+
* What is decided *here* is the fifth, and it is the one that is genuinely
|
|
9
|
+
* about this bind rather than about the routes:
|
|
10
|
+
*
|
|
11
|
+
* **TLS.** Not for confidentiality on a loopback socket, but because Safari
|
|
12
|
+
* will not let an HTTPS page fetch `http://127.0.0.1` and offers no header,
|
|
13
|
+
* flag or opt-in that changes it. Without this the surface does not exist for a
|
|
14
|
+
* Safari user. It is also the whole reason for a second port: the MCP listener
|
|
15
|
+
* must keep answering `http://127.0.0.1:7337` for every registration that
|
|
16
|
+
* already exists.
|
|
17
|
+
*
|
|
18
|
+
* A deployed workspace needs none of this — Cloud Run terminates TLS with a
|
|
19
|
+
* certificate a browser already trusts, and routes exactly one port — so it
|
|
20
|
+
* takes the routes through the endpoint's own router instead. See
|
|
21
|
+
* `./deployed.ts`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { READ_ORIGINS, type AuditTail, type ReadDeps } from './routes.ts';
|
|
25
|
+
|
|
26
|
+
export interface ReadListenerOptions extends ReadDeps {
|
|
27
|
+
readonly host: string;
|
|
28
|
+
readonly port: number;
|
|
29
|
+
readonly tls: { readonly cert: string; readonly key: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunningReadListener {
|
|
33
|
+
readonly url: string;
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function serveRead(options: ReadListenerOptions): RunningReadListener {
|
|
38
|
+
const server = Bun.serve({
|
|
39
|
+
hostname: options.host,
|
|
40
|
+
port: options.port,
|
|
41
|
+
tls: { cert: options.tls.cert, key: options.tls.key },
|
|
42
|
+
// Everything, because this owns a whole port. The router on the deployed
|
|
43
|
+
// side passes only what `isReadPath` matched — handing an unmatched path to
|
|
44
|
+
// `readRoutes` there would swallow `/mcp`.
|
|
45
|
+
fetch: (request) => readRoutes(request, options),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
// The port the kernel assigned, not the one that was asked for. They differ
|
|
50
|
+
// whenever `port: 0` is passed, and a URL naming 0 is one nothing can reach.
|
|
51
|
+
url: `https://${options.host}:${server.port}`,
|
|
52
|
+
stop: () => server.stop(true),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { X509Certificate } from 'node:crypto';
|
|
2
|
+
import { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, readConnections } from '#profile';
|
|
3
|
+
import type { Runtime } from '#cli/runtime.ts';
|
|
4
|
+
import type { Logger } from '#connectivity';
|
|
5
|
+
import type { RunningServer } from '../index.ts';
|
|
6
|
+
import type { ProfileRuntime } from '../mcp/visibility.ts';
|
|
7
|
+
import { directPairingCredential } from './credential.ts';
|
|
8
|
+
import { serveRead, type RunningReadListener } from './listener.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The dashboard's read surface on loopback, if this workspace has been paired.
|
|
12
|
+
*
|
|
13
|
+
* Absent by default and absent for every workspace that has not run
|
|
14
|
+
* `lanes link pair`, which is the whole shape of ADR-063: a browser origin
|
|
15
|
+
* reaching loopback is a grant somebody makes deliberately, not a property of
|
|
16
|
+
* running an endpoint. All three pieces must be present — the token and both
|
|
17
|
+
* halves of the certificate — because a partial pairing would bind a port
|
|
18
|
+
* serving something no browser will connect to.
|
|
19
|
+
*
|
|
20
|
+
* Bound one above the MCP port, and a failure to bind is reported rather than
|
|
21
|
+
* fatal: the endpoint is what the operator ran this for, and refusing to serve
|
|
22
|
+
* it because a second port is occupied would be the wrong trade.
|
|
23
|
+
*/
|
|
24
|
+
export async function openReadListener(
|
|
25
|
+
primary: Runtime,
|
|
26
|
+
server: RunningServer,
|
|
27
|
+
profiles: () => ReadonlyMap<string, ProfileRuntime>,
|
|
28
|
+
log: Logger,
|
|
29
|
+
version: string,
|
|
30
|
+
): Promise<RunningReadListener | null> {
|
|
31
|
+
// Loopback only, and checked before a single credential is read.
|
|
32
|
+
//
|
|
33
|
+
// A second TLS listener one port above the endpoint is a loopback-only
|
|
34
|
+
// object: Cloud Run routes exactly one port, so there is nowhere for it to
|
|
35
|
+
// bind. Reading the three refs regardless meant a deployed revision asked
|
|
36
|
+
// Secret Manager for secrets no IAM binding covered — and Secret Manager
|
|
37
|
+
// answers a missing binding with 403 rather than 404, so the rejection
|
|
38
|
+
// escaped this function's try block, which wraps only `serveRead`, and the
|
|
39
|
+
// revision never went healthy.
|
|
40
|
+
//
|
|
41
|
+
// A deployed workspace now serves the same routes through the endpoint's own
|
|
42
|
+
// router (`./deployed.ts`), which reads no credential at boot at all — so
|
|
43
|
+
// that failure cannot recur there by construction, and `readableRefs` binds
|
|
44
|
+
// the token so the read itself stops being a rejection.
|
|
45
|
+
const bound = new URL(server.url);
|
|
46
|
+
if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(bound.hostname)) return null;
|
|
47
|
+
|
|
48
|
+
const [token, cert, key] = await Promise.all([
|
|
49
|
+
primary.credentials.get(PAIR_TOKEN_REF),
|
|
50
|
+
primary.credentials.get(PAIR_CERT_REF),
|
|
51
|
+
primary.credentials.get(PAIR_KEY_REF),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
if (token === null || cert === null || key === null) return null;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
return serveRead({
|
|
58
|
+
host: bound.hostname,
|
|
59
|
+
port: Number(bound.port) + 1,
|
|
60
|
+
workspace: primary.target,
|
|
61
|
+
profiles,
|
|
62
|
+
audit: primary.audit,
|
|
63
|
+
connections: async () =>
|
|
64
|
+
(await readConnections(primary.resolution.workspaceRoot)).connections,
|
|
65
|
+
// Read on every presentation, so `pair --rotate` takes effect on a
|
|
66
|
+
// running endpoint. Affordable here because the store is a local file;
|
|
67
|
+
// the deployed bind caches for exactly this reason. `refresh()` drops the
|
|
68
|
+
// store's decrypted copy first, because the rotation was written by a
|
|
69
|
+
// different process.
|
|
70
|
+
credential: directPairingCredential({
|
|
71
|
+
read: () => primary.credentials.get(PAIR_TOKEN_REF),
|
|
72
|
+
refresh: () => primary.credentials.refresh?.(),
|
|
73
|
+
onError: (reason) => log.warn('could not read the pairing credential', { reason }),
|
|
74
|
+
}),
|
|
75
|
+
endpoint: { kind: 'local', version, certificateExpiresAt: expiryOf(cert) },
|
|
76
|
+
tls: { cert, key },
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
log.warn('could not serve the dashboard read surface', {
|
|
80
|
+
port: Number(bound.port) + 1,
|
|
81
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
82
|
+
});
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* When the pairing certificate stops working, as an ISO instant.
|
|
89
|
+
*
|
|
90
|
+
* `null` rather than a throw for a certificate that cannot be parsed: the
|
|
91
|
+
* surface it protects is already serving by the time anyone reads this, and
|
|
92
|
+
* refusing to answer `/state` because an expiry could not be formatted would
|
|
93
|
+
* take down the working thing to report on the broken one.
|
|
94
|
+
*/
|
|
95
|
+
function expiryOf(certificate: string): string | null {
|
|
96
|
+
try {
|
|
97
|
+
return new X509Certificate(certificate).validToDate.toISOString();
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|