@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,21 +1,43 @@
|
|
|
1
|
-
import type { PolicyDocument, PolicyRule } from '#policy';
|
|
2
|
-
import type { Config } from '#profile';
|
|
1
|
+
import type { PolicyDocument, PolicyRule, ProfilePolicy } from '#policy';
|
|
2
|
+
import type { Config, GrantConfig } from '#profile';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Turn the config's `
|
|
5
|
+
* Turn the config's `grants:` rows into the documents the policy engine
|
|
6
6
|
* evaluates.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* what
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Two format differences are reconciled here, and keeping them in one place is
|
|
9
|
+
* what stops either leaking into the other side.
|
|
10
|
+
*
|
|
11
|
+
* The config keeps `allow` and `deny` as separate lists because that is what
|
|
12
|
+
* reads clearly in YAML; the engine wants one list carrying an effect, because
|
|
13
|
+
* evaluation must consider both together to make deny win regardless of
|
|
14
|
+
* ordering.
|
|
15
|
+
*
|
|
16
|
+
* And the config is a *sequence* of rows while the engine wants a lookup by
|
|
17
|
+
* connection (ADR-058). A profile cannot declare the same connection twice —
|
|
18
|
+
* `assertGrantsResolve` refuses it at load, precisely so that this conversion
|
|
19
|
+
* has no collision to resolve and no precedence to invent.
|
|
20
|
+
*
|
|
21
|
+
* This is the only place `#profile`'s shape meets `#policy`'s. `#policy` may
|
|
22
|
+
* not import `#profile` and does not need to: it takes a map of rule lists and
|
|
23
|
+
* knows nothing about YAML, grants, or where a connection was declared.
|
|
12
24
|
*/
|
|
13
|
-
export function toPolicyDocument(config: Config):
|
|
25
|
+
export function toPolicyDocument(config: Config): ProfilePolicy {
|
|
26
|
+
const byConnection = new Map<string, PolicyDocument>();
|
|
27
|
+
|
|
28
|
+
for (const grant of config.grants) {
|
|
29
|
+
byConnection.set(grant.connection, { rules: rulesFor(grant) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { byConnection };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rulesFor(grant: GrantConfig): PolicyRule[] {
|
|
14
36
|
const rules: PolicyRule[] = [];
|
|
15
37
|
|
|
16
38
|
for (const [effect, entries] of [
|
|
17
|
-
['allow',
|
|
18
|
-
['deny',
|
|
39
|
+
['allow', grant.allow],
|
|
40
|
+
['deny', grant.deny],
|
|
19
41
|
] as const) {
|
|
20
42
|
for (const entry of entries) {
|
|
21
43
|
rules.push({
|
|
@@ -28,5 +50,5 @@ export function toPolicyDocument(config: Config): PolicyDocument {
|
|
|
28
50
|
}
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
return
|
|
53
|
+
return rules;
|
|
32
54
|
}
|
|
@@ -144,7 +144,7 @@ export function planIsNoop(plan: ReconcilePlan): boolean {
|
|
|
144
144
|
* half-configured Gmail account must not stop the other providers from serving.
|
|
145
145
|
*/
|
|
146
146
|
export async function planReconcile(
|
|
147
|
-
|
|
147
|
+
connections: readonly ConnectionConfig[],
|
|
148
148
|
state: RuntimeState,
|
|
149
149
|
credentials: SecretStore,
|
|
150
150
|
/**
|
|
@@ -164,7 +164,7 @@ export async function planReconcile(
|
|
|
164
164
|
const unauthorized: string[] = [];
|
|
165
165
|
const declared = new Set<string>();
|
|
166
166
|
|
|
167
|
-
for (const connection of
|
|
167
|
+
for (const connection of connections) {
|
|
168
168
|
const key = `${connection.provider}.${connection.id}`;
|
|
169
169
|
declared.add(key);
|
|
170
170
|
|
|
@@ -229,11 +229,11 @@ export async function planReconcile(
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
export async function applyReconcile(
|
|
232
|
-
|
|
232
|
+
connections: readonly ConnectionConfig[],
|
|
233
233
|
state: RuntimeState,
|
|
234
234
|
plan: ReconcilePlan,
|
|
235
235
|
): Promise<void> {
|
|
236
|
-
const byKey = new Map(
|
|
236
|
+
const byKey = new Map(connections.map((one) => [`${one.provider}.${one.id}`, one]));
|
|
237
237
|
|
|
238
238
|
for (const action of plan.actions) {
|
|
239
239
|
if (action.kind === 'unchanged') continue;
|
package/src/registry/registry.ts
CHANGED
|
@@ -78,7 +78,7 @@ export class ProviderRegistry {
|
|
|
78
78
|
|
|
79
79
|
if (!this.#allowReserved && RESERVED_PROVIDER_IDS.includes(manifest.id)) {
|
|
80
80
|
throw new Error(
|
|
81
|
-
`Provider id "${manifest.id}" is reserved for the owner layer (
|
|
81
|
+
`Provider id "${manifest.id}" is reserved for the owner layer (the lanes_ surfaces) and cannot be claimed.`,
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IssuedTokenAuthenticator,
|
|
3
|
+
OAuthServer,
|
|
4
|
+
OAuthStore,
|
|
5
|
+
OidcAuthenticator,
|
|
6
|
+
OidcVerifier,
|
|
7
|
+
lanesFederation,
|
|
8
|
+
type Authenticator,
|
|
9
|
+
} from '#auth';
|
|
10
|
+
import type { Logger } from '#connectivity';
|
|
11
|
+
import type { Runtime } from '#cli/runtime.ts';
|
|
12
|
+
import { MCP_PATH } from './index.ts';
|
|
13
|
+
import type { AuthorizationSurface } from './oauth.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deciding how a remote client proves who it is.
|
|
17
|
+
*
|
|
18
|
+
* Its own file, away from the lifecycle in `endpoint.ts`, because the two
|
|
19
|
+
* answer different questions and only one of them is interesting. Starting an
|
|
20
|
+
* endpoint is bind, serve, reload, stop. This is which of three models the
|
|
21
|
+
* profile declared — none, an issuer of its own, or somebody else's — and each
|
|
22
|
+
* arm carries an argument that has to be read to be changed safely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The remote-client gate, if this profile declares one.
|
|
27
|
+
*
|
|
28
|
+
* Endpoint-scoped rather than per profile, like the bearer token and for the
|
|
29
|
+
* same reason (ADR-009): one URL serves every profile in the workspace, so
|
|
30
|
+
* there is one place a client authorises and one set of tokens.
|
|
31
|
+
*
|
|
32
|
+
* Returns null when `auth.authorization` is absent, and everything downstream
|
|
33
|
+
* treats null as "exactly as before" — no metadata published, no pointer on the
|
|
34
|
+
* `401`, one authenticator instead of a chain.
|
|
35
|
+
*/
|
|
36
|
+
export async function openAuthorization(
|
|
37
|
+
primary: Runtime,
|
|
38
|
+
log: Logger,
|
|
39
|
+
members: (subject: string) => Promise<readonly string[]>,
|
|
40
|
+
): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
|
|
41
|
+
const declared = primary.config.auth.authorization;
|
|
42
|
+
if (!declared) return null;
|
|
43
|
+
|
|
44
|
+
const profile = primary.resolution.profile;
|
|
45
|
+
|
|
46
|
+
if (declared.mode === 'oidc') {
|
|
47
|
+
const audience = await primary.credentials.get(declared.client_id_ref);
|
|
48
|
+
if (!audience) {
|
|
49
|
+
// Refuse rather than verify without an audience. A verifier that cannot
|
|
50
|
+
// check who a token was issued for accepts every token the issuer minted
|
|
51
|
+
// for anything, which is the failure this mode exists to prevent.
|
|
52
|
+
throw new Error(
|
|
53
|
+
`auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
|
|
54
|
+
`this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const verifier = new OidcVerifier({
|
|
59
|
+
issuer: declared.issuer,
|
|
60
|
+
audience,
|
|
61
|
+
allowedSubjects: declared.allowed_subjects,
|
|
62
|
+
...(declared.introspection_endpoint
|
|
63
|
+
? { introspectionEndpoint: declared.introspection_endpoint }
|
|
64
|
+
: {}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
// The issuer is somebody else's origin, so it is a constant here rather
|
|
69
|
+
// than derived from the request.
|
|
70
|
+
surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
|
|
71
|
+
authenticator: new OidcAuthenticator(verifier, profile),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const store = new OAuthStore(primary.state.kv);
|
|
76
|
+
|
|
77
|
+
const server = new OAuthServer({
|
|
78
|
+
store,
|
|
79
|
+
accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
|
|
80
|
+
// So a replayed refresh token leaves a line. It is refused rather than
|
|
81
|
+
// acted on (ADR-035), and a refusal nobody can see is how a connector
|
|
82
|
+
// losing its authorization came to need log forensics to explain.
|
|
83
|
+
log,
|
|
84
|
+
// Identity comes from lanes.sh, not from a credential pasted into a form on
|
|
85
|
+
// this machine (ADR-062). What this endpoint decides is the half lanes.sh
|
|
86
|
+
// cannot know: which of *its* profiles name that person.
|
|
87
|
+
federation: lanesFederation({ profilesFor: members }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
|
|
92
|
+
authenticator: new IssuedTokenAuthenticator(store, profile),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/server/edge.ts
CHANGED
|
@@ -33,11 +33,20 @@ export const FAILED_AUTH_PER_MINUTE = 30;
|
|
|
33
33
|
* - `/authorize` compares against the endpoint token, which is another read of
|
|
34
34
|
* the credential store.
|
|
35
35
|
* - `/token` reads and writes bucket objects.
|
|
36
|
+
* - `/state` and `/audit` verify the pairing token, which on a deployed
|
|
37
|
+
* workspace is a Secret Manager call and has no cache behind it at all.
|
|
36
38
|
*
|
|
37
39
|
* `/health` presented with *no* credential is deliberately free: it reads
|
|
38
40
|
* nothing, and it is what a platform probe and `lanes link outputs` send.
|
|
39
41
|
* Metering it would put a ceiling on the one request that costs nothing to
|
|
40
42
|
* answer.
|
|
43
|
+
*
|
|
44
|
+
* The read surface is **not** given that exemption, and the asymmetry is the
|
|
45
|
+
* point: nothing legitimate calls `/state` without a credential — no probe, no
|
|
46
|
+
* CLI command — so there is no free request to protect. Metering it
|
|
47
|
+
* unconditionally also means the ceiling does not depend on `readRoutes`
|
|
48
|
+
* continuing to parse the header before it asks the store anything. That
|
|
49
|
+
* short-circuit is an optimisation; this is the guarantee.
|
|
41
50
|
*/
|
|
42
51
|
export const UNAUTHENTICATED_PER_MINUTE = 30;
|
|
43
52
|
|
|
@@ -141,6 +150,9 @@ export function unauthenticatedRefusal(input: {
|
|
|
141
150
|
readonly healthPath: string;
|
|
142
151
|
readonly isAuthorizationPath: (pathname: string) => boolean;
|
|
143
152
|
readonly authorizationEnabled: boolean;
|
|
153
|
+
/** Passed rather than imported, for the reason `isAuthorizationPath` is. */
|
|
154
|
+
readonly isReadPath: (pathname: string) => boolean;
|
|
155
|
+
readonly readEnabled: boolean;
|
|
144
156
|
}): Response | undefined {
|
|
145
157
|
// A `/health` carrying no credential reads nothing and is deliberately free —
|
|
146
158
|
// it is what a platform probe and `lanes link outputs` send, and a ceiling on
|
|
@@ -149,7 +161,8 @@ export function unauthenticatedRefusal(input: {
|
|
|
149
161
|
const costly =
|
|
150
162
|
input.pathname === input.healthPath
|
|
151
163
|
? input.request.headers.get('authorization') !== null
|
|
152
|
-
: input.
|
|
164
|
+
: (input.readEnabled && input.isReadPath(input.pathname)) ||
|
|
165
|
+
(input.authorizationEnabled && input.isAuthorizationPath(input.pathname));
|
|
153
166
|
|
|
154
167
|
if (!costly) return undefined;
|
|
155
168
|
|
package/src/server/endpoint.ts
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
import { MCP_PATH, serve } from './index.ts';
|
|
1
|
+
import { MCP_PATH, serve, type RunningServer } from './index.ts';
|
|
2
2
|
import { serveOverStdio } from './stdio.ts';
|
|
3
3
|
import { Generations, type OpenedWorkspace } from './generations.ts';
|
|
4
4
|
import type { AuthorizationSurface } from './oauth.ts';
|
|
5
5
|
import type { ProfileRuntime } from './mcp/index.ts';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
OidcAuthenticator,
|
|
12
|
-
OidcVerifier,
|
|
13
|
-
tokensMatch,
|
|
14
|
-
type Authenticator,
|
|
15
|
-
} from '#auth';
|
|
6
|
+
import { AuthenticatorChain } from '#auth';
|
|
7
|
+
import { openAuthorization } from './authorization.ts';
|
|
8
|
+
import { openReadListener } from './read/open.ts';
|
|
9
|
+
import { deployedReadDeps } from './read/deployed.ts';
|
|
10
|
+
import { version } from '#cli/version.ts';
|
|
16
11
|
import type { Logger } from '#connectivity';
|
|
17
12
|
import { silentLogger } from './logging.ts';
|
|
18
|
-
import { listProfiles } from '#profile';
|
|
13
|
+
import { listProfiles, readConnections } from '#profile';
|
|
19
14
|
import {
|
|
20
15
|
applyReconcile,
|
|
21
16
|
formatPlan,
|
|
@@ -82,6 +77,8 @@ export interface EndpointOptions {
|
|
|
82
77
|
export interface RunningEndpoint {
|
|
83
78
|
readonly url: string;
|
|
84
79
|
readonly profiles: readonly string[];
|
|
80
|
+
/** The dashboard read surface, when this workspace is paired (ADR-063). */
|
|
81
|
+
readonly readUrl?: string | undefined;
|
|
85
82
|
stop(): Promise<void>;
|
|
86
83
|
}
|
|
87
84
|
|
|
@@ -133,21 +130,33 @@ async function openReconciled(options: {
|
|
|
133
130
|
}
|
|
134
131
|
}
|
|
135
132
|
|
|
136
|
-
for
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
133
|
+
// Once for the workspace, over every connection the workspace holds.
|
|
134
|
+
//
|
|
135
|
+
// Runtime state is one store per workspace since contract 3, and reconcile
|
|
136
|
+
// disables everything in it that the connection list does not declare — so
|
|
137
|
+
// running it per profile over that profile's *grants* had each pass disable
|
|
138
|
+
// the connections only the other profiles granted. Two profiles was enough:
|
|
139
|
+
// the second pass disabled the first's accounts, every later call was
|
|
140
|
+
// refused `denied_connection_unauthorized`, and restarting flipped which
|
|
141
|
+
// profile survived.
|
|
142
|
+
//
|
|
143
|
+
// The primary's runtime is used for the stores because they are the same
|
|
144
|
+
// stores for every profile here. `workspaceConnections` is the whole list,
|
|
145
|
+
// which is what "undeclared" has to be measured against.
|
|
146
|
+
const declared = primary.workspaceConnections;
|
|
147
|
+
const result = await planReconcile(
|
|
148
|
+
declared,
|
|
149
|
+
primary.state,
|
|
150
|
+
primary.credentials,
|
|
151
|
+
primary.manifestFor,
|
|
152
|
+
);
|
|
153
|
+
if (!planIsNoop(result)) {
|
|
154
|
+
reporter.reconciled({
|
|
155
|
+
profile: primary.resolution.profile,
|
|
156
|
+
plan: formatPlan(result),
|
|
157
|
+
ofMany: false,
|
|
158
|
+
});
|
|
159
|
+
await applyReconcile(declared, primary.state, result);
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
return { primary, runtimes };
|
|
@@ -167,6 +176,10 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
|
|
|
167
176
|
name,
|
|
168
177
|
{
|
|
169
178
|
config: runtime.config,
|
|
179
|
+
// For the `connection` argument's description: an id says nothing about
|
|
180
|
+
// which account it is, and the caller choosing between two of a vendor
|
|
181
|
+
// has only that description to go on.
|
|
182
|
+
connections: runtime.connections,
|
|
170
183
|
registry: runtime.registry,
|
|
171
184
|
dispatcher: runtime.dispatcher,
|
|
172
185
|
policy: toPolicyDocument(runtime.config),
|
|
@@ -183,80 +196,6 @@ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
|
|
|
183
196
|
return Promise.all([...runtimes.values()].map((runtime) => runtime.close()));
|
|
184
197
|
}
|
|
185
198
|
|
|
186
|
-
/**
|
|
187
|
-
* The remote-client gate, if this profile declares one.
|
|
188
|
-
*
|
|
189
|
-
* Endpoint-scoped rather than per profile, like the bearer token and for the
|
|
190
|
-
* same reason (ADR-009): one URL serves every profile in the workspace, so
|
|
191
|
-
* there is one place a client authorises and one set of tokens.
|
|
192
|
-
*
|
|
193
|
-
* Returns null when `auth.authorization` is absent, and everything downstream
|
|
194
|
-
* treats null as "exactly as before" — no metadata published, no pointer on the
|
|
195
|
-
* `401`, one authenticator instead of a chain.
|
|
196
|
-
*/
|
|
197
|
-
async function openAuthorization(
|
|
198
|
-
primary: Runtime,
|
|
199
|
-
log: Logger,
|
|
200
|
-
): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
|
|
201
|
-
const declared = primary.config.auth.authorization;
|
|
202
|
-
if (!declared) return null;
|
|
203
|
-
|
|
204
|
-
const profile = primary.resolution.profile;
|
|
205
|
-
|
|
206
|
-
if (declared.mode === 'oidc') {
|
|
207
|
-
const audience = await primary.credentials.get(declared.client_id_ref);
|
|
208
|
-
if (!audience) {
|
|
209
|
-
// Refuse rather than verify without an audience. A verifier that cannot
|
|
210
|
-
// check who a token was issued for accepts every token the issuer minted
|
|
211
|
-
// for anything, which is the failure this mode exists to prevent.
|
|
212
|
-
throw new Error(
|
|
213
|
-
`auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
|
|
214
|
-
`this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const verifier = new OidcVerifier({
|
|
219
|
-
issuer: declared.issuer,
|
|
220
|
-
audience,
|
|
221
|
-
allowedSubjects: declared.allowed_subjects,
|
|
222
|
-
...(declared.introspection_endpoint
|
|
223
|
-
? { introspectionEndpoint: declared.introspection_endpoint }
|
|
224
|
-
: {}),
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
// The issuer is somebody else's origin, so it is a constant here rather
|
|
229
|
-
// than derived from the request.
|
|
230
|
-
surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
|
|
231
|
-
authenticator: new OidcAuthenticator(verifier, profile),
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const store = new OAuthStore(primary.state.kv);
|
|
236
|
-
const expected = primary.config.auth.token_ref;
|
|
237
|
-
|
|
238
|
-
const server = new OAuthServer({
|
|
239
|
-
store,
|
|
240
|
-
accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
|
|
241
|
-
// So a replayed refresh token leaves a line. It is refused rather than
|
|
242
|
-
// acted on (ADR-035), and a refusal nobody can see is how a connector
|
|
243
|
-
// losing its authorization came to need log forensics to explain.
|
|
244
|
-
log,
|
|
245
|
-
// Approval is proof of holding the endpoint token, compared the same way
|
|
246
|
-
// the request path compares it. There is one person behind this endpoint
|
|
247
|
-
// and they already have exactly one credential; a second one invented for
|
|
248
|
-
// the consent screen would be a password to lose.
|
|
249
|
-
verifyOwner: async (presented) => {
|
|
250
|
-
const token = await primary.credentials.get(expected);
|
|
251
|
-
return token !== null && tokensMatch(presented, token);
|
|
252
|
-
},
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
return {
|
|
256
|
-
surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
|
|
257
|
-
authenticator: new IssuedTokenAuthenticator(store, profile),
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
199
|
|
|
261
200
|
export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
|
|
262
201
|
const reporter = options.reporter ?? SILENT;
|
|
@@ -278,13 +217,26 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
278
217
|
if (!token) {
|
|
279
218
|
throw new Error(
|
|
280
219
|
`No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
|
|
281
|
-
'A deployed instance never mints its own — run `lanes link token rotate --
|
|
220
|
+
'A deployed instance never mints its own — run `lanes link token rotate --workspace <name>` ' +
|
|
282
221
|
'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
|
|
283
222
|
);
|
|
284
223
|
}
|
|
285
224
|
}
|
|
286
225
|
|
|
287
|
-
|
|
226
|
+
// Read through a holder rather than closed over `runtimes`, because a
|
|
227
|
+
// reload replaces that map and the gate is deliberately built once
|
|
228
|
+
// (ADR-029). Without the indirection, a member added after start would
|
|
229
|
+
// stay invisible until the endpoint was restarted — which is precisely the
|
|
230
|
+
// thing `profile members add` tells the operator has taken effect.
|
|
231
|
+
let serving: ReadonlyMap<string, Runtime> = runtimes;
|
|
232
|
+
|
|
233
|
+
const gate = await openAuthorization(primary, log, async (subject) =>
|
|
234
|
+
[...serving]
|
|
235
|
+
.filter(([, runtime]) =>
|
|
236
|
+
runtime.config.members.some((member) => member.subject === subject),
|
|
237
|
+
)
|
|
238
|
+
.map(([name]) => name),
|
|
239
|
+
);
|
|
288
240
|
|
|
289
241
|
// The authenticator and the authorization gate are built once, from the
|
|
290
242
|
// runtime this endpoint booted with, and are deliberately not part of what
|
|
@@ -295,6 +247,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
295
247
|
{ profiles: profileRuntimes(runtimes), close: () => closeAll(runtimes).then(() => {}) },
|
|
296
248
|
async (): Promise<OpenedWorkspace> => {
|
|
297
249
|
const reopened = await openReconciled(options);
|
|
250
|
+
serving = reopened.runtimes;
|
|
298
251
|
return {
|
|
299
252
|
profiles: profileRuntimes(reopened.runtimes),
|
|
300
253
|
close: () => closeAll(reopened.runtimes).then(() => {}),
|
|
@@ -312,6 +265,12 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
312
265
|
{ primary: primary.resolution.profile, log, ...(gate ? { remoteClients: true } : {}) },
|
|
313
266
|
);
|
|
314
267
|
|
|
268
|
+
// Read once. `version()` walks up to the install root and parses
|
|
269
|
+
// `package.json`; doing it per request would put a synchronous file read on
|
|
270
|
+
// the read surface's hot path to answer a value that cannot change while
|
|
271
|
+
// this process lives.
|
|
272
|
+
const runningVersion = version();
|
|
273
|
+
|
|
315
274
|
const server = serve({
|
|
316
275
|
generations,
|
|
317
276
|
primary: primary.resolution.profile,
|
|
@@ -322,20 +281,46 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
322
281
|
...(gate ? { authorization: gate.surface } : {}),
|
|
323
282
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
324
283
|
...(options.host !== undefined ? { host: options.host } : {}),
|
|
284
|
+
// Offered unconditionally and discarded by `serve()` on a loopback bind,
|
|
285
|
+
// which is where every other property of the bind address is decided. It
|
|
286
|
+
// opens nothing and reads no credential, so building it for a bind that
|
|
287
|
+
// will not use it costs a closure (ADR-064).
|
|
288
|
+
read: deployedReadDeps({
|
|
289
|
+
primary,
|
|
290
|
+
profiles: () => generations.current.profiles,
|
|
291
|
+
log,
|
|
292
|
+
version: runningVersion,
|
|
293
|
+
}),
|
|
325
294
|
});
|
|
326
295
|
|
|
327
296
|
// After `serve()`, so the record means the socket is bound. Recording it
|
|
328
297
|
// from the constructor claimed an endpoint that a failed bind never served.
|
|
329
298
|
generations.announce();
|
|
330
299
|
|
|
300
|
+
// Only when `lanes link pair` has provisioned all three. Absent, this is
|
|
301
|
+
// simply not served — the read surface is opt-in and its absence is the
|
|
302
|
+
// default (ADR-063), so an endpoint that was never paired binds one port
|
|
303
|
+
// exactly as it always did.
|
|
304
|
+
const read = await openReadListener(
|
|
305
|
+
primary,
|
|
306
|
+
server,
|
|
307
|
+
() => generations.current.profiles,
|
|
308
|
+
log,
|
|
309
|
+
runningVersion,
|
|
310
|
+
);
|
|
311
|
+
|
|
331
312
|
return {
|
|
332
313
|
url: server.url,
|
|
333
314
|
profiles: [...runtimes.keys()],
|
|
315
|
+
...(read ? { readUrl: read.url } : {}),
|
|
334
316
|
// `server.stop()` closes the request handler, which closes whichever
|
|
335
317
|
// generation is current — and a generation owns the runtimes it opened.
|
|
336
318
|
// Closing `runtimes` here too would reach past a reload and close a set
|
|
337
319
|
// nothing is serving from any more.
|
|
338
|
-
stop: () =>
|
|
320
|
+
stop: async () => {
|
|
321
|
+
await read?.stop();
|
|
322
|
+
await server.stop();
|
|
323
|
+
},
|
|
339
324
|
};
|
|
340
325
|
} catch (error) {
|
|
341
326
|
await closeAll(runtimes);
|
package/src/server/generation.ts
CHANGED
|
@@ -189,7 +189,16 @@ export class Generation {
|
|
|
189
189
|
* a handler outliving its generation is the stale-config bug.
|
|
190
190
|
*/
|
|
191
191
|
handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
|
|
192
|
-
|
|
192
|
+
// The delegation list is part of the key, not just the identity.
|
|
193
|
+
//
|
|
194
|
+
// `Principal` gained `profiles` this release, and `mergeCapabilities` and
|
|
195
|
+
// `forProfile` both read it off the *captured* principal — so two tokens for
|
|
196
|
+
// one subject with different scopes hashed to one entry and whichever
|
|
197
|
+
// authorized first decided what the other could reach. Removing somebody
|
|
198
|
+
// from a profile and re-authorizing then served them the profile they had
|
|
199
|
+
// just lost, or refused one they still had, depending on order.
|
|
200
|
+
const reach = principal.profiles === undefined ? '*' : [...principal.profiles].sort().join(',');
|
|
201
|
+
const key = `${principal.id}\u0000${reach}\u0000${clientLabel ?? ''}`;
|
|
193
202
|
const existing = this.#handlers.get(key);
|
|
194
203
|
if (existing) return existing;
|
|
195
204
|
|
package/src/server/harness.ts
CHANGED
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
IssuedTokenAuthenticator,
|
|
6
6
|
OAuthServer,
|
|
7
7
|
OAuthStore,
|
|
8
|
-
|
|
8
|
+
type Federation,
|
|
9
9
|
} from '#auth';
|
|
10
10
|
import { oneProfile, type ProfileRuntime } from './mcp/index.ts';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type ConnectionConfig, parseConfig, type Config } from '#profile';
|
|
12
13
|
import { ProviderRegistry, toPolicyDocument } from '#registry';
|
|
13
14
|
import { Dispatcher } from '#dispatch';
|
|
14
15
|
import { createMemoryCredentials, createMemoryState } from '#stores/state/testing.ts';
|
|
@@ -33,26 +34,58 @@ import { serveOverStdio } from './stdio.ts';
|
|
|
33
34
|
* the right calls, and a mocked transport cannot demonstrate that.
|
|
34
35
|
*/
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The connections a harness config implies, derived from its grants.
|
|
39
|
+
*
|
|
40
|
+
* A test config declares grants and no `connections.yaml` — there is no
|
|
41
|
+
* workspace on disk to read one from. Deriving the rows from the grant refs
|
|
42
|
+
* keeps the harness honest about the only thing dispatch uses them for, which is
|
|
43
|
+
* resolving `<provider>.<id>` to a provider and an id. Anything richer (an
|
|
44
|
+
* account label, a credential ref) belongs to a real workspace and a test that
|
|
45
|
+
* needs one builds it.
|
|
46
|
+
*/
|
|
47
|
+
function harnessConnections(config: Config): ConnectionConfig[] {
|
|
48
|
+
return config.grants.map((grant) => {
|
|
49
|
+
const [provider = '', id = ''] = grant.connection.split('.');
|
|
50
|
+
return { provider, id, account: grant.connection };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
export const TEST_TOKEN = 'llk_test_token_value';
|
|
37
55
|
|
|
56
|
+
/** A signed-in person no profile lists. See the federation stub below. */
|
|
57
|
+
export const STRANGER = 'NOBODY_LISTS_THIS_PERSON';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A profile for the harness, from the `allow`/`deny` a test hands over.
|
|
61
|
+
*
|
|
62
|
+
* The two `example` accounts are the point: every test here is about a rule
|
|
63
|
+
* covering both, or one of them, so the harness gives each its own grant row
|
|
64
|
+
* carrying the same rules (ADR-058). That is what the flat block used to mean,
|
|
65
|
+
* which keeps every existing test asserting what it was written to assert.
|
|
66
|
+
*/
|
|
38
67
|
export function configFor(profile: string, port: number, policy: string): Config {
|
|
68
|
+
const rules = policy
|
|
69
|
+
.split('\n')
|
|
70
|
+
.filter((line) => line.trim().length > 0)
|
|
71
|
+
.map((line) => ` ${line.trim()}`)
|
|
72
|
+
.join('\n');
|
|
73
|
+
|
|
74
|
+
const grant = (id: string): string =>
|
|
75
|
+
` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
|
|
76
|
+
|
|
39
77
|
return parseConfig(`
|
|
40
|
-
contract:
|
|
78
|
+
contract: 4
|
|
41
79
|
instance:
|
|
42
80
|
profile: ${profile}
|
|
43
81
|
port: ${port}
|
|
44
82
|
limits:
|
|
45
83
|
requests_per_minute: 1000
|
|
46
84
|
upstream_calls_per_minute: 1000
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- id: b
|
|
52
|
-
provider: example
|
|
53
|
-
account: Scratch B
|
|
54
|
-
policy:
|
|
55
|
-
${policy}
|
|
85
|
+
grants:
|
|
86
|
+
${grant('a')}
|
|
87
|
+
${grant('b')}
|
|
88
|
+
members: []
|
|
56
89
|
`).config;
|
|
57
90
|
}
|
|
58
91
|
|
|
@@ -110,6 +143,15 @@ export interface HarnessOptions {
|
|
|
110
143
|
refreshSkills?: (registry: ProviderRegistry) => Promise<void>;
|
|
111
144
|
/** Serve the `self` authorization flow alongside the bearer token. */
|
|
112
145
|
authorization?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* The identity half of that flow, stubbed.
|
|
148
|
+
*
|
|
149
|
+
* The real one talks to lanes.sh and verifies a signature; a test that
|
|
150
|
+
* exercised it would be testing `AssertionVerifier`, which has its own file
|
|
151
|
+
* and its own key pair. What a harness test is about is what the endpoint
|
|
152
|
+
* does *with* an answer, so the answer is injected.
|
|
153
|
+
*/
|
|
154
|
+
federation?: Partial<Federation>;
|
|
113
155
|
/**
|
|
114
156
|
* What a reload re-reads, standing in for `openReconciled` over a workspace
|
|
115
157
|
* this harness does not have. Throwing is how the "a failed reload keeps
|
|
@@ -162,6 +204,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
162
204
|
|
|
163
205
|
const dispatcher = new Dispatcher({
|
|
164
206
|
config,
|
|
207
|
+
connections: harnessConnections(config),
|
|
208
|
+
oauthApps: [],
|
|
165
209
|
registry,
|
|
166
210
|
connectorFor: (providerId): AnyConnector | undefined => {
|
|
167
211
|
const entry = registry.get(providerId);
|
|
@@ -199,6 +243,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
199
243
|
policy: extraPolicy,
|
|
200
244
|
dispatcher: new Dispatcher({
|
|
201
245
|
config: extraConfig,
|
|
246
|
+
connections: harnessConnections(extraConfig),
|
|
247
|
+
oauthApps: [],
|
|
202
248
|
registry,
|
|
203
249
|
connectorFor: (providerId): AnyConnector | undefined => {
|
|
204
250
|
const entry = registry.get(providerId);
|
|
@@ -241,7 +287,19 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
241
287
|
accessTokenTtlMs: 3_600_000,
|
|
242
288
|
log,
|
|
243
289
|
...(options.now ? { now: options.now } : {}),
|
|
244
|
-
|
|
290
|
+
federation: {
|
|
291
|
+
consentUrl: 'https://lanes.example/link/authorize',
|
|
292
|
+
// Anything non-empty verifies, as the subject it spells. Enough to
|
|
293
|
+
// drive the flow, and obviously not a verifier.
|
|
294
|
+
verify: async (assertion) =>
|
|
295
|
+
assertion ? { subject: `lanes:${assertion}`, email: null } : null,
|
|
296
|
+
// One reserved spelling answers "no profile names them", because
|
|
297
|
+
// that refusal is a real branch — a person signs in successfully
|
|
298
|
+
// and still reaches nothing — and there has to be a way to drive it.
|
|
299
|
+
profilesFor: async (subject) =>
|
|
300
|
+
subject === `lanes:${STRANGER}` ? [] : [options.profile],
|
|
301
|
+
...options.federation,
|
|
302
|
+
},
|
|
245
303
|
}),
|
|
246
304
|
issuer: (origin: string) => origin,
|
|
247
305
|
mcpPath: '/mcp',
|