@lanes-sh/link 0.6.1 → 0.6.3

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/.dockerignore ADDED
@@ -0,0 +1,45 @@
1
+ # The build context for src/deployments/gcp/Dockerfile is this directory.
2
+ #
3
+ # The first block is a security control, not an image-size optimisation. `data/`
4
+ # holds the encrypted credential store *and* the key that opens it, and `*.key`
5
+ # catches the same file wherever else it lands. A credential baked into an image
6
+ # is pushed to a registry, cached on every builder that touched it, and readable
7
+ # by anyone who can pull the tag — and the deployed target does not want them
8
+ # anyway, because it reads credentials from Secret Manager.
9
+ data/
10
+ *.key
11
+ *.pem
12
+ *.enc
13
+ *.p12
14
+ .env
15
+ .env.*
16
+
17
+ # Not needed at runtime, and `.git` in particular carries every branch.
18
+ .git/
19
+ .gitignore
20
+ .worktrees/
21
+ node_modules/
22
+ **/node_modules/
23
+ coverage/
24
+ dist/
25
+ build/
26
+ *.tsbuildinfo
27
+
28
+ # Tests, docs, and tooling: the image runs the endpoint and nothing else.
29
+ #
30
+ # `instructions/` is what the CLI installs into a client — a skill and an agent
31
+ # definition. The endpoint describes itself from code (`server/mcp/instructions.ts`)
32
+ # precisely so a deployed revision does not need these files.
33
+ **/*.test.ts
34
+ **/*.test.json
35
+ docs/
36
+ instructions/
37
+ **/README.md
38
+ .lanes/
39
+ .claude/
40
+ .vscode/
41
+ .idea/
42
+ .DS_Store
43
+
44
+ # The compiled binary from `bun build --compile`, if one was made locally.
45
+ /lanes
package/README.md CHANGED
@@ -69,6 +69,20 @@ Your agents can now use it. Memory, tasks, files, skills, and the vault hold you
69
69
  rather than an account, so they are already there — nothing to connect, no credentials, no browser.
70
70
  Mail and calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
71
71
 
72
+ ## In the Lanes desktop app
73
+
74
+ Prefer not to use a terminal? The [Lanes desktop app](https://lanes.sh/desktop) drives this CLI from
75
+ a settings page. **Settings → Integrations → Lanes Link** installs it, holds the profile and target
76
+ every command runs against, connects your accounts, starts and stops the endpoint, and registers it
77
+ with Claude Code or Codex.
78
+
79
+ ![The Lanes Link page in the Lanes desktop app: the CLI status card and its version, the target and profile selectors, the endpoint row with its running state, and the list of connected accounts.](docs/images/lanes-link-desktop.png)
80
+
81
+ It runs the commands above rather than reimplementing them, so consent and the token stay here where
82
+ they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
83
+ Lanes v0.47.0, as a research preview.
84
+ **[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
85
+
72
86
  ## What your agent gets
73
87
 
74
88
  | | | Manage it with |
@@ -166,6 +180,7 @@ than being rebuilt.
166
180
  ## Docs
167
181
 
168
182
  - **[Quickstart](docs/quickstart.md)** — from nothing to a working endpoint
183
+ - **[In the Lanes desktop app](https://lanes.sh/docs/desktop/lanes-link)** — the page that drives it
169
184
  - **[Connect your accounts](docs/connect.md)** — every provider, and what each one needs
170
185
  - **[Add it to your agent](docs/clients.md)** — Claude Code, Codex, Claude Desktop, claude.ai, ChatGPT
171
186
  - **[Deploy to your own cloud](docs/deploy.md)** — five commands to a URL
package/bunfig.toml ADDED
@@ -0,0 +1,17 @@
1
+ [install]
2
+ # Supply chain: this repository holds live OAuth refresh tokens, so a
3
+ # dependency compromise is a live threat. The common attack is to publish a
4
+ # compromised version and yank it within hours; a release-age floor keeps a
5
+ # version that young out of the lockfile entirely.
6
+ #
7
+ # 604800 seconds = 7 days.
8
+ #
9
+ # An urgent security fix can be pulled in ahead of the window by installing an
10
+ # exact version explicitly, or by adding the package to minimumReleaseAgeExcludes.
11
+ minimumReleaseAge = 604800
12
+ minimumReleaseAgeExcludes = []
13
+
14
+ # Pin exact versions rather than ranges, so the lockfile is the only thing
15
+ # that decides what gets installed. Bun does not run dependency lifecycle
16
+ # scripts unless a package is listed in trustedDependencies; keep that empty.
17
+ exact = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -42,7 +42,10 @@
42
42
  "!src/**/*.test.ts",
43
43
  "instructions",
44
44
  "README.md",
45
- "LICENSE"
45
+ "LICENSE",
46
+ "bun.lock",
47
+ "bunfig.toml",
48
+ ".dockerignore"
46
49
  ],
47
50
  "scripts": {
48
51
  "test": "bun test",
@@ -0,0 +1,48 @@
1
+ import { bindConnectionCredentials, type BindOutcome } from '#deployments/bind.ts';
2
+ import type { Runtime } from '../../runtime.ts';
3
+
4
+ /**
5
+ * Step 7 of `connect`: bind the credential to the revision that will serve it.
6
+ *
7
+ * Split out for the reason every other step in this directory was — `index.ts`
8
+ * holds the order, not the substance, and the file has a size budget
9
+ * (`architecture.test.ts`) that exists to keep it that way.
10
+ *
11
+ * What it is for is in `deployments/bind.ts`. Two decisions belong here.
12
+ *
13
+ * **It is called ahead of `connect`'s early return, not after it.** Re-running
14
+ * `connect` against an existing connection is what an operator does to repair
15
+ * one, and that run reaches the end with no config changes to make. Binding
16
+ * after the return would skip the repair path, leaving the command that looks
17
+ * like the fix doing nothing about the actual fault.
18
+ *
19
+ * **A failure is a note, never a throw.** The credential is in the store and the
20
+ * config is about to be saved by the time this runs, so failing the command
21
+ * would report "connect failed" about a connect that happened — and connecting
22
+ * an account against a deployed target from a machine that has never had
23
+ * `gcloud` installed has to keep working.
24
+ *
25
+ * The third decision is which connection to bind. The declared row is
26
+ * preferred over one assembled from the arguments, because an operator may have
27
+ * written a `credential_ref` into the profile by hand and that field decides
28
+ * where a `local` provider's credential lives — binding the derived ref instead
29
+ * would grant a secret nobody writes and leave the written one unbound, which is
30
+ * the failure `rotatableCredentialRefsFor` documents from the other direction.
31
+ */
32
+ export function bindNewCredential(
33
+ runtime: Runtime,
34
+ providerId: string,
35
+ connectionId: string,
36
+ account: string,
37
+ ): Promise<BindOutcome> {
38
+ const declared = runtime.config.connections.find(
39
+ (candidate) => candidate.provider === providerId && candidate.id === connectionId,
40
+ );
41
+
42
+ return bindConnectionCredentials({
43
+ deploy: runtime.declared.deploy,
44
+ target: runtime.target,
45
+ connection: declared ?? { provider: providerId, id: connectionId, account },
46
+ manifest: runtime.manifestFor(providerId),
47
+ });
48
+ }
@@ -16,6 +16,7 @@ import { chooseAuthMethod } from './method.ts';
16
16
  import { preflight } from './requirements.ts';
17
17
  import { ALREADY, NOTHING, renderOutcome, where, type ConnectOutcome } from './outcome.ts';
18
18
  import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
19
+ import { bindNewCredential } from './bind-credential.ts';
19
20
  import { ensureStaticCredential } from './setup.ts';
20
21
  import { settleIdentity } from './settle.ts';
21
22
  import { runStrategySetup } from './strategy.ts';
@@ -356,6 +357,9 @@ export async function runConnect(
356
357
  ? ['that is your own memory, tasks, assets, skills and vault — no account, nothing stored until you use them']
357
358
  : [];
358
359
 
360
+ // 7. Bind the credential to the revision that serves it — `bind-credential.ts`.
361
+ const bound = await bindNewCredential(runtime, providerId, connectionId, account);
362
+ if (bound.failed) notes.push(bound.failed);
359
363
  if (changes.length === 0 && granted.length === 0) {
360
364
  return {
361
365
  ...NOTHING,
@@ -364,6 +368,7 @@ export async function runConnect(
364
368
  account,
365
369
  label,
366
370
  ...where(runtime),
371
+ ...(notes.length > 0 ? { notes } : {}),
367
372
  discovered: discovered.length,
368
373
  next: ALREADY,
369
374
  };
@@ -4,6 +4,7 @@ import { announce, announceProfile, emit, fail, ok, print, warn } from '../../ou
4
4
  import { staleNudge } from '../../release.ts';
5
5
  import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
6
6
  import type { FetchLike } from '#deployments/knowledge.ts';
7
+ import { unboundRotatableRefs } from '#deployments/bind.ts';
7
8
  import { credentialAge, reportCapabilityDrift } from './findings.ts';
8
9
  import { migratedContract, migratedRenamedProviders } from './migrate.ts';
9
10
 
@@ -276,6 +277,37 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
276
277
  });
277
278
  }
278
279
 
280
+ // Whether the revision can still rewrite what it serves.
281
+ //
282
+ // A *problem*, not a warning: an unbound credential is a connection that
283
+ // works until its access token expires and then stops, and every other
284
+ // report on this machine calls it healthy in the meantime — `status` and
285
+ // `setup_overview` read the state store, and the state store knows nothing
286
+ // about IAM. This is the only place that asks the thing that decides.
287
+ //
288
+ // Skipped entirely for a local target, where credentials are a file this
289
+ // process owns and there is no revision to grant anything to.
290
+ const rotation = await unboundRotatableRefs({
291
+ deploy: runtime.declared.deploy,
292
+ target: runtime.target,
293
+ connections: runtime.config.connections,
294
+ manifestFor: runtime.manifestFor,
295
+ });
296
+ if (rotation.unbound.length > 0) {
297
+ problems.push({
298
+ kind: 'unbound_credentials',
299
+ message:
300
+ `the deployed endpoint can read ${rotation.unbound.join(', ')} but not rotate ` +
301
+ `${rotation.unbound.length === 1 ? 'it' : 'them'} — so ${rotation.unbound.length === 1 ? 'that connection' : 'those connections'} ` +
302
+ 'will fail about an hour after each use, when the token refresh tries to persist. ' +
303
+ 'A connection made since the last deploy is the usual cause',
304
+ fix: forSelection('lanes link deploy'),
305
+ });
306
+ }
307
+ if (rotation.unavailable) {
308
+ warnings.push({ kind: 'rotation_uncheckable', message: rotation.unavailable });
309
+ }
310
+
279
311
  if (problems.length > 0) process.exitCode = 1;
280
312
 
281
313
  return emit(flags.json, { ok: problems.length === 0, checks, warnings, problems }, () => {
@@ -42,16 +42,24 @@ export async function migratedRenamedProviders(
42
42
  ): Promise<boolean> {
43
43
  if (!(refusal instanceof ConfigError)) return false;
44
44
 
45
- const selection = await resolveSelection({ profileFlag: flags.profile });
46
- const document = await ConfigDocument.open(selection.workspaceRoot, selection.profile);
45
+ // **The target's workspace, not this machine's.** `resolveSelection` defaults
46
+ // to the local root, which is where this looked before ADR-052 — and there was
47
+ // only one place a profile could be. The row that needs renaming is in the
48
+ // file the *named target* holds, so a bucket's stale row was invisible from
49
+ // here while the refusal kept naming this command as the fix.
50
+ const target = flags.target ?? '';
51
+ const localRoot = resolveWorkspaceRoot();
52
+ const root = await resolveTargetWorkspace(localRoot, target).catch(() => localRoot);
53
+
54
+ const selection = await resolveSelection({ profileFlag: flags.profile, root });
55
+ const document = await ConfigDocument.open(root, selection.profile);
47
56
  if (pendingRenames(document).length === 0) return false;
48
57
 
49
58
  // Shape-only, because the check this document fails runs after the schema.
50
59
  // Throws when it is malformed beyond a rename, which is a better sentence
51
60
  // than the referential one it would otherwise be reported under.
52
61
  const config = shapeOf(document);
53
- const target = flags.target ?? '';
54
- const credentials = await openSecretStoreFor(config, selection.workspaceRoot, target);
62
+ const credentials = await openSecretStoreFor(config, root, target);
55
63
 
56
64
  const migration = await migrateRenamedProviders(document, credentials, {
57
65
  apply: flags.fix === true,
@@ -70,6 +78,7 @@ export async function migratedRenamedProviders(
70
78
  ok: applied && migration.blocked.length === 0,
71
79
  profile: selection.profile,
72
80
  target,
81
+ workspace: root,
73
82
  applied,
74
83
  rows: migration.rows,
75
84
  changes: migration.changes,
@@ -0,0 +1,214 @@
1
+ import type { ConnectionConfig, DeployConfig } from '#profile';
2
+ import { credentialRefFor, rotatableCredentialRefsFor } from '#registry';
3
+ import type { SecretRef } from '#secrets';
4
+ import type { DeployDriver } from './driver.ts';
5
+ import { driverFor } from './drivers.ts';
6
+ import { secretGrantSteps } from './gcp/provision.ts';
7
+ import { requireProject } from './gcp/gcloud.ts';
8
+ import { encodeRef } from './adapters/gcp-secret-manager.ts';
9
+
10
+ /**
11
+ * Binding one connection's credentials to the revision that will serve them.
12
+ *
13
+ * **The invariant this restores: a revision can rotate every credential it
14
+ * serves.** `provisionSteps` establishes it over the connections the config held
15
+ * *at deploy time*, one secret at a time, which is the right shape — a
16
+ * resource-level grant needs no condition to be scoped. What nothing did was
17
+ * keep it true afterwards. `connect` writes a credential into the same store and
18
+ * binds nothing, so from the next connect until the next deploy the invariant is
19
+ * false and no command says so.
20
+ *
21
+ * The failure it produces is the worst shape available. Read is unaffected, so
22
+ * the connection authorises, answers, and reports `active`; only the *write* on
23
+ * the far side of the first token refresh is denied, roughly an hour later, by
24
+ * which time the connect that caused it is not the recent event. `status` and
25
+ * `setup_overview` both keep saying "connected and reachable" throughout,
26
+ * because both read the state store and the state store knows nothing about IAM.
27
+ *
28
+ * **Every path that writes a credential reaches this one.** `connect custom`
29
+ * delegates to `runConnect` after writing its manifest, and `connectFamily`
30
+ * calls `runConnect` per member, so binding at that one call site covers all
31
+ * three. The reverse direction needs nothing: `disconnect` deletes the whole
32
+ * secret rather than a version, and a secret's IAM policy goes with it, so there
33
+ * is no orphaned binding to revoke.
34
+ *
35
+ * Same steps as the deploy, from the same functions, over one connection's refs.
36
+ * Not a second implementation of the grant: `reconcile.ts` argues that a preview
37
+ * computed differently from the mutation eventually becomes a lie, and the
38
+ * argument is stronger here, because two spellings of a grant do not disagree on
39
+ * screen — they disagree about which permission actually exists.
40
+ */
41
+
42
+ export interface BindOutcome {
43
+ /** Refs the revision can now read and rotate. Empty is a normal result. */
44
+ readonly bound: readonly SecretRef[];
45
+ /**
46
+ * Why nothing was bound, when nothing was and that is fine: a local target, a
47
+ * deployment with no runtime service account, or a provider holding no
48
+ * credential at all.
49
+ */
50
+ readonly skipped?: string | undefined;
51
+ /**
52
+ * A binding that could not be applied, as a sentence for the operator.
53
+ *
54
+ * Carried rather than thrown, and that is deliberate. By the time this runs
55
+ * the credential is already in the store and the config is already saved, so
56
+ * failing the command would report "connect failed" about a connect that
57
+ * happened. It also must not require `gcloud` to be installed: someone can
58
+ * legitimately connect an account against a deployed target from a machine
59
+ * that has never deployed one.
60
+ */
61
+ readonly failed?: string | undefined;
62
+ }
63
+
64
+ const NOTHING_TO_BIND = 'this provider stores no credential';
65
+
66
+ export async function bindConnectionCredentials(input: {
67
+ readonly deploy: DeployConfig | undefined;
68
+ readonly target: string;
69
+ readonly connection: ConnectionConfig;
70
+ /**
71
+ * The provider's manifest, so an omitted `credential_ref` can be derived.
72
+ *
73
+ * Typed off `#registry`'s own signature rather than by importing
74
+ * `ProviderManifest`: `#deployments` may not import `#connectivity`
75
+ * (`architecture.test.ts`), which is the same rule that put `credentialRefFor`
76
+ * in `#registry` in the first place. `prepare.ts` avoids it by never naming
77
+ * the type; this one has to name it, so it derives it.
78
+ */
79
+ readonly manifest: Parameters<typeof rotatableCredentialRefsFor>[1];
80
+ /** Injectable so a test asserts the argv without a cloud project near it. */
81
+ readonly driver?: DeployDriver | undefined;
82
+ }): Promise<BindOutcome> {
83
+ const { deploy, connection, manifest } = input;
84
+ if (!deploy) return { bound: [], skipped: 'this target runs here, not on a platform' };
85
+
86
+ const cloudrun = requireProject(deploy, input.target);
87
+ const serviceAccount = cloudrun.service_account;
88
+ if (!serviceAccount) {
89
+ return { bound: [], skipped: 'this deployment declares no runtime service account' };
90
+ }
91
+
92
+ // Both halves, because a connection made since the last deploy has neither.
93
+ // The read grant is the one an older deployment's project-wide
94
+ // `secretAccessor` happens to cover, which is exactly what made this failure
95
+ // partial and therefore slow to find; a deployment provisioned since that
96
+ // changed has no read on it either.
97
+ const readable = credentialRefFor(connection, manifest);
98
+ const rotatable = rotatableCredentialRefsFor(connection, manifest);
99
+ if (!readable && rotatable.length === 0) return { bound: [], skipped: NOTHING_TO_BIND };
100
+
101
+ const steps = secretGrantSteps({
102
+ project: cloudrun.project,
103
+ serviceAccount,
104
+ readable: readable ? [readable] : [],
105
+ rotatable,
106
+ });
107
+
108
+ const driver = input.driver ?? (await driverFor(deploy.platform));
109
+
110
+ for (const step of steps) {
111
+ const result = await driver.run(step.argv, { quiet: true });
112
+ // `tolerateFailure` on these means "the secret may already exist", which is
113
+ // the create step's success case. A binding that genuinely could not be
114
+ // applied is still worth saying out loud — quietly tolerating it here is how
115
+ // the deploy path let this class of gap through in the first place.
116
+ if (!result.ok && !step.argv.includes('add-iam-policy-binding')) continue;
117
+ if (!result.ok) {
118
+ return {
119
+ bound: [],
120
+ failed:
121
+ `could not bind ${connection.provider}.${connection.id}'s credential to ` +
122
+ `${serviceAccount}, so the deployed endpoint will be able to read it and not ` +
123
+ 'rotate it — which fails about an hour after the first use. ' +
124
+ `Run \`lanes link deploy --target ${input.target}\` to bind it. ` +
125
+ `(${driver.tool}: ${result.stderr.trim().split('\n').slice(-1)[0] ?? 'failed'})`,
126
+ };
127
+ }
128
+ }
129
+
130
+ return { bound: readable ? [readable, ...rotatable] : rotatable };
131
+ }
132
+
133
+ /**
134
+ * The credentials a deployed revision serves but cannot rewrite.
135
+ *
136
+ * The detection half of the same invariant `bindConnectionCredentials` keeps.
137
+ * Binding at connect time closes the gap going forward; this one answers for a
138
+ * workspace that already has it, and for every way a binding can go missing that
139
+ * no command is watching — a secret rebuilt by hand, a service account replaced,
140
+ * a profile edited in an editor, a connection made by an older CLI.
141
+ *
142
+ * Asked of the platform rather than derived from a record this repository keeps,
143
+ * because a record would only ever agree with itself. IAM is the thing that
144
+ * actually decides, so IAM is what gets read.
145
+ *
146
+ * One call per ref, concurrently. `doctor` is the command where a few seconds
147
+ * buys an answer nothing else on the machine can give — and the alternative,
148
+ * finding out from a 403 an hour after a connect, is the failure this exists to
149
+ * pre-empt.
150
+ */
151
+ export async function unboundRotatableRefs(input: {
152
+ readonly deploy: DeployConfig | undefined;
153
+ readonly target: string;
154
+ readonly connections: readonly ConnectionConfig[];
155
+ readonly manifestFor: (providerId: string) => Parameters<typeof rotatableCredentialRefsFor>[1];
156
+ readonly driver?: DeployDriver | undefined;
157
+ }): Promise<{ unbound: readonly SecretRef[]; unavailable?: string | undefined }> {
158
+ const { deploy } = input;
159
+ if (!deploy) return { unbound: [] };
160
+
161
+ const cloudrun = requireProject(deploy, input.target);
162
+ const serviceAccount = cloudrun.service_account;
163
+ if (!serviceAccount) return { unbound: [] };
164
+
165
+ const refs = new Set<SecretRef>();
166
+ for (const connection of input.connections) {
167
+ for (const ref of rotatableCredentialRefsFor(connection, input.manifestFor(connection.provider))) {
168
+ refs.add(ref);
169
+ }
170
+ }
171
+ if (refs.size === 0) return { unbound: [] };
172
+
173
+ const driver = input.driver ?? (await driverFor(deploy.platform));
174
+ const member = `serviceAccount:${serviceAccount}`;
175
+
176
+ const verdicts = await Promise.all(
177
+ [...refs].map(async (ref) => {
178
+ const result = await driver.run(
179
+ ['secrets', 'get-iam-policy', encodeRef(ref), '--project', cloudrun.project, '--format', 'json'],
180
+ { quiet: true },
181
+ );
182
+ // A ref whose policy cannot be read is not reported as unbound: "could not
183
+ // look" and "is not granted" send an operator to different places, and
184
+ // this command exists to be trusted about the second one.
185
+ if (!result.ok) return { ref, bound: true, unreadable: true };
186
+
187
+ try {
188
+ const policy = JSON.parse(result.stdout) as {
189
+ bindings?: { role?: string; members?: string[] }[];
190
+ };
191
+ const bound = (policy.bindings ?? []).some(
192
+ (binding) =>
193
+ binding.role === 'roles/secretmanager.secretVersionAdder' &&
194
+ (binding.members ?? []).includes(member),
195
+ );
196
+ return { ref, bound, unreadable: false };
197
+ } catch {
198
+ return { ref, bound: true, unreadable: true };
199
+ }
200
+ }),
201
+ );
202
+
203
+ const unreadable = verdicts.filter((verdict) => verdict.unreadable).length;
204
+ return {
205
+ unbound: verdicts.filter((verdict) => !verdict.bound).map((verdict) => verdict.ref),
206
+ ...(unreadable > 0
207
+ ? {
208
+ unavailable:
209
+ `${unreadable} of ${refs.size} credential policies could not be read, so this ` +
210
+ `check covered the rest. ${driver.tool} has to be installed and authorised for it.`,
211
+ }
212
+ : {}),
213
+ };
214
+ }
@@ -272,7 +272,18 @@ export async function deploy(flags: DeployFlags): Promise<void> {
272
272
  // Before the rollout, so the revision that comes up finds a config to read.
273
273
  // Uploading after would leave a window where the service is serving and the
274
274
  // workspace it was told to read is not there yet.
275
- await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
275
+ // **Only when there is somewhere to copy from.** After ADR-052 the profiles
276
+ // a deployed target serves *live in* that target's workspace, so
277
+ // `resolution.workspaceRoot` and `workspace` are the same bucket and this is
278
+ // a copy onto itself. It ran, and the self-copy is how the bucket's registry
279
+ // came to be overwritten.
280
+ //
281
+ // What it is still for is the one-way trip: a first deploy, where the
282
+ // profile is on this machine and the bucket does not hold it yet. That is a
283
+ // move, not a sync — the next deploy finds it already there.
284
+ if (resolution.workspaceRoot !== workspace) {
285
+ await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
286
+ }
276
287
 
277
288
  // **The bucket's own migration, here and nowhere else.**
278
289
  //
@@ -304,7 +315,11 @@ export async function deploy(flags: DeployFlags): Promise<void> {
304
315
  last_deploy: stamped,
305
316
  });
306
317
 
307
- await recordTarget(resolution.workspaceRoot, target, {
318
+ // **This machine's registry, not the target's.** `resolution.workspaceRoot`
319
+ // is the workspace the profile was *found* in, which for a deployed target is
320
+ // the bucket — so writing the pointer there pointed the bucket at itself, and
321
+ // the revision that came up refused to open its own target.
322
+ await recordTarget(resolveWorkspaceRoot(), target, {
308
323
  workspace,
309
324
  primary: resolution.profile,
310
325
  last_deploy: stamped,
@@ -7,7 +7,8 @@ import type {
7
7
  SurveyInput,
8
8
  SurveyResult,
9
9
  } from '../driver.ts';
10
- import type { DeployConfig } from '#profile';
10
+ import { join } from 'node:path';
11
+ import { installRoot, type DeployConfig } from '#profile';
11
12
  import { encodeRef } from '../adapters/gcp-secret-manager.ts';
12
13
  import {
13
14
  captureGcloud,
@@ -50,6 +51,10 @@ export function deployPlan(input: PlanInput): DeployStep[] {
50
51
  const cloudrun = requireProject(input.deploy, input.target);
51
52
  const image = imageReference(cloudrun, input.tag);
52
53
  const scope = ['--project', cloudrun.project, '--region', cloudrun.region];
54
+ // Where this package is installed, which is where the Dockerfile and the build
55
+ // config live. Never the working directory: `lanes link deploy` is run from
56
+ // wherever somebody happens to be standing.
57
+ const root = installRoot(import.meta.dir);
53
58
 
54
59
  return [
55
60
  {
@@ -80,10 +85,15 @@ export function deployPlan(input: PlanInput): DeployStep[] {
80
85
  '--project',
81
86
  cloudrun.project,
82
87
  '--config',
83
- 'src/deployments/gcp/cloudbuild.yaml',
88
+ join(root, 'src/deployments/gcp/cloudbuild.yaml'),
84
89
  '--substitutions',
85
90
  `_IMAGE=${image}`,
86
- '.',
91
+ // The build context, and the config beside it, are the *installed
92
+ // package* — not whatever directory the operator happened to run from.
93
+ // Both were relative, so `lanes link deploy` worked from a checkout and
94
+ // failed everywhere else with a missing cloudbuild.yaml, which reads as
95
+ // a broken install rather than as a wrong working directory.
96
+ root,
87
97
  ],
88
98
  },
89
99
  {
@@ -1,4 +1,4 @@
1
- import { VAULT_DOCUMENT_REF } from '#secrets';
1
+ import { VAULT_DOCUMENT_REF, 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 { requireProject } from './gcloud.ts';
@@ -43,6 +43,109 @@ const REQUIRED_SERVICES = [
43
43
  'cloudresourcemanager.googleapis.com',
44
44
  ];
45
45
 
46
+ /**
47
+ * Who the grant is for, and on what.
48
+ *
49
+ * A named type because these two functions are now called from two places that
50
+ * must not disagree — see the note on `secretGrantSteps`.
51
+ */
52
+ export interface SecretGrant {
53
+ readonly project: string;
54
+ readonly serviceAccount: string;
55
+ readonly refs: readonly SecretRef[];
56
+ }
57
+
58
+ /** Read, named one secret at a time, so the grant needs no condition to be scoped. */
59
+ export function readSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
60
+ return refs.map((ref) => ({
61
+ title: `let the revision read ${ref}`,
62
+ argv: [
63
+ 'secrets',
64
+ 'add-iam-policy-binding',
65
+ encodeRef(ref),
66
+ '--project',
67
+ project,
68
+ '--member',
69
+ `serviceAccount:${serviceAccount}`,
70
+ '--role',
71
+ 'roles/secretmanager.secretAccessor',
72
+ // Bindings are printed as the whole policy otherwise, which is pages
73
+ // of YAML per deploy and buries everything after it.
74
+ '--condition',
75
+ 'None',
76
+ ],
77
+ tolerateFailure: true,
78
+ }));
79
+ }
80
+
81
+ /**
82
+ * Write, two steps each, and the first is what keeps the second narrow: the
83
+ * secret is created here so the revision only ever needs to *add a version*,
84
+ * never `secrets.create`, which is a project-level permission that would let it
85
+ * mint credential references of its own.
86
+ */
87
+ export function rotateSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
88
+ return refs.flatMap((ref) => {
89
+ const id = encodeRef(ref);
90
+ return [
91
+ {
92
+ title: `create the secret ${id}, so the revision never needs secrets.create`,
93
+ argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
94
+ tolerateFailure: true,
95
+ },
96
+ {
97
+ title: `let the revision rewrite ${ref}, and nothing else in the store`,
98
+ argv: [
99
+ 'secrets',
100
+ 'add-iam-policy-binding',
101
+ id,
102
+ '--project',
103
+ project,
104
+ '--member',
105
+ `serviceAccount:${serviceAccount}`,
106
+ '--role',
107
+ 'roles/secretmanager.secretVersionAdder',
108
+ '--condition',
109
+ 'None',
110
+ ],
111
+ tolerateFailure: true,
112
+ },
113
+ ];
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Both halves for one connection's credentials, for a caller that is not a deploy.
119
+ *
120
+ * **This exists because the grant was a deploy-time snapshot of a set that
121
+ * changes between deploys.** `provisionSteps` walks the config's connections and
122
+ * binds each secret it finds; `connect` then adds a connection, writes its
123
+ * credential, and binds nothing. The revision can read the new secret — an older
124
+ * deployment's project-wide `secretAccessor` covers it, and a current one does
125
+ * not — but it cannot add a version, so the first OAuth refresh 403s. The
126
+ * connection works for exactly as long as its initial access token lasts, which
127
+ * is about an hour, and then stops for a reason nothing on the connection says.
128
+ *
129
+ * So the same steps are reachable from `connect`, over one connection's refs
130
+ * rather than the whole config's. Deliberately the *same functions* rather than
131
+ * a second implementation: `reconcile.ts` makes the same argument for planning
132
+ * and applying, and it holds harder here, because a second spelling of a grant
133
+ * is not a wrong answer on screen, it is a permission that is missing in one
134
+ * path and present in the other.
135
+ */
136
+ export function secretGrantSteps(
137
+ grant: Omit<SecretGrant, 'refs'> & {
138
+ readonly readable: readonly SecretRef[];
139
+ readonly rotatable: readonly SecretRef[];
140
+ },
141
+ ): DeployStep[] {
142
+ const { project, serviceAccount } = grant;
143
+ return [
144
+ ...readSteps({ project, serviceAccount, refs: grant.readable }),
145
+ ...rotateSteps({ project, serviceAccount, refs: grant.rotatable }),
146
+ ];
147
+ }
148
+
46
149
  export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
47
150
  const cloudrun = requireProject(input.deploy, input.target);
48
151
  const { project, region, service_account: serviceAccount } = cloudrun;
@@ -116,27 +219,7 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
116
219
  // Affordable because the serving path reads by explicit ref: `list()` is a
117
220
  // CLI call, and `secretAccessor` never carried `secrets.list` anyway.
118
221
  // `readableRefs` derives the set from config and manifests at deploy time.
119
- for (const ref of input.readable ?? []) {
120
- steps.push({
121
- title: `let the revision read ${ref}`,
122
- argv: [
123
- 'secrets',
124
- 'add-iam-policy-binding',
125
- encodeRef(ref),
126
- '--project',
127
- project,
128
- '--member',
129
- `serviceAccount:${serviceAccount}`,
130
- '--role',
131
- 'roles/secretmanager.secretAccessor',
132
- // Bindings are printed as the whole policy otherwise, which is pages
133
- // of YAML per deploy and buries everything after it.
134
- '--condition',
135
- 'None',
136
- ],
137
- tolerateFailure: true,
138
- });
139
- }
222
+ steps.push(...readSteps({ project, serviceAccount, refs: input.readable ?? [] }));
140
223
  }
141
224
 
142
225
  // What a revision rewrites in its own credential store, named one secret at a
@@ -165,32 +248,8 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
165
248
  ]
166
249
  : [];
167
250
 
168
- for (const ref of writable) {
169
- const id = encodeRef(ref);
170
-
171
- steps.push({
172
- title: `create the secret ${id}, so the revision never needs secrets.create`,
173
- argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
174
- tolerateFailure: true,
175
- });
176
-
177
- steps.push({
178
- title: `let the revision rewrite ${ref}, and nothing else in the store`,
179
- argv: [
180
- 'secrets',
181
- 'add-iam-policy-binding',
182
- id,
183
- '--project',
184
- project,
185
- '--member',
186
- `serviceAccount:${serviceAccount}`,
187
- '--role',
188
- 'roles/secretmanager.secretVersionAdder',
189
- '--condition',
190
- 'None',
191
- ],
192
- tolerateFailure: true,
193
- });
251
+ if (serviceAccount) {
252
+ steps.push(...rotateSteps({ project, serviceAccount, refs: writable }));
194
253
  }
195
254
 
196
255
  // Any target that addresses a bucket, which deployed means all of them:
@@ -241,8 +300,18 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
241
300
  // what the revision owns from what declares what it is. Anchored to the
242
301
  // profile segment rather than matched loosely: `contains("/providers.d/")`
243
302
  // would also catch a blob whose own key happened to spell it.
303
+ //
304
+ // The dot is a character class, not `\.`, and that is the fix rather than
305
+ // a style: this string is a *CEL string literal* holding a regex, so it is
306
+ // unescaped once by CEL before the regex engine ever sees it. `\.` is not
307
+ // a CEL escape sequence, so the whole expression failed to compile —
308
+ // `token recognition error at: '"^projects/_/buckets/...providers\.'` —
309
+ // and both bindings below carry `tolerateFailure`, so a deploy printed two
310
+ // warnings and carried on with the scoping silently not applied. `[.]` is
311
+ // the same regex and survives a layer of string unescaping unchanged,
312
+ // which is what keeps the next person from reintroducing it.
244
313
  const providerManifests =
245
- `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers\\.d/")`;
314
+ `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers[.]d/")`;
246
315
 
247
316
  steps.push({
248
317
  title: 'let the revision write its own data, but not the manifests in it',
@@ -64,7 +64,15 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
64
64
  * between matching it and not is a credential in a bucket.
65
65
  */
66
66
  export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
67
- if (key === WORKSPACE_FILE) return true;
67
+ // **Never the workspace file.** It was sent, and it is the one file that must
68
+ // not be: it holds the *target registry*, and the two workspaces have
69
+ // different ones. This machine's says `cloud: workspace: gs://…`, so copying
70
+ // it into that bucket left the bucket pointing at itself — a loop `openTarget`
71
+ // refuses, on the target it had just deployed (ADR-052).
72
+ //
73
+ // The bucket's own registry is written by `deploy`, from the declaration, once
74
+ // the upload is done.
75
+ if (key === WORKSPACE_FILE) return false;
68
76
 
69
77
  // A set rather than one name, because a deploy now sends every profile that
70
78
  // declares the target rather than the single one it was told. `undefined`
@@ -39,11 +39,14 @@ export async function recordTarget(
39
39
  ): Promise<void> {
40
40
  await editRegistry(workspaceRoot, (targets) => {
41
41
  const previous = targets[target];
42
+ // Neither shape merges into the other, and the symmetry is the point: an
43
+ // entry carrying both a `workspace:` and adapters is what the schema refuses.
44
+ //
42
45
  // A pointer replacing a declaration is `deploy` handing the target over to
43
- // the workspace it just wrote, so the adapter keys have to go rather than
44
- // merge an entry carrying both is what the schema refuses.
45
- targets[target] =
46
- entry.workspace !== undefined ? { ...pick(previous), ...entry } : { ...previous, ...entry };
46
+ // the workspace it just wrote. A declaration replacing a pointer is the same
47
+ // command writing the bucket's own registry and merging there left the
48
+ // laptop's `workspace:` on it, pointing the bucket at itself.
49
+ targets[target] = { ...pick(previous), ...entry };
47
50
  });
48
51
  }
49
52
 
@@ -54,7 +57,7 @@ export async function removeTarget(workspaceRoot: string, target: string): Promi
54
57
  });
55
58
  }
56
59
 
57
- /** The fields that survive a declaration becoming a pointer: the deploy record. */
60
+ /** The fields that survive an entry changing shape: the deploy record, and only it. */
58
61
  function pick(previous: WorkspaceTarget | undefined): Partial<WorkspaceTarget> {
59
62
  if (!previous) return {};
60
63
  return {