@lanes-sh/link 0.7.1 → 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/brand.ts +39 -10
- package/src/cli/callback-page.ts +37 -104
- 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 -75
- 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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
+
readConnections,
|
|
2
3
|
ConfigError,
|
|
3
4
|
listProfiles,
|
|
4
5
|
loadWorkspaceProfiles,
|
|
5
6
|
readRegistry,
|
|
7
|
+
resolveTargetWorkspace,
|
|
6
8
|
type WorkspaceProfiles,
|
|
7
9
|
} from '#profile';
|
|
8
10
|
import { rotatableCredentialRefsFor } from '#registry';
|
|
@@ -49,16 +51,25 @@ export async function servingProfiles(input: {
|
|
|
49
51
|
return { profiles: [...named], primary: named[0]! };
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
// **Where the profiles actually are, which is not this machine.** After
|
|
55
|
+
// ADR-052 a deployed target's profiles live in its workspace, so listing the
|
|
56
|
+
// local root answered with whatever happened to be here: an empty workspace
|
|
57
|
+
// holding nothing but a pointer refused a redeploy of a live endpoint
|
|
58
|
+
// outright, and a local profile the bucket does not hold would have been sent
|
|
59
|
+
// to a revision that cannot open it. A declaration resolves back to the local
|
|
60
|
+
// root, which is the right answer there — a first deploy is exactly the case
|
|
61
|
+
// where the profiles are still on this machine and about to be uploaded.
|
|
62
|
+
const where = await resolveTargetWorkspace(workspaceRoot, target).catch(() => workspaceRoot);
|
|
63
|
+
const living = await listProfiles(where);
|
|
53
64
|
|
|
54
65
|
if (living.length === 0) {
|
|
55
66
|
throw new ConfigError(
|
|
56
67
|
`No profile lives in "${target}", so there is no set to deploy.\n` +
|
|
57
68
|
' A first deploy creates the target, and has to be told which profile\n' +
|
|
58
69
|
' it belongs to:\n' +
|
|
59
|
-
` lanes link deploy --
|
|
70
|
+
` lanes link deploy --workspace ${target} --profile <name>\n\n` +
|
|
60
71
|
` If "${target}" was deployed before and the pointer to it was lost:\n` +
|
|
61
|
-
` lanes link sync targets --
|
|
72
|
+
` lanes link sync targets --workspace ${target} --discover`,
|
|
62
73
|
);
|
|
63
74
|
}
|
|
64
75
|
|
|
@@ -89,7 +100,7 @@ async function choosePrimary(
|
|
|
89
100
|
"of them owns the endpoint's token. One token opens the endpoint and\n" +
|
|
90
101
|
'reaches every profile behind it, so this cannot be picked for you.\n\n' +
|
|
91
102
|
` Name it once and it is remembered:\n` +
|
|
92
|
-
` lanes link deploy --
|
|
103
|
+
` lanes link deploy --workspace ${target} --profile ${declaring[0]!}` +
|
|
93
104
|
declaring
|
|
94
105
|
.slice(1)
|
|
95
106
|
.map((name) => ` --profile ${name}`)
|
|
@@ -97,73 +108,3 @@ async function choosePrimary(
|
|
|
97
108
|
);
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
/**
|
|
101
|
-
* A credential reference two profiles would both write, in one store.
|
|
102
|
-
*
|
|
103
|
-
* References are flat — `gmail/main`, not `personal/gmail/main` — and a target
|
|
104
|
-
* has one credential store, so two profiles deployed to the same project share
|
|
105
|
-
* a namespace. `https://lanes.sh/docs/link/configuration` admits this in an aside about
|
|
106
|
-
* removing a profile; deploying both at once is where it stops being an aside.
|
|
107
|
-
*
|
|
108
|
-
* The failure is silent and it is the bad kind: `personal`'s Gmail refresh
|
|
109
|
-
* token is overwritten by `work`'s, both profiles go on listing their own
|
|
110
|
-
* account in config, and the first symptom is one of them reading the other's
|
|
111
|
-
* mailbox. Nothing downstream can catch it, because by then there is one
|
|
112
|
-
* credential and it is valid.
|
|
113
|
-
*
|
|
114
|
-
* `profile/token` is deliberately not a collision. Every profile defaults to
|
|
115
|
-
* that ref and the endpoint has exactly one token by design — sharing it is
|
|
116
|
-
* what ADR-009 says happens, rather than an accident.
|
|
117
|
-
*/
|
|
118
|
-
export interface Collision {
|
|
119
|
-
readonly ref: string;
|
|
120
|
-
readonly profiles: string[];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function collidingRefs(
|
|
124
|
-
workspaceRoot: string,
|
|
125
|
-
profiles: readonly string[],
|
|
126
|
-
workspace?: WorkspaceProfiles,
|
|
127
|
-
): Promise<Collision[]> {
|
|
128
|
-
const loaded = (workspace ?? (await loadWorkspaceProfiles(workspaceRoot))).loaded.filter(
|
|
129
|
-
(entry) => profiles.includes(entry.profile),
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const owners = new Map<string, string[]>();
|
|
133
|
-
|
|
134
|
-
for (const entry of loaded) {
|
|
135
|
-
const registry = await buildRegistryWithWorkspace(workspaceRoot, entry.profile);
|
|
136
|
-
const refs = new Set<string>();
|
|
137
|
-
|
|
138
|
-
for (const connection of entry.config.connections) {
|
|
139
|
-
const manifest = registry.manifest(connection.provider);
|
|
140
|
-
for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
|
|
141
|
-
if (connection.credential_ref) refs.add(connection.credential_ref);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
for (const ref of refs) {
|
|
145
|
-
if (ref === entry.config.auth.token_ref) continue;
|
|
146
|
-
owners.set(ref, [...(owners.get(ref) ?? []), entry.profile]);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return [...owners.entries()]
|
|
151
|
-
.filter(([, names]) => names.length > 1)
|
|
152
|
-
.map(([ref, names]) => ({ ref, profiles: names.sort() }))
|
|
153
|
-
.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** The refusal, as a block, so the wording is testable without a deploy. */
|
|
157
|
-
export function collisionRefusal(found: readonly Collision[], target: string): string {
|
|
158
|
-
const rows = found.map((one) => ` ${one.ref} ${one.profiles.join(', ')}`).join('\n');
|
|
159
|
-
|
|
160
|
-
return (
|
|
161
|
-
`${found.length} credential reference(s) would be written by more than one\n` +
|
|
162
|
-
`profile into the one credential store "${target}" has:\n\n${rows}\n\n` +
|
|
163
|
-
' References are flat, so these are the same secret and the last deploy\n' +
|
|
164
|
-
' wins — after which one profile is reading the other\'s account, and\n' +
|
|
165
|
-
' both still name their own in config.\n\n' +
|
|
166
|
-
' Give the connections different ids, or deploy the profiles to targets\n' +
|
|
167
|
-
' in separate projects.'
|
|
168
|
-
);
|
|
169
|
-
}
|
|
@@ -69,14 +69,14 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
|
|
|
69
69
|
return createFileSecretStore({
|
|
70
70
|
path: workspacePath(
|
|
71
71
|
root,
|
|
72
|
-
declared.credentials.path ?? layout.credentials(
|
|
72
|
+
declared.credentials.path ?? layout.credentials(),
|
|
73
73
|
),
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
case 'gcp-secret-manager': {
|
|
77
77
|
if (!declared.credentials.project) {
|
|
78
78
|
throw new ConfigError(
|
|
79
|
-
`
|
|
79
|
+
`workspaces.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
const { GcpSecretManagerStore } = await import('./adapters/gcp-secret-manager.ts');
|
|
@@ -97,8 +97,8 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
|
|
|
97
97
|
* Its own root, `layout.state`, so it is not addressable from a provider's
|
|
98
98
|
* blob namespace — the same containment `openAudit` relies on.
|
|
99
99
|
*/
|
|
100
|
-
export function openState(storage: StorageFactory
|
|
101
|
-
return createRuntimeState(storage(layout.state(
|
|
100
|
+
export function openState(storage: StorageFactory): RuntimeState {
|
|
101
|
+
return createRuntimeState(storage(layout.state()));
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
@@ -115,8 +115,8 @@ export function openState(storage: StorageFactory, profile: string): RuntimeStat
|
|
|
115
115
|
* into (`layout.audit`), and that separation is what keeps ADR-007's wall
|
|
116
116
|
* intact.
|
|
117
117
|
*/
|
|
118
|
-
export function openAudit(storage: StorageFactory
|
|
119
|
-
return createBlobAuditStore({ storage: storage(layout.audit(
|
|
118
|
+
export function openAudit(storage: StorageFactory): AuditStore {
|
|
119
|
+
return createBlobAuditStore({ storage: storage(layout.audit()) });
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
|
@@ -137,7 +137,7 @@ export async function openAuditSinks(
|
|
|
137
137
|
secrets: SecretStore,
|
|
138
138
|
log?: (message: string) => void,
|
|
139
139
|
): Promise<{ sink: AuditSink; reader: AuditReader }> {
|
|
140
|
-
const durable = openAudit(storage
|
|
140
|
+
const durable = openAudit(storage);
|
|
141
141
|
const declared = input.declared.audit?.sinks ?? [];
|
|
142
142
|
if (declared.length === 0) return { sink: durable, reader: durable };
|
|
143
143
|
|
|
@@ -201,7 +201,7 @@ export async function openStorage(
|
|
|
201
201
|
switch (declared.storage.adapter) {
|
|
202
202
|
case 'filesystem': {
|
|
203
203
|
const { createFilesystemBlobStore } = await import('./adapters/filesystem.ts');
|
|
204
|
-
const base = declared.storage.path ?? layout.blobs(
|
|
204
|
+
const base = declared.storage.path ?? layout.blobs();
|
|
205
205
|
return (area) =>
|
|
206
206
|
createFilesystemBlobStore({ root: workspacePath(root, area === undefined ? base : area) });
|
|
207
207
|
}
|
|
@@ -214,12 +214,12 @@ export async function openStorage(
|
|
|
214
214
|
// by hand in a console.
|
|
215
215
|
const { bucket, prefix } = declared.storage;
|
|
216
216
|
if (!bucket) {
|
|
217
|
-
throw new ConfigError(`
|
|
217
|
+
throw new ConfigError(`workspaces.${target}.storage.bucket is required for the gcs adapter.`);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
const { createGcsBlobStore } = await import('./adapters/gcs.ts');
|
|
221
221
|
const base = prefix ?? '';
|
|
222
|
-
const root = layout.blobs(
|
|
222
|
+
const root = layout.blobs();
|
|
223
223
|
|
|
224
224
|
return (area) =>
|
|
225
225
|
createGcsBlobStore({
|
|
@@ -231,25 +231,25 @@ export async function openStorage(
|
|
|
231
231
|
case 's3': {
|
|
232
232
|
const { bucket, endpoint, region, prefix } = declared.storage;
|
|
233
233
|
if (!bucket) {
|
|
234
|
-
throw new ConfigError(`
|
|
234
|
+
throw new ConfigError(`workspaces.${target}.storage.bucket is required for the s3 adapter.`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
const accessKeyId = await requireSecret(
|
|
238
238
|
secrets,
|
|
239
239
|
declared.storage.access_key_id_ref,
|
|
240
|
-
`
|
|
240
|
+
`workspaces.${target}.storage.access_key_id_ref`,
|
|
241
241
|
target,
|
|
242
242
|
);
|
|
243
243
|
const secretAccessKey = await requireSecret(
|
|
244
244
|
secrets,
|
|
245
245
|
declared.storage.secret_access_key_ref,
|
|
246
|
-
`
|
|
246
|
+
`workspaces.${target}.storage.secret_access_key_ref`,
|
|
247
247
|
target,
|
|
248
248
|
);
|
|
249
249
|
|
|
250
250
|
const { createS3BlobStore } = await import('./adapters/s3.ts');
|
|
251
251
|
const base = prefix ?? '';
|
|
252
|
-
const root = layout.blobs(
|
|
252
|
+
const root = layout.blobs();
|
|
253
253
|
|
|
254
254
|
return (area) =>
|
|
255
255
|
createS3BlobStore({
|
|
@@ -289,7 +289,7 @@ export async function requireSecret(
|
|
|
289
289
|
if (!value) {
|
|
290
290
|
throw new ConfigError(
|
|
291
291
|
`${field} names "${ref}", which is not in this target's secret store. ` +
|
|
292
|
-
`Store it with: lanes link secrets set ${ref} --
|
|
292
|
+
`Store it with: lanes link secrets set ${ref} --workspace ${target}`,
|
|
293
293
|
);
|
|
294
294
|
}
|
|
295
295
|
return value;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
openTarget,
|
|
3
3
|
DATA_DIR,
|
|
4
|
+
CONNECTIONS_FILE,
|
|
4
5
|
WORKSPACE_FILE,
|
|
5
6
|
layout,
|
|
6
7
|
listProfiles,
|
|
@@ -55,13 +56,18 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
|
|
|
55
56
|
* forgets means an endpoint that will not boot, and a credential this includes
|
|
56
57
|
* by accident means a credential in a bucket. Forgetting is loud.
|
|
57
58
|
*
|
|
58
|
-
* **Two areas inside `data/` are authored rather than accumulated
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
59
|
+
* **Two areas inside `data/` are authored rather than accumulated.** Skills and
|
|
60
|
+
* provider manifests have to go up or a deployed instance loses both — the
|
|
61
|
+
* regression ADR-014 §2 fixed for skills. So this reaches into `data/` for
|
|
62
|
+
* exactly those two, by whole path segment and never by prefix:
|
|
63
|
+
* `data/skills.detour/` is not `skills.d`, and the difference between matching
|
|
64
|
+
* it and not is a credential in a bucket.
|
|
65
|
+
*
|
|
66
|
+
* Neither is filtered by the profile set any more. Both are keyed by connection
|
|
67
|
+
* now rather than by profile (ADR-057, ADR-059), and a connection can be
|
|
68
|
+
* granted by any profile in the workspace — so sending "only this profile's
|
|
69
|
+
* skills" is not a thing that can be computed, and withholding them would
|
|
70
|
+
* deploy an endpoint whose prompts are missing.
|
|
65
71
|
*/
|
|
66
72
|
export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
|
|
67
73
|
// **Never the workspace file.** It was sent, and it is the one file that must
|
|
@@ -74,37 +80,45 @@ export function isWorkspaceConfig(key: string, profiles?: readonly string[]): bo
|
|
|
74
80
|
// the upload is done.
|
|
75
81
|
if (key === WORKSPACE_FILE) return false;
|
|
76
82
|
|
|
83
|
+
// **The connections file always goes up.** It is configuration, the endpoint
|
|
84
|
+
// cannot resolve a single grant without it, and unlike the registry above
|
|
85
|
+
// there is nothing machine-specific in it — a connection is a connection
|
|
86
|
+
// wherever the workspace is read from (ADR-057).
|
|
87
|
+
if (key === CONNECTIONS_FILE) return true;
|
|
88
|
+
|
|
89
|
+
if (isAuthoredArea(key)) return true;
|
|
90
|
+
|
|
77
91
|
// A set rather than one name, because a deploy now sends every profile that
|
|
78
92
|
// declares the target rather than the single one it was told. `undefined`
|
|
79
93
|
// still means the whole workspace, and an *empty* set means nothing — which
|
|
80
94
|
// is a distinction a bare string could not make.
|
|
81
95
|
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
82
96
|
|
|
83
|
-
const owner = authoredAreaOwner(key);
|
|
84
|
-
if (owner !== null) return wanted === undefined || wanted.has(owner);
|
|
85
|
-
|
|
86
97
|
if (!key.startsWith('profiles/') || !key.endsWith('.yaml')) return false;
|
|
87
98
|
const name = key.slice('profiles/'.length, -'.yaml'.length);
|
|
88
99
|
return wanted === undefined || wanted.has(name);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
/**
|
|
92
|
-
*
|
|
103
|
+
* Whether `key` is inside one of the two authored areas.
|
|
93
104
|
*
|
|
94
105
|
* Composed back out of `layout` rather than compared against literals, so a
|
|
95
106
|
* renamed directory moves both this and the store that reads it, or neither.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
107
|
+
*
|
|
108
|
+
* The segment counts differ because the layouts do, and both are the "a
|
|
109
|
+
* directory is not a file to send" rule: a manifest is `data/providers.d/<file>`
|
|
110
|
+
* and a skill is `data/skills.d/<connection>/<file>`, so requiring one more
|
|
111
|
+
* segment than the area itself has is what stops the bare directory key
|
|
112
|
+
* matching.
|
|
98
113
|
*/
|
|
99
|
-
function
|
|
114
|
+
function isAuthoredArea(key: string): boolean {
|
|
100
115
|
const segments = key.split('/');
|
|
101
|
-
if (segments.length <
|
|
102
|
-
|
|
103
|
-
const owner = segments[1]!;
|
|
104
|
-
if (owner.length === 0) return null;
|
|
116
|
+
if (segments.length < 3 || segments[0] !== DATA_DIR) return false;
|
|
105
117
|
|
|
106
|
-
const area = `${DATA_DIR}/${
|
|
107
|
-
|
|
118
|
+
const area = `${DATA_DIR}/${segments[1]}`;
|
|
119
|
+
if (area === layout.providers()) return segments.length >= 3;
|
|
120
|
+
if (area === layout.skillsRoot()) return segments.length >= 4;
|
|
121
|
+
return false;
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
/**
|
|
@@ -157,7 +171,16 @@ export async function publishWorkspace(input: {
|
|
|
157
171
|
readonly config: Config;
|
|
158
172
|
readonly workspaceRoot: string;
|
|
159
173
|
readonly target: string;
|
|
160
|
-
|
|
174
|
+
/**
|
|
175
|
+
* The profiles this edit touched, not just the one it was run under.
|
|
176
|
+
*
|
|
177
|
+
* `disconnect` removes a connection from the workspace and drops the grant
|
|
178
|
+
* from *every* profile that named it, so publishing one profile left the
|
|
179
|
+
* bucket holding a `connections.yaml` without the connection and a sibling
|
|
180
|
+
* profile still granting it — which `assertGrantsResolve` refuses at load, so
|
|
181
|
+
* `openReconciled` skipped that profile and it silently stopped being served.
|
|
182
|
+
*/
|
|
183
|
+
readonly profile: string | readonly string[];
|
|
161
184
|
}): Promise<string | null> {
|
|
162
185
|
// Resolution failures are swallowed rather than thrown. The config edit that
|
|
163
186
|
// called this has already succeeded and is on disk; a target that cannot be
|
|
@@ -173,6 +196,7 @@ export async function publishWorkspace(input: {
|
|
|
173
196
|
const destination = deployedWorkspace(declared);
|
|
174
197
|
if (!destination) return null;
|
|
175
198
|
|
|
176
|
-
|
|
199
|
+
const touched = typeof input.profile === 'string' ? [input.profile] : input.profile;
|
|
200
|
+
await uploadWorkspace(input.workspaceRoot, destination, touched);
|
|
177
201
|
return destination;
|
|
178
202
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Principal } from '#auth';
|
|
2
|
+
import type { AuditSink } from '#audit';
|
|
3
|
+
import type { PolicyDocument, ProfilePolicy, RateLimiter } from '#policy';
|
|
4
|
+
import type { Config, ConnectionConfig } from '#profile';
|
|
5
|
+
import type { ProviderRegistry } from '#registry';
|
|
6
|
+
import type { SecretStore } from '#secrets';
|
|
7
|
+
import type { BlobStore } from '#stores/blobs';
|
|
8
|
+
import type { RuntimeState } from '#stores/state';
|
|
9
|
+
import type { AnyConnector } from '#connectivity';
|
|
10
|
+
import type { Logger } from '#connectivity';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the dispatcher is given, and what it is asked.
|
|
14
|
+
*
|
|
15
|
+
* Split from the dispatcher itself so that file stays inside the size budget.
|
|
16
|
+
* The seam is real rather than arithmetic: everything here is what a *caller*
|
|
17
|
+
* assembles, and the class next door is the fixed order in which a call is
|
|
18
|
+
* authorised and run. Reading one has never required reading the other.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The dispatch path.
|
|
23
|
+
*
|
|
24
|
+
* Every invocation goes through here in one fixed order — authenticate,
|
|
25
|
+
* resolve, authorize, rate-limit, dispatch, audit — with no way around it.
|
|
26
|
+
* The ordering is not stylistic: policy is evaluated before a provider is
|
|
27
|
+
* reached, so a provider never sees a request it was not authorised to serve,
|
|
28
|
+
* and authorization is never something provider code could get wrong.
|
|
29
|
+
*
|
|
30
|
+
* Exactly one audit event is written per invocation, on every path including
|
|
31
|
+
* denials. That is enforced structurally by `finally` rather than by
|
|
32
|
+
* remembering to call it at each return.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface DispatchDeps {
|
|
36
|
+
readonly config: Config;
|
|
37
|
+
/**
|
|
38
|
+
* The accounts this workspace holds, and the clients it registered (ADR-057).
|
|
39
|
+
*
|
|
40
|
+
* Separate from `config` because neither is the profile's to declare. The
|
|
41
|
+
* *gate* stays policy: a connection that exists and is not granted resolves
|
|
42
|
+
* and is then denied `denied_default`, which is the same answer a caller gets
|
|
43
|
+
* for one that does not exist — two shapes of "no" that read alike, so probing
|
|
44
|
+
* is not an oracle for what the workspace holds.
|
|
45
|
+
*/
|
|
46
|
+
readonly connections: readonly ConnectionConfig[];
|
|
47
|
+
readonly oauthApps: readonly string[];
|
|
48
|
+
readonly registry: ProviderRegistry;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the connector for a provider. Supplied by the caller so core does
|
|
51
|
+
* not import connector implementations, keeping the dependency direction
|
|
52
|
+
* intact: infrastructure -> sdk -> core -> connectors.
|
|
53
|
+
*/
|
|
54
|
+
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
55
|
+
/** Attach whatever the manifest's auth kind requires to an outbound request. */
|
|
56
|
+
readonly authorizeRequest?: (
|
|
57
|
+
providerId: string,
|
|
58
|
+
connectionId: string,
|
|
59
|
+
request: Request,
|
|
60
|
+
) => Promise<Request>;
|
|
61
|
+
readonly policy: ProfilePolicy;
|
|
62
|
+
/** Optional instance floor. Empty in M1; composition is tighten-only. */
|
|
63
|
+
readonly floor?: PolicyDocument;
|
|
64
|
+
readonly state: RuntimeState;
|
|
65
|
+
/**
|
|
66
|
+
* Where events go. Separate from `state` because the log is no longer in
|
|
67
|
+
* it — and because dispatch only ever writes, so taking the sink rather than
|
|
68
|
+
* the whole store means this path structurally cannot read the log back.
|
|
69
|
+
*/
|
|
70
|
+
readonly audit: AuditSink;
|
|
71
|
+
readonly credentials: SecretStore;
|
|
72
|
+
readonly storage: BlobStore;
|
|
73
|
+
readonly limiter: RateLimiter;
|
|
74
|
+
readonly log: Logger;
|
|
75
|
+
readonly now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DispatchRequest {
|
|
79
|
+
readonly principal: Principal;
|
|
80
|
+
/** Fully qualified, e.g. `example.echo`. */
|
|
81
|
+
readonly capabilityId: string;
|
|
82
|
+
/** Fully qualified, e.g. `example.a`. */
|
|
83
|
+
readonly connectionKey: string;
|
|
84
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
85
|
+
/** Self-reported by the client. Observability only — never authorization. */
|
|
86
|
+
readonly clientLabel?: string | undefined;
|
|
87
|
+
readonly signal?: AbortSignal;
|
|
88
|
+
}
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { AuditDraft, AuditLogger, AuditSink, AuthorizationResult } from '#audit';
|
|
2
2
|
import { keepKeys, redactAllValues } from '#audit';
|
|
3
|
-
import type
|
|
3
|
+
import { mayReach, type Principal } from '#auth';
|
|
4
4
|
import type { SecretStore } from '#secrets';
|
|
5
5
|
import type { RuntimeState } from '#stores/state';
|
|
6
|
-
import type { PolicyDocument } from '#policy';
|
|
6
|
+
import type { PolicyDocument, ProfilePolicy } from '#policy';
|
|
7
7
|
import { RateLimiter, evaluate } from '#policy';
|
|
8
8
|
import type { BlobStore } from '#stores/blobs';
|
|
9
9
|
import type {
|
|
@@ -14,69 +14,14 @@ import type {
|
|
|
14
14
|
Logger,
|
|
15
15
|
} from '#connectivity';
|
|
16
16
|
import { isToolResult, strategyContextFrom, strategyFor } from '#connectivity';
|
|
17
|
-
import type { Config } from '#profile';
|
|
17
|
+
import type { Config, ConnectionConfig } from '#profile';
|
|
18
18
|
import { buildProviderContext, createProviderLogger } from './context.ts';
|
|
19
19
|
import { fetchStaged, stageAttachment } from './staging.ts';
|
|
20
20
|
import type { FetchStagedRequest, StagedAttachment, StageRequest } from './staging.ts';
|
|
21
21
|
import type { ProviderRegistry } from '#registry';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
* Every invocation goes through here in one fixed order — authenticate,
|
|
27
|
-
* resolve, authorize, rate-limit, dispatch, audit — with no way around it.
|
|
28
|
-
* The ordering is not stylistic: policy is evaluated before a provider is
|
|
29
|
-
* reached, so a provider never sees a request it was not authorised to serve,
|
|
30
|
-
* and authorization is never something provider code could get wrong.
|
|
31
|
-
*
|
|
32
|
-
* Exactly one audit event is written per invocation, on every path including
|
|
33
|
-
* denials. That is enforced structurally by `finally` rather than by
|
|
34
|
-
* remembering to call it at each return.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
export interface DispatchDeps {
|
|
38
|
-
readonly config: Config;
|
|
39
|
-
readonly registry: ProviderRegistry;
|
|
40
|
-
/**
|
|
41
|
-
* Resolve the connector for a provider. Supplied by the caller so core does
|
|
42
|
-
* not import connector implementations, keeping the dependency direction
|
|
43
|
-
* intact: infrastructure -> sdk -> core -> connectors.
|
|
44
|
-
*/
|
|
45
|
-
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
46
|
-
/** Attach whatever the manifest's auth kind requires to an outbound request. */
|
|
47
|
-
readonly authorizeRequest?: (
|
|
48
|
-
providerId: string,
|
|
49
|
-
connectionId: string,
|
|
50
|
-
request: Request,
|
|
51
|
-
) => Promise<Request>;
|
|
52
|
-
readonly policy: PolicyDocument;
|
|
53
|
-
/** Optional instance floor. Empty in M1; composition is tighten-only. */
|
|
54
|
-
readonly floor?: PolicyDocument;
|
|
55
|
-
readonly state: RuntimeState;
|
|
56
|
-
/**
|
|
57
|
-
* Where events go. Separate from `state` because the log is no longer in
|
|
58
|
-
* it — and because dispatch only ever writes, so taking the sink rather than
|
|
59
|
-
* the whole store means this path structurally cannot read the log back.
|
|
60
|
-
*/
|
|
61
|
-
readonly audit: AuditSink;
|
|
62
|
-
readonly credentials: SecretStore;
|
|
63
|
-
readonly storage: BlobStore;
|
|
64
|
-
readonly limiter: RateLimiter;
|
|
65
|
-
readonly log: Logger;
|
|
66
|
-
readonly now?: () => number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface DispatchRequest {
|
|
70
|
-
readonly principal: Principal;
|
|
71
|
-
/** Fully qualified, e.g. `example.echo`. */
|
|
72
|
-
readonly capabilityId: string;
|
|
73
|
-
/** Fully qualified, e.g. `example.a`. */
|
|
74
|
-
readonly connectionKey: string;
|
|
75
|
-
readonly arguments: Readonly<Record<string, unknown>>;
|
|
76
|
-
/** Self-reported by the client. Observability only — never authorization. */
|
|
77
|
-
readonly clientLabel?: string | undefined;
|
|
78
|
-
readonly signal?: AbortSignal;
|
|
79
|
-
}
|
|
23
|
+
export type { DispatchDeps, DispatchRequest } from './deps.ts';
|
|
24
|
+
import type { DispatchDeps, DispatchRequest } from './deps.ts';
|
|
80
25
|
|
|
81
26
|
/**
|
|
82
27
|
* `result` is a union because a capability is not necessarily a tool — a
|
|
@@ -198,7 +143,21 @@ export class Dispatcher {
|
|
|
198
143
|
|
|
199
144
|
auditArguments = (redact ?? redactAllValues)(request.arguments);
|
|
200
145
|
|
|
201
|
-
|
|
146
|
+
// **Who, before what.** A caller reaches a profile only if that profile's
|
|
147
|
+
// `members:` names them (ADR-060), and this is checked before the
|
|
148
|
+
// connection is even resolved: a refusal that first said "no such
|
|
149
|
+
// connection" would answer a question the caller was not entitled to ask.
|
|
150
|
+
// The profile is the principal's: the MCP layer routes on the `profile`
|
|
151
|
+
// argument and builds the principal for it, so this is the same value the
|
|
152
|
+
// caller named and the one the audit event records.
|
|
153
|
+
if (!mayReach(request.principal, request.principal.profile)) {
|
|
154
|
+
return (outcome = deny(
|
|
155
|
+
'denied_default',
|
|
156
|
+
`Profile ${request.principal.profile} is not available`,
|
|
157
|
+
));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const declared = this.#deps.connections.find(
|
|
202
161
|
(connection) => `${connection.provider}.${connection.id}` === request.connectionKey,
|
|
203
162
|
);
|
|
204
163
|
if (!declared || declared.provider !== providerId) {
|
|
@@ -311,7 +270,7 @@ export class Dispatcher {
|
|
|
311
270
|
// Which vendors this profile registered a client of its own for. A
|
|
312
271
|
// provider that could be brokered but is not reads its client from the
|
|
313
272
|
// store, so it has to be able to.
|
|
314
|
-
ownClients:
|
|
273
|
+
ownClients: this.#deps.oauthApps,
|
|
315
274
|
...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
|
|
316
275
|
});
|
|
317
276
|
|
package/src/policy/index.ts
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* withheld. The floor is empty in M1 — the invariant is implemented
|
|
15
15
|
* anyway, because it is what makes delegated access safe to add later,
|
|
16
16
|
* and retrofitting it once rules exist in the wild is not possible.
|
|
17
|
+
*
|
|
18
|
+
* Since contract 3 a profile's rules are grouped by the connection they govern
|
|
19
|
+
* (ADR-058). Both invariants are unchanged and one is now sharper: a connection
|
|
20
|
+
* with no rules at all is not merely unmatched, it is absent, so default deny
|
|
21
|
+
* bites before a capability is even considered.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
export type { AuthorizationResult } from '#audit';
|
|
@@ -43,6 +48,26 @@ export interface PolicyDocument {
|
|
|
43
48
|
|
|
44
49
|
export const EMPTY_POLICY: PolicyDocument = { rules: [] };
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A profile's rules, grouped by the connection each governs (ADR-058).
|
|
53
|
+
*
|
|
54
|
+
* A map rather than one list carrying a connection per rule, because the
|
|
55
|
+
* lookup is the decision: a call names exactly one connection, and the rules
|
|
56
|
+
* for any other are not evidence about it. Flattening them into one list would
|
|
57
|
+
* mean every evaluation re-filtered, and a filter that was ever wrong would let
|
|
58
|
+
* one account's `allow` answer for another.
|
|
59
|
+
*
|
|
60
|
+
* A connection absent from the map is not granted. That is default deny on the
|
|
61
|
+
* connection axis, and it is why `grants:` with an empty `allow` is a different
|
|
62
|
+
* thing from no row at all — the first says "reachable, nothing permitted", the
|
|
63
|
+
* second says "not reachable".
|
|
64
|
+
*/
|
|
65
|
+
export interface ProfilePolicy {
|
|
66
|
+
readonly byConnection: ReadonlyMap<string, PolicyDocument>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const EMPTY_PROFILE_POLICY: ProfilePolicy = { byConnection: new Map() };
|
|
70
|
+
|
|
46
71
|
export interface PolicyRequest {
|
|
47
72
|
/**
|
|
48
73
|
* The authenticated principal. One per profile in M1 (the owner). Carried
|
|
@@ -131,14 +156,21 @@ export function evaluateDocument(
|
|
|
131
156
|
*/
|
|
132
157
|
export function evaluate(
|
|
133
158
|
request: PolicyRequest,
|
|
134
|
-
profile:
|
|
159
|
+
profile: ProfilePolicy,
|
|
135
160
|
floor?: PolicyDocument,
|
|
136
161
|
): PolicyDecision {
|
|
137
162
|
if (floor) {
|
|
138
163
|
const floorDecision = evaluateDocument(floor, request);
|
|
139
164
|
if (!floorDecision.allowed) return floorDecision;
|
|
140
165
|
}
|
|
141
|
-
|
|
166
|
+
|
|
167
|
+
// The floor is still evaluated against the whole request, because a floor is
|
|
168
|
+
// an instance-wide narrowing and knows nothing about which connections a
|
|
169
|
+
// profile happens to hold. Only the profile's half is per connection.
|
|
170
|
+
const granted = profile.byConnection.get(request.connection);
|
|
171
|
+
if (granted === undefined) return { allowed: false, reason: 'denied_default' };
|
|
172
|
+
|
|
173
|
+
return evaluateDocument(granted, request);
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
/**
|
|
@@ -153,7 +185,7 @@ export function allowedConnections(
|
|
|
153
185
|
capability: string,
|
|
154
186
|
connections: readonly string[],
|
|
155
187
|
principal: string,
|
|
156
|
-
profile:
|
|
188
|
+
profile: ProfilePolicy,
|
|
157
189
|
floor?: PolicyDocument,
|
|
158
190
|
at?: Date,
|
|
159
191
|
): string[] {
|
|
@@ -164,16 +196,16 @@ export function allowedConnections(
|
|
|
164
196
|
const provider = capability.slice(0, capability.indexOf('.'));
|
|
165
197
|
const ofProvider = connections.filter((connection) => connection.startsWith(`${provider}.`));
|
|
166
198
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
199
|
+
// One evaluation per account, where this used to be all-or-nothing. That
|
|
200
|
+
// difference is the whole of ADR-058 at the discovery boundary: a profile
|
|
201
|
+
// holding two mailboxes with different rules now advertises the `connection`
|
|
202
|
+
// enum each capability actually permits, instead of offering both wherever
|
|
203
|
+
// either was allowed.
|
|
204
|
+
return ofProvider.filter((connection) => {
|
|
205
|
+
const request: PolicyRequest = at
|
|
206
|
+
? { principal, capability, connection, at }
|
|
207
|
+
: { principal, capability, connection };
|
|
208
|
+
|
|
209
|
+
return evaluate(request, profile, floor).allowed;
|
|
210
|
+
});
|
|
179
211
|
}
|