@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
|
@@ -14,10 +14,10 @@ import { recordDeployment, type DeploymentRecord } from './record.ts';
|
|
|
14
14
|
import { printSteps, runSteps } from './steps.ts';
|
|
15
15
|
import { driverFor } from './drivers.ts';
|
|
16
16
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
17
|
-
import { repairOwnerLayer } from '#cli/config-repair.ts';
|
|
18
|
-
import {
|
|
17
|
+
import { repairOwnerLayer } from '#cli/config-repair-sweep.ts';
|
|
18
|
+
import { migrateToCurrentContract } from '#cli/workspace-migrate.ts';
|
|
19
19
|
import { deployedWorkspace, uploadWorkspace } from './upload.ts';
|
|
20
|
-
import {
|
|
20
|
+
import { servingProfiles } from './serving.ts';
|
|
21
21
|
import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -75,9 +75,14 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
75
75
|
// declare anything: it reads this machine's pointer and stops. So the bucket is
|
|
76
76
|
// located, migrated, and only then opened.
|
|
77
77
|
//
|
|
78
|
-
// Idempotent, and silent on a workspace already
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// Idempotent, and silent on a workspace already current — a listing and no
|
|
79
|
+
// writes. `--dry-run` reports what it would do and writes nothing, like every
|
|
80
|
+
// other step of this command.
|
|
81
|
+
//
|
|
82
|
+
// It goes all the way to `SUPPORTED_CONTRACT`, which it did not use to: this
|
|
83
|
+
// ran the contract 1→2 step alone, so a contract-2 bucket passed straight
|
|
84
|
+
// through and the revision came up reading an empty `data/` while the bytes
|
|
85
|
+
// stayed under `data/<profile>/`. See `migrateToCurrentContract`.
|
|
81
86
|
if (!(await migrateTargetWorkspace(requireTargetFlag(flags), flags.dryRun !== true))) return;
|
|
82
87
|
|
|
83
88
|
// The one command allowed to name a target that does not exist yet: creating
|
|
@@ -112,14 +117,13 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
112
117
|
print(style.dim(` serving ${serving.join(', ')} — ${primary} owns the token`));
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// no later symptom worth
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
120
|
+
// The credential-collision preflight is gone. It existed because two profiles
|
|
121
|
+
// each held their own `gmail.main` and both derived the flat ref `gmail/main`
|
|
122
|
+
// into one store — the last deploy winning, with no later symptom worth
|
|
123
|
+
// having. A connection belongs to the workspace now and `<provider>.<id>` is
|
|
124
|
+
// unique by construction (ADR-057), so the state it guarded against cannot be
|
|
125
|
+
// written: `assertConnectionsUnique` refuses it at load, before a deploy runs
|
|
126
|
+
// at all.
|
|
123
127
|
|
|
124
128
|
// `check` before anything external, per the gate order: a config that will be
|
|
125
129
|
// rejected on boot should be rejected here, not after a five-minute build.
|
|
@@ -150,7 +154,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
150
154
|
// below are, and read from config and manifests before anything opens a
|
|
151
155
|
// store — `--dry-run` must reach the printed step list without touching a
|
|
152
156
|
// credential.
|
|
153
|
-
const rotatable = await rotatableRefs(resolution.workspaceRoot, serving);
|
|
157
|
+
const rotatable = await rotatableRefs(resolution.workspaceRoot, serving, declared);
|
|
154
158
|
const readable = await readableRefs(resolution.workspaceRoot, serving, declared);
|
|
155
159
|
const provision = await driver.provision({
|
|
156
160
|
deploy: deployConfig,
|
|
@@ -225,7 +229,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
225
229
|
await runSteps(driver, provision);
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
const credentials = await openSecretStoreFor(
|
|
232
|
+
const credentials = await openSecretStoreFor(resolution.workspaceRoot, target);
|
|
229
233
|
const prepared = await prepareSecrets({
|
|
230
234
|
config,
|
|
231
235
|
declared,
|
|
@@ -240,7 +244,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
240
244
|
for (const problem of prepared.blocking) print(fail(problem));
|
|
241
245
|
throw new ConfigError(
|
|
242
246
|
'The deployed instance cannot start without these. Store them with ' +
|
|
243
|
-
`lanes link secrets set <ref> --profile ${resolution.profile} --
|
|
247
|
+
`lanes link secrets set <ref> --profile ${resolution.profile} --workspace ${target}, or ` +
|
|
244
248
|
`copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
|
|
245
249
|
`--from local --to ${target}.`,
|
|
246
250
|
);
|
|
@@ -275,6 +279,17 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
275
279
|
// workspace that this target cannot open, and nothing left to check.
|
|
276
280
|
await repairOwnerLayer(resolution.workspaceRoot, serving);
|
|
277
281
|
|
|
282
|
+
// **The bucket's own contract, before anything is written over it.**
|
|
283
|
+
//
|
|
284
|
+
// The pass at the top of the command resolves the target through this
|
|
285
|
+
// machine's pointer, which a *first* deploy does not have: the entry is
|
|
286
|
+
// still a declaration, `resolveTargetWorkspace` answers with the local root,
|
|
287
|
+
// and it returns early. So this is the only pass that ever sees an existing
|
|
288
|
+
// bucket at contract 2 — and running it after the upload is the same as not
|
|
289
|
+
// running it, because the upload writes contract-3 profiles and
|
|
290
|
+
// `needsContract3` reads the profiles.
|
|
291
|
+
await migrateToCurrentContract(workspace, { apply: true, target });
|
|
292
|
+
|
|
278
293
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
279
294
|
// Uploading after would leave a window where the service is serving and the
|
|
280
295
|
// workspace it was told to read is not there yet.
|
|
@@ -291,15 +306,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
291
306
|
await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
|
|
292
307
|
}
|
|
293
308
|
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
// migrated whatever the bucket already held; this catches what the upload
|
|
298
|
-
// just put there — profiles from a workspace that is itself at contract 2
|
|
299
|
-
// arrive migrated, but a first deploy of a *newly created* bucket writes
|
|
300
|
-
// them here for the first time. Idempotent, so the ordinary case is one
|
|
301
|
-
// listing and no writes.
|
|
302
|
-
await migrateWorkspace(workspace, { apply: true });
|
|
309
|
+
// Again for what the upload put there: a newly created bucket gets its
|
|
310
|
+
// profiles written here for the first time. Idempotent — one listing.
|
|
311
|
+
await migrateToCurrentContract(workspace, { apply: true, target });
|
|
303
312
|
|
|
304
313
|
// Where this deployment lives, in both registries — see `record.ts`. The
|
|
305
314
|
// declaration has to land before the revision boots, so it goes here rather
|
|
@@ -325,7 +334,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
325
334
|
if (!url) {
|
|
326
335
|
print(
|
|
327
336
|
warn(
|
|
328
|
-
`deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --
|
|
337
|
+
`deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --workspace ${target}`,
|
|
329
338
|
),
|
|
330
339
|
);
|
|
331
340
|
return;
|
|
@@ -365,7 +374,7 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
|
|
|
365
374
|
const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
|
|
366
375
|
if (workspace === null || workspace === root) return true;
|
|
367
376
|
|
|
368
|
-
const migrated = await
|
|
377
|
+
const migrated = await migrateToCurrentContract(workspace, { apply, target });
|
|
369
378
|
if (migrated.alreadyCurrent) return true;
|
|
370
379
|
|
|
371
380
|
heading(apply ? 'Migrated' : 'Would migrate');
|
|
@@ -383,6 +392,6 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
|
|
|
383
392
|
print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
|
|
384
393
|
print(style.dim(' command opens the target, which is not readable until this has run.'));
|
|
385
394
|
print('');
|
|
386
|
-
print(style.dim(` Run it for real: lanes link deploy --
|
|
395
|
+
print(style.dim(` Run it for real: lanes link deploy --workspace ${target}`));
|
|
387
396
|
return false;
|
|
388
397
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { installRoot, layout } from '#profile';
|
|
2
|
+
import { CONNECTIONS_FILE, WORKSPACE_FILE, installRoot, layout } from '#profile';
|
|
3
3
|
import type { DeployStep } from '../driver.ts';
|
|
4
4
|
import {
|
|
5
5
|
removalStep,
|
|
@@ -51,6 +51,11 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
|
|
|
51
51
|
`resource.name.startsWith("projects/_/buckets/${bucket}/objects/${path}")`;
|
|
52
52
|
const objectIs = (path: string): string =>
|
|
53
53
|
`resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
|
|
54
|
+
// Parenthesised, because `!` binds tighter than `==` in CEL: `!resource.name
|
|
55
|
+
// == "…"` negates the *name* and compares that, which is not a type error and
|
|
56
|
+
// not what it reads as. Bracketing is the whole difference between excluding
|
|
57
|
+
// one object and excluding nothing.
|
|
58
|
+
const objectIsNot = (path: string): string => `!(${objectIs(path)})`;
|
|
54
59
|
/**
|
|
55
60
|
* The bucket itself, which is a different resource from anything in it.
|
|
56
61
|
*
|
|
@@ -77,12 +82,44 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
|
|
|
77
82
|
*/
|
|
78
83
|
const theBucket = `resource.name == "projects/_/buckets/${bucket}"`;
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
// One prefix, not one per profile. Manifests are the workspace's since
|
|
86
|
+
// ADR-057 — a manifest defines a connection, and connections do not live in a
|
|
87
|
+
// profile — so the carve-out no longer varies with the profile set.
|
|
88
|
+
const manifests = `(${objectsUnder(`${layout.providers()}/`)})`;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a running revision writes, named rather than carved out of `data/`.
|
|
92
|
+
*
|
|
93
|
+
* It used to be `objectsUnder('data/') && !manifests` — everything under one
|
|
94
|
+
* prefix, minus the exception. There is no `data/` any more (ADR-067), and
|
|
95
|
+
* naming the writable prefixes is the better shape regardless: an allowlist
|
|
96
|
+
* says what a compromised revision can reach, where a denylist says only what
|
|
97
|
+
* it cannot and grows silently every time something new lands under the
|
|
98
|
+
* prefix.
|
|
99
|
+
*
|
|
100
|
+
* `credentials.enc` is in it because `connect --from-endpoint` and a token
|
|
101
|
+
* refresh both write there (ADR-025), and its `.key` sits beside it.
|
|
102
|
+
*/
|
|
103
|
+
// **Per profile, not `profiles/` wholesale.** Granting the whole tree and
|
|
104
|
+
// carving the served declarations back out left the revision `create` and
|
|
105
|
+
// `delete` on every *other* profile's `profile.yaml` — the configuration of a
|
|
106
|
+
// profile the same endpoint serves — and on any name it invented, which
|
|
107
|
+
// `listProfiles` would then pick up on the next boot. Before ADR-067 no
|
|
108
|
+
// declaration was writable at all, because `profiles/` sat outside `data/`;
|
|
109
|
+
// this restores that, one prefix per profile.
|
|
110
|
+
//
|
|
111
|
+
// `!==` on a whole object rather than a pattern, because Cloud Storage IAM
|
|
112
|
+
// conditions have no `matches` — see the note on the read binding below.
|
|
113
|
+
const writable = [
|
|
114
|
+
objectsUnder(`${layout.audit()}/`),
|
|
115
|
+
objectsUnder(`${layout.state()}/`),
|
|
116
|
+
objectsUnder(layout.credentials()),
|
|
117
|
+
...profiles.map(
|
|
118
|
+
(profile) =>
|
|
119
|
+
`(${objectsUnder(`${layout.profileDir(profile)}/`)} && ` +
|
|
120
|
+
`${objectIsNot(layout.profileConfig(profile))})`,
|
|
121
|
+
),
|
|
122
|
+
].join(' || ');
|
|
86
123
|
|
|
87
124
|
return [
|
|
88
125
|
{
|
|
@@ -90,16 +127,26 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
|
|
|
90
127
|
// skills are all written by the running endpoint.
|
|
91
128
|
role: 'roles/storage.objectAdmin',
|
|
92
129
|
title: 'owns-its-data',
|
|
93
|
-
expression:
|
|
130
|
+
expression: `(${writable})`,
|
|
94
131
|
},
|
|
95
132
|
{
|
|
96
133
|
// `expression=true` was here, which is every object in the bucket — the
|
|
97
134
|
// step title and ADR-023 both claim a narrowing this did not do. The
|
|
98
|
-
// config the revision reads is the workspace file,
|
|
99
|
-
//
|
|
135
|
+
// config the revision reads is the workspace file, `connections.yaml`
|
|
136
|
+
// beside it, the profiles, and the manifests, so name exactly those.
|
|
137
|
+
//
|
|
138
|
+
// **Named by the constants, because the list went stale once already.**
|
|
139
|
+
// ADR-057 moved connections out of the profile into `connections.yaml` at
|
|
140
|
+
// the workspace root, and `upload.ts` puts it in the bucket because "the
|
|
141
|
+
// endpoint cannot resolve a single grant without it" — but this expression
|
|
142
|
+
// still enumerated the contract-2 set. Every fresh deploy built its image,
|
|
143
|
+
// rolled a revision, and died on `GCS refused to read "connections.yaml"
|
|
144
|
+
// (403)` before it could listen on its port. Nothing caught it: the
|
|
145
|
+
// condition is a string assembled here and asserted nowhere, so the suite
|
|
146
|
+
// saw a passing build and Cloud Run saw a container that never started.
|
|
100
147
|
role: 'roles/storage.objectViewer',
|
|
101
148
|
title: 'reads-its-config',
|
|
102
|
-
expression: `${theBucket} || ${objectsUnder(
|
|
149
|
+
expression: `${theBucket} || ${objectsUnder(`${layout.profilesRoot()}/`)} || ${objectIs(WORKSPACE_FILE)} || ${objectIs(CONNECTIONS_FILE)} || ${manifests}`,
|
|
103
150
|
},
|
|
104
151
|
];
|
|
105
152
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SecretRef } from '#secrets';
|
|
2
2
|
import type { DeployStep, ProvisionInput } from '../driver.ts';
|
|
3
3
|
import { encodeRef } from '../adapters/gcp-secret-manager.ts';
|
|
4
4
|
import { bucketSteps } from './bucket.ts';
|
|
@@ -312,12 +312,12 @@ export async function provisionSteps(
|
|
|
312
312
|
// mind: nothing here was wrong, it was incomplete, and being incomplete
|
|
313
313
|
// looked exactly like being finished. Reading mail 403'd an hour after every
|
|
314
314
|
// deploy.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
];
|
|
315
|
+
// Both kinds arrive already derived: the vault document is named per vault
|
|
316
|
+
// connection (ADR-059) and only `prepare.ts` has the profile configs to work
|
|
317
|
+
// it out. Naming `vault/document` here — the contract-2 constant — created
|
|
318
|
+
// and granted one secret while `openVault` asked for another, and the
|
|
319
|
+
// revision died on `PERMISSION_DENIED` before it could listen. See `vaultRef`.
|
|
320
|
+
const rotatable = [...(input.rotatable ?? [])];
|
|
321
321
|
|
|
322
322
|
// Read, named one secret at a time, for the same reason the write side is:
|
|
323
323
|
// a resource-level grant needs no condition to be scoped.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SecretStore } from '#secrets';
|
|
2
2
|
import type { BlobStore } from '#stores/blobs';
|
|
3
3
|
import type { BlobRoute } from '#stores/blobs/route.ts';
|
|
4
|
-
import { KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeArea, type KnowledgeConfig } from '#profile';
|
|
4
|
+
import { KNOWLEDGE_LAYOUT, KNOWLEDGE_PREFIX, knowledgeRoot, type KnowledgeArea, type KnowledgeConfig } from '#profile';
|
|
5
5
|
import { requireSecret, type TargetInput } from './target.ts';
|
|
6
6
|
import type { FetchLike } from './adapters/github-api.ts';
|
|
7
7
|
// Type-only, so a target with no `knowledge` block never loads the adapter.
|
|
@@ -134,10 +134,15 @@ export async function knowledgeStores(
|
|
|
134
134
|
* how they would come to disagree. `skills` is absent because it is not a
|
|
135
135
|
* prefix of that root — it is a store of its own, handed over whole.
|
|
136
136
|
*/
|
|
137
|
-
export function knowledgeRoutes(
|
|
137
|
+
export function knowledgeRoutes(
|
|
138
|
+
stores: Pick<KnowledgeStores, 'memory' | 'entities'>,
|
|
139
|
+
): BlobRoute[] {
|
|
138
140
|
return [
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
// The *provider's* prefix, which is what core scopes a store into. The
|
|
142
|
+
// repository directory it lands in is `KNOWLEDGE_LAYOUT`, and the two are
|
|
143
|
+
// different strings since contract 4 prefixed the owner layer.
|
|
144
|
+
{ prefix: `${KNOWLEDGE_PREFIX.memory}/`, store: stores.memory },
|
|
145
|
+
{ prefix: `${KNOWLEDGE_PREFIX.entities}/`, store: stores.entities },
|
|
141
146
|
];
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { credentialRefFor, ownClientRefsFor, rotatableCredentialRefsFor } from '#registry';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PAIR_TOKEN_REF,
|
|
4
|
+
readConnections, listProfiles, loadProfileConfig, vaultRef, type Config, type TargetConfig } from '#profile';
|
|
3
5
|
import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
|
|
4
6
|
import { ok, print, style, warn } from '#cli/output.ts';
|
|
5
7
|
import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts';
|
|
@@ -66,6 +68,7 @@ export interface PrepareResult {
|
|
|
66
68
|
export async function rotatableRefs(
|
|
67
69
|
root: string,
|
|
68
70
|
profiles: readonly string[] | undefined,
|
|
71
|
+
declared: TargetConfig | undefined,
|
|
69
72
|
): Promise<string[]> {
|
|
70
73
|
const refs = new Set<string>();
|
|
71
74
|
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
@@ -80,16 +83,27 @@ export async function rotatableRefs(
|
|
|
80
83
|
continue;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// `vault.put` is a capability an agent may hold under policy (ADR-022), so
|
|
87
|
+
// the revision rewrites this one — and it is per vault connection, which is
|
|
88
|
+
// what makes this a per-profile pass rather than a target-level constant.
|
|
89
|
+
// The loop had been reduced to `void config` when the derivation moved out;
|
|
90
|
+
// this is it moving back, next to the config it needs.
|
|
91
|
+
if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
|
|
92
|
+
}
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
// Once, outside the profile loop. Connections and the manifests that describe
|
|
95
|
+
// them belong to the workspace (ADR-057), so the per-profile registry ADR-030
|
|
96
|
+
// required is gone — and with it the failure it guarded against, where a
|
|
97
|
+
// connection in `work` resolved to nothing against `personal`'s registry and
|
|
98
|
+
// the missed ref became a 403 an hour after the revision reported healthy.
|
|
99
|
+
//
|
|
100
|
+
// Every connection, not just granted ones: a credential a revision cannot read
|
|
101
|
+
// is a broken account, and whether some profile currently grants it is a
|
|
102
|
+
// question that changes without redeploying.
|
|
103
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
104
|
+
for (const connection of (await readConnections(root)).connections) {
|
|
105
|
+
const manifest = registry.manifest(connection.provider);
|
|
106
|
+
for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
// Sorted, and a set: two Gmail connections share one dynamically registered
|
|
@@ -132,9 +146,41 @@ export async function readableRefs(
|
|
|
132
146
|
): Promise<string[]> {
|
|
133
147
|
const refs = new Set<string>();
|
|
134
148
|
|
|
149
|
+
// Unconditionally, for every deploy, whether or not this workspace has ever
|
|
150
|
+
// been paired — and that is the point rather than a rounding up.
|
|
151
|
+
//
|
|
152
|
+
// Secret Manager answers a *missing binding* with 403, not 404, so that an
|
|
153
|
+
// identity cannot enumerate secrets by their error codes. The adapter returns
|
|
154
|
+
// null for the 404 and throws for the 403, so an unbound ref is a thrown
|
|
155
|
+
// error on the read path — and that is the failure `server/read/open.ts`
|
|
156
|
+
// records: a deployed revision asked for refs no binding covered and never
|
|
157
|
+
// went healthy.
|
|
158
|
+
//
|
|
159
|
+
// Bound, the deployed-but-never-paired case becomes the 404 instead — a
|
|
160
|
+
// secret that exists with no version — which reads back as null and renders
|
|
161
|
+
// as `401 {error:'unpaired'}`. The grant is what turns a crash into a
|
|
162
|
+
// refusal.
|
|
163
|
+
//
|
|
164
|
+
// Deciding it by asking *whether the workspace is paired* is the thing that
|
|
165
|
+
// cannot happen: that means opening a credential store inside `readableRefs`,
|
|
166
|
+
// which `--dry-run` reaches and must not do.
|
|
167
|
+
//
|
|
168
|
+
// Deliberately not in `rotatableRefs`. The revision never writes this one —
|
|
169
|
+
// minting is `lanes link pair`, from the operator's machine — and
|
|
170
|
+
// `secretVersionAdder` here would let a compromised revision issue itself a
|
|
171
|
+
// credential that reads the whole workspace.
|
|
172
|
+
//
|
|
173
|
+
// The cert and key refs are absent for a different reason: Cloud Run
|
|
174
|
+
// terminates TLS with a certificate a browser already trusts, so a deployed
|
|
175
|
+
// revision never calls `serveRead` and never reads either.
|
|
176
|
+
refs.add(PAIR_TOKEN_REF);
|
|
177
|
+
|
|
178
|
+
// Only the key is the target's. The *document* is named per vault connection
|
|
179
|
+
// (ADR-059), so it is added inside the profile loop below — naming it here
|
|
180
|
+
// meant `vault/document`, which is not what `openVault` opens, and the
|
|
181
|
+
// revision 403'd on a ref nothing had created. See `vaultRef`.
|
|
135
182
|
if (declared?.vault?.adapter === 'secret') {
|
|
136
183
|
refs.add(VAULT_KEY_REF);
|
|
137
|
-
refs.add(declared.vault.ref ?? VAULT_DOCUMENT_REF);
|
|
138
184
|
} else if (declared?.vault?.adapter === 'blob') {
|
|
139
185
|
// Ciphertext lives in the bucket, but the key that opens it is still here.
|
|
140
186
|
refs.add(VAULT_KEY_REF);
|
|
@@ -153,22 +199,24 @@ export async function readableRefs(
|
|
|
153
199
|
}
|
|
154
200
|
|
|
155
201
|
refs.add(config.auth.token_ref);
|
|
202
|
+
if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
|
|
156
203
|
// The OIDC audience check reads this on every verify (`server/endpoint.ts`).
|
|
157
204
|
if (config.auth.authorization?.mode === 'oidc') {
|
|
158
205
|
refs.add(config.auth.authorization.client_id_ref);
|
|
159
206
|
}
|
|
160
207
|
|
|
161
|
-
|
|
162
|
-
// profile's, so the registry that resolves its connections must be too.
|
|
163
|
-
const registry = await buildRegistryWithWorkspace(root, name);
|
|
208
|
+
}
|
|
164
209
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
// Once, for the reason `rotatableRefs` gives.
|
|
211
|
+
const connectionsFile = await readConnections(root);
|
|
212
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
213
|
+
|
|
214
|
+
for (const connection of connectionsFile.connections) {
|
|
215
|
+
const manifest = registry.manifest(connection.provider);
|
|
216
|
+
const ref = credentialRefFor(connection, manifest);
|
|
217
|
+
if (ref) refs.add(ref);
|
|
218
|
+
for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
|
|
219
|
+
for (const client of ownClientRefsFor(manifest, connectionsFile.oauth_apps)) refs.add(client);
|
|
172
220
|
}
|
|
173
221
|
|
|
174
222
|
return [...refs].sort();
|
|
@@ -190,13 +238,13 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
|
|
|
190
238
|
// The command is spelled out rather than described. This is the one manual
|
|
191
239
|
// step a deploy genuinely cannot take for you, so it should cost a paste
|
|
192
240
|
// rather than a trip to the docs to work out how the id is spelled.
|
|
193
|
-
const registry = await buildRegistryWithWorkspace(root
|
|
194
|
-
for (const connection of
|
|
241
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
242
|
+
for (const connection of (await readConnections(root)).connections) {
|
|
195
243
|
const ref = credentialRefFor(connection, registry.manifest(connection.provider));
|
|
196
244
|
if (!ref || (await credentials.has(ref))) continue;
|
|
197
245
|
warnings.push(
|
|
198
246
|
`${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
|
|
199
|
-
` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --
|
|
247
|
+
` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --workspace ${target} --id ${connection.id}`,
|
|
200
248
|
);
|
|
201
249
|
}
|
|
202
250
|
|
|
@@ -56,7 +56,7 @@ export async function recordDeployment(record: DeploymentRecord): Promise<void>
|
|
|
56
56
|
// there pointed the bucket at itself, and the revision that came up refused to
|
|
57
57
|
// open its own target.
|
|
58
58
|
await recordTarget(resolveWorkspaceRoot(), record.target, {
|
|
59
|
-
|
|
59
|
+
at: record.workspace,
|
|
60
60
|
...stamp,
|
|
61
61
|
});
|
|
62
62
|
}
|
|
@@ -37,7 +37,7 @@ import { heading, ok, print, style, warn } from '#cli/output.ts';
|
|
|
37
37
|
export function registerLine(profile: string, target: string): string {
|
|
38
38
|
return style.dim(
|
|
39
39
|
` Connect your accounts first, then register with:\n` +
|
|
40
|
-
` lanes link outputs --profile ${profile} --
|
|
40
|
+
` lanes link outputs --profile ${profile} --workspace ${target}\n` +
|
|
41
41
|
' A client keeps the tool list it fetched when it connected, so one registered\n' +
|
|
42
42
|
' before the accounts holds a surface without them until it is re-added.',
|
|
43
43
|
);
|
|
@@ -66,7 +66,7 @@ export function reportUnauthorised(warnings: readonly string[], profile: string,
|
|
|
66
66
|
print(
|
|
67
67
|
style.dim(
|
|
68
68
|
' A browser consent per account is the one step this cannot take for you:\n' +
|
|
69
|
-
` lanes link connect <provider> --profile ${profile} --
|
|
69
|
+
` lanes link connect <provider> --profile ${profile} --workspace ${target}\n` +
|
|
70
70
|
' Each is served as soon as it is authorised. There is no second deploy —\n' +
|
|
71
71
|
' deploying is how code gets here, and authorising an account changes none.',
|
|
72
72
|
),
|
|
@@ -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
|
-
}
|