@lanes-sh/link 0.1.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.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,175 @@
1
+ import type {
2
+ CommandResult,
3
+ DeployDriver,
4
+ DeployStep,
5
+ PlanInput,
6
+ ProvisionInput,
7
+ SurveyInput,
8
+ SurveyResult,
9
+ } from '../driver.ts';
10
+ import type { DeployConfig } from '#profile';
11
+ import { encodeRef } from '../adapters/gcp-secret-manager.ts';
12
+ import {
13
+ captureGcloud,
14
+ gcloudPath,
15
+ requireProject,
16
+ runGcloud,
17
+ serviceUrl,
18
+ type CloudRunTarget,
19
+ } from './gcloud.ts';
20
+ import { provisionSteps } from './provision.ts';
21
+ import { surveyCloudRun } from './survey.ts';
22
+
23
+ /**
24
+ * Cloud Run.
25
+ *
26
+ * Everything Google-specific about deploying lives behind this object: the argv
27
+ * `gcloud` wants, the shape of an Artifact Registry reference, and the fact that
28
+ * a Cloud Run URL has to be asked for rather than constructed. The generic
29
+ * command in `../deploy.ts` knows none of it.
30
+ *
31
+ * Config travels one way — from the CLI into an image — and a deployed instance
32
+ * never mutates its own configuration and exposes no admin API. That is ADR-007
33
+ * extended to the deployment, and it is why this builds an image rather than
34
+ * pushing config to a running service.
35
+ */
36
+
37
+ /** Where the image lives. Artifact Registry, one repository per project. */
38
+ export function imageReference(cloudrun: CloudRunTarget, tag: string): string {
39
+ return `${cloudrun.region}-docker.pkg.dev/${cloudrun.project}/lanes-link/${cloudrun.service}:${tag}`;
40
+ }
41
+
42
+ /**
43
+ * The commands a deploy runs, as data.
44
+ *
45
+ * Separated from running them so `--dry-run` shows exactly what will happen and
46
+ * so the argv construction is testable without a Google Cloud project. Every
47
+ * value is a separate argv element; nothing is interpolated into a shell.
48
+ */
49
+ export function deployPlan(input: PlanInput): DeployStep[] {
50
+ const cloudrun = requireProject(input.deploy, input.target);
51
+ const image = imageReference(cloudrun, input.tag);
52
+ const scope = ['--project', cloudrun.project, '--region', cloudrun.region];
53
+
54
+ return [
55
+ {
56
+ title: 'ensure the Artifact Registry repository exists',
57
+ argv: [
58
+ 'artifacts',
59
+ 'repositories',
60
+ 'create',
61
+ 'lanes-link',
62
+ '--repository-format',
63
+ 'docker',
64
+ '--location',
65
+ cloudrun.region,
66
+ '--project',
67
+ cloudrun.project,
68
+ '--description',
69
+ 'Lanes Link images',
70
+ ],
71
+ // Second and subsequent deploys: already there, and that is the success
72
+ // case rather than a failure worth stopping for.
73
+ tolerateFailure: true,
74
+ },
75
+ {
76
+ title: 'build and push the image',
77
+ argv: [
78
+ 'builds',
79
+ 'submit',
80
+ '--project',
81
+ cloudrun.project,
82
+ '--config',
83
+ 'src/deployments/gcp/cloudbuild.yaml',
84
+ '--substitutions',
85
+ `_IMAGE=${image}`,
86
+ '.',
87
+ ],
88
+ },
89
+ {
90
+ title: 'roll a revision',
91
+ argv: [
92
+ 'run',
93
+ 'deploy',
94
+ cloudrun.service,
95
+ ...scope,
96
+ '--image',
97
+ image,
98
+ '--platform',
99
+ 'managed',
100
+ '--port',
101
+ '8080',
102
+ // The container reads these; everything else it needs is baked in.
103
+ '--set-env-vars',
104
+ [
105
+ `LANES_LINK_TARGET=${input.target}`,
106
+ ...(input.profile ? [`LANES_LINK_PROFILE=${input.profile}`] : []),
107
+ ...(input.workspace ? [`LANES_LINK_HOME=${input.workspace}`] : []),
108
+ ].join(','),
109
+ ...secretMounts(input.secretEnv),
110
+ ...(cloudrun.service_account ? ['--service-account', cloudrun.service_account] : []),
111
+ // `iam` is Cloud Run's own identity check, which admits only a caller
112
+ // holding a Google-signed identity token for this service. No agent
113
+ // harness can mint one — so a target reached by a remote MCP client
114
+ // declares `public` and gates the request in the application instead.
115
+ cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
116
+ ],
117
+ },
118
+ ];
119
+ }
120
+
121
+ /**
122
+ * Environment the revision reads out of Secret Manager, as `--set-secrets`.
123
+ *
124
+ * The value never travels: Cloud Run is told a secret id and resolves it at
125
+ * instance start, as the runtime service account, so the key stays out of the
126
+ * argv this prints, out of the revision's stored description, and out of
127
+ * anything `gcloud run services describe` returns.
128
+ *
129
+ * The `/` → `__` encoding is the credential adapter's, applied here rather than
130
+ * duplicated: `vault/key` is the secret `vault__key`, and a driver that spelled
131
+ * that itself would drift from the store that writes it.
132
+ */
133
+ function secretMounts(secretEnv: PlanInput['secretEnv']): string[] {
134
+ const entries = Object.entries(secretEnv ?? {});
135
+ if (entries.length === 0) return [];
136
+
137
+ return [
138
+ '--set-secrets',
139
+ entries.map(([variable, ref]) => `${variable}=${encodeRef(ref)}:latest`).join(','),
140
+ ];
141
+ }
142
+
143
+ export const cloudRunDriver: DeployDriver = {
144
+ platform: 'cloudrun',
145
+ tool: 'gcloud',
146
+
147
+ preflight() {
148
+ // A property of *this* deployment, not of deployments generally — which is
149
+ // why it is asked here rather than by the generic command.
150
+ return gcloudPath()
151
+ ? null
152
+ : 'gcloud is not on your PATH. Install the Google Cloud CLI, run `gcloud auth login`, ' +
153
+ 'and try again — or use --dry-run to see the commands and run them yourself.';
154
+ },
155
+
156
+ survey(input: SurveyInput): Promise<SurveyResult> {
157
+ return surveyCloudRun(input);
158
+ },
159
+
160
+ provision(input: ProvisionInput): Promise<DeployStep[]> {
161
+ return provisionSteps(input);
162
+ },
163
+
164
+ plan: deployPlan,
165
+
166
+ url(deploy: DeployConfig): Promise<string | null> {
167
+ // `outputs` calls this for any deployable target and must degrade rather
168
+ // than throw: gcloud may be absent and the service may not exist yet.
169
+ return deploy.project ? serviceUrl(deploy as CloudRunTarget) : Promise.resolve(null);
170
+ },
171
+
172
+ run(argv: readonly string[], options?: { quiet?: boolean }): Promise<CommandResult> {
173
+ return options?.quiet === true ? captureGcloud(argv) : runGcloud(argv);
174
+ },
175
+ };
@@ -0,0 +1,178 @@
1
+ import { ConfigError, type DeployConfig } from '#profile';
2
+ import type { CommandResult } from '../driver.ts';
3
+
4
+ /**
5
+ * Shelling out to `gcloud`.
6
+ *
7
+ * `lanes link deploy` is a thin wrapper, not a deployment engine (`docs/detailed/init.md`), and
8
+ * the thinnest correct wrapper drives the tool the operator already has
9
+ * authenticated. Reimplementing Cloud Build and the Cloud Run Admin API over
10
+ * REST would mean owning an OAuth flow, a long-running-operation poller, and a
11
+ * revision spec — for a command whose entire job is "build this, roll that".
12
+ *
13
+ * The commands are constructed as argv arrays and never as shell strings, so a
14
+ * project or service name cannot become an argument to something else.
15
+ */
16
+
17
+ export function gcloudPath(): string | null {
18
+ return Bun.which('gcloud');
19
+ }
20
+
21
+ /**
22
+ * Run a `gcloud` invocation, streaming its output to the terminal.
23
+ *
24
+ * Streamed rather than captured because a build takes minutes and silence for
25
+ * the duration is indistinguishable from a hang.
26
+ *
27
+ * stderr is streamed *and* kept. It used to be inherited outright, which meant
28
+ * a failed step reached the caller as a bare exit code — and "should this be
29
+ * retried" is a question about the message, not the code. A freshly enabled API
30
+ * and a genuinely missing permission both exit 1 and say different things.
31
+ *
32
+ * Only the tail is kept. A build streams its entire log here, and no amount of
33
+ * it beyond the end explains an exit code.
34
+ */
35
+ const KEPT_STDERR = 8192;
36
+
37
+ export async function runGcloud(argv: readonly string[]): Promise<CommandResult> {
38
+ const gcloud = gcloudPath();
39
+ if (!gcloud) {
40
+ return {
41
+ ok: false,
42
+ stdout: '',
43
+ stderr:
44
+ 'gcloud is not on your PATH. Install the Google Cloud CLI and run `gcloud auth login`.',
45
+ };
46
+ }
47
+
48
+ const child = Bun.spawn([gcloud, ...argv], { stdout: 'inherit', stderr: 'pipe' });
49
+
50
+ let captured = '';
51
+ const decoder = new TextDecoder();
52
+ const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
53
+
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+
58
+ // Written through as it arrives, so a long build reads exactly as it did
59
+ // when this was inherited.
60
+ const text = decoder.decode(value, { stream: true });
61
+ process.stderr.write(text);
62
+ captured = (captured + text).slice(-KEPT_STDERR);
63
+ }
64
+
65
+ const code = await child.exited;
66
+ return { ok: code === 0, stdout: '', stderr: captured };
67
+ }
68
+
69
+ /** Run a `gcloud` invocation for its output, without printing it. */
70
+ export async function captureGcloud(argv: readonly string[]): Promise<CommandResult> {
71
+ const gcloud = gcloudPath();
72
+ if (!gcloud) {
73
+ return { ok: false, stdout: '', stderr: 'gcloud is not on your PATH.' };
74
+ }
75
+
76
+ const child = Bun.spawn([gcloud, ...argv], { stdout: 'pipe', stderr: 'pipe' });
77
+ const [stdout, stderr, code] = await Promise.all([
78
+ new Response(child.stdout).text(),
79
+ new Response(child.stderr).text(),
80
+ child.exited,
81
+ ]);
82
+ return { ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim() };
83
+ }
84
+
85
+ /**
86
+ * A deploy block that has the field Cloud Run cannot do without.
87
+ *
88
+ * `project` is optional in the schema because it means nothing to a platform
89
+ * without projects, on the same reasoning as `credentials.project` — so the
90
+ * driver that needs it is the thing that refuses without it, and says which
91
+ * platform is asking.
92
+ */
93
+ export interface CloudRunTarget extends DeployConfig {
94
+ readonly project: string;
95
+ }
96
+
97
+ export function requireProject(deploy: DeployConfig, target: string): CloudRunTarget {
98
+ if (!deploy.project) {
99
+ throw new ConfigError(
100
+ `targets.${target}.deploy.project is required for the cloudrun platform — ` +
101
+ 'a Cloud Run service is addressed by project, region, and name.',
102
+ );
103
+ }
104
+ return { ...deploy, project: deploy.project };
105
+ }
106
+
107
+ /**
108
+ * The public URL Cloud Run assigned this service, or null if it has none yet.
109
+ *
110
+ * The URL is not derivable from the service name — Cloud Run mixes in a
111
+ * project-specific hash — so it has to be asked for rather than constructed.
112
+ */
113
+ export async function serviceUrl(target: CloudRunTarget): Promise<string | null> {
114
+ const result = await captureGcloud([
115
+ 'run',
116
+ 'services',
117
+ 'describe',
118
+ target.service,
119
+ '--project',
120
+ target.project,
121
+ '--region',
122
+ target.region,
123
+ '--format',
124
+ 'value(status.url)',
125
+ ]);
126
+
127
+ return result.ok && result.stdout ? result.stdout : null;
128
+ }
129
+
130
+ /** The project `gcloud` is currently pointed at, if any — a survey default. */
131
+ export async function activeProject(): Promise<string | null> {
132
+ const result = await captureGcloud(['config', 'get-value', 'project']);
133
+ const value = result.stdout.trim();
134
+ return result.ok && value && value !== '(unset)' ? value : null;
135
+ }
136
+
137
+ /**
138
+ * Whether a project id is already somebody's.
139
+ *
140
+ * Asked so the survey knows whether it is naming an existing project or one it
141
+ * has to create — which decides whether it needs a billing account at all.
142
+ * A project id that exists but belongs to someone else answers the same as one
143
+ * of yours, which is the right answer here: either way this deploy will not be
144
+ * creating it.
145
+ */
146
+ export async function projectExists(project: string): Promise<boolean> {
147
+ const result = await captureGcloud(['projects', 'describe', project, '--format', 'value(projectId)']);
148
+ return result.ok && result.stdout.trim() === project;
149
+ }
150
+
151
+ /**
152
+ * Billing accounts this login can attach a project to.
153
+ *
154
+ * Only open ones: a closed account lists, links, and then fails every API
155
+ * enable behind it with a message about the API rather than about billing.
156
+ */
157
+ export async function openBillingAccounts(): Promise<{ id: string; name: string }[]> {
158
+ const result = await captureGcloud([
159
+ 'billing',
160
+ 'accounts',
161
+ 'list',
162
+ '--filter',
163
+ 'open=true',
164
+ '--format',
165
+ 'value(name,displayName)',
166
+ ]);
167
+ if (!result.ok) return [];
168
+
169
+ return result.stdout
170
+ .split('\n')
171
+ .map((line) => line.trim())
172
+ .filter(Boolean)
173
+ .map((line) => {
174
+ const [id = '', ...rest] = line.split(/\s+/);
175
+ return { id: id.replace(/^billingAccounts\//, ''), name: rest.join(' ') || id };
176
+ })
177
+ .filter((account) => account.id.length > 0);
178
+ }
@@ -0,0 +1,290 @@
1
+ import { VAULT_DOCUMENT_REF } from '#secrets';
2
+ import type { DeployStep, ProvisionInput } from '../driver.ts';
3
+ import { encodeRef } from '../adapters/gcp-secret-manager.ts';
4
+ import { requireProject } from './gcloud.ts';
5
+
6
+ /**
7
+ * The project-level things a Cloud Run deploy needs to already exist.
8
+ *
9
+ * These used to be a numbered list in `docs/detailed/deployment-cloudrun.md` that the
10
+ * operator worked through by hand before their first deploy could get past its
11
+ * first step. Everything here is derivable from what the target already
12
+ * declares, so asking someone to transcribe it into a console was work with no
13
+ * decision in it.
14
+ *
15
+ * Returned as steps rather than run, like the rollout itself, so `--dry-run`
16
+ * shows the whole first-run sequence and so this is testable without a project.
17
+ * Every one tolerates failure: the second deploy finds all of them present, and
18
+ * `ALREADY_EXISTS` is the success case.
19
+ */
20
+
21
+ /**
22
+ * Enabled together, in one call.
23
+ *
24
+ * Cloud Build is not optional even though nothing here names it after the build
25
+ * step: `gcloud builds submit` fails with a permission error rather than a
26
+ * "not enabled" one, which reads as a broken account.
27
+ *
28
+ * The last two are for the steps *below this one*, and they were missing. Every
29
+ * step here tolerates failure, so `iam.service-accounts create` and
30
+ * `projects add-iam-policy-binding` against a project without them failed
31
+ * silently — the deploy carried on, rolled a revision with no service account
32
+ * and no binding, and the first symptom was the revision failing to read a
33
+ * secret several minutes later. An API a later step needs belongs in the call
34
+ * that enables APIs.
35
+ */
36
+ const REQUIRED_SERVICES = [
37
+ 'run.googleapis.com',
38
+ 'cloudbuild.googleapis.com',
39
+ 'artifactregistry.googleapis.com',
40
+ 'secretmanager.googleapis.com',
41
+ 'storage.googleapis.com',
42
+ 'iam.googleapis.com',
43
+ 'cloudresourcemanager.googleapis.com',
44
+ ];
45
+
46
+ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
47
+ const cloudrun = requireProject(input.deploy, input.target);
48
+ const { project, region, service_account: serviceAccount } = cloudrun;
49
+ const steps: DeployStep[] = [];
50
+
51
+ // Before everything, when the survey found no project by this name.
52
+ //
53
+ // `billing_account` is only written for a project that did not exist, so its
54
+ // presence is the record of "this deploy owns creating it". Linking billing is
55
+ // not optional and not deferrable: a project without it enables no API, and
56
+ // every step below would fail describing the API rather than the billing.
57
+ if (cloudrun.billing_account) {
58
+ steps.push({
59
+ title: `create the project ${project}`,
60
+ argv: ['projects', 'create', project],
61
+ tolerateFailure: true,
62
+ });
63
+
64
+ steps.push({
65
+ title: `attach it to billing account ${cloudrun.billing_account}`,
66
+ argv: [
67
+ 'billing',
68
+ 'projects',
69
+ 'link',
70
+ project,
71
+ '--billing-account',
72
+ cloudrun.billing_account,
73
+ ],
74
+ tolerateFailure: true,
75
+ });
76
+ }
77
+
78
+ steps.push({
79
+ title: `enable the APIs this deploy uses (${REQUIRED_SERVICES.length})`,
80
+ argv: ['services', 'enable', ...REQUIRED_SERVICES, '--project', project],
81
+ tolerateFailure: true,
82
+ });
83
+
84
+ if (serviceAccount) {
85
+ // The local part of the address is the account id gcloud wants; passing the
86
+ // whole address creates `foo@bar.iam...@project.iam...`, which then fails
87
+ // every binding below with a name that looks almost right.
88
+ const accountId = serviceAccount.split('@')[0] || `${cloudrun.service}-run`;
89
+
90
+ steps.push({
91
+ title: `create the runtime service account ${accountId}`,
92
+ argv: [
93
+ 'iam',
94
+ 'service-accounts',
95
+ 'create',
96
+ accountId,
97
+ '--project',
98
+ project,
99
+ '--display-name',
100
+ `Lanes Link (${cloudrun.service})`,
101
+ ],
102
+ tolerateFailure: true,
103
+ });
104
+
105
+ // Read, named one secret at a time, for the same reason the write side is:
106
+ // a resource-level grant needs no condition to be scoped.
107
+ //
108
+ // This was a project-wide `secretAccessor`. The adapter argued for it and
109
+ // the argument was half right — the line worth defending really is
110
+ // `secrets.create`, which stays with the operator — but project-wide read
111
+ // is broader than a revision ever uses, and `askProject` is happy to point
112
+ // a deploy at a project that already holds other things. An SSRF in the
113
+ // endpoint should reach this profile's credentials, not everything sharing
114
+ // a project with it.
115
+ //
116
+ // Affordable because the serving path reads by explicit ref: `list()` is a
117
+ // CLI call, and `secretAccessor` never carried `secrets.list` anyway.
118
+ // `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
+ }
140
+ }
141
+
142
+ // What a revision rewrites in its own credential store, named one secret at a
143
+ // time. Two kinds, and they arrive from different places:
144
+ //
145
+ // - the vault document, because `vault.put` is a capability an agent may
146
+ // hold under policy (ADR-022);
147
+ // - each connection's OAuth token, because a refresh persists and serving a
148
+ // request is what triggers it (ADR-026).
149
+ //
150
+ // The second was missing, and the shape of the miss is worth keeping in mind:
151
+ // nothing here was wrong, it was incomplete, and being incomplete looked
152
+ // exactly like being finished. Reading mail 403'd an hour after every deploy.
153
+ //
154
+ // Two steps each, and the first is what keeps the second narrow: the secret is
155
+ // created here so the revision only ever needs to *add a version*, never
156
+ // `secrets.create`, which is a project-level permission that would let it mint
157
+ // credential refs of its own. The binding is on the one secret, so it needs no
158
+ // condition to be scoped — a resource-level grant already is.
159
+ const writable = serviceAccount
160
+ ? [
161
+ ...(input.declared.vault?.adapter === 'secret'
162
+ ? [input.declared.vault.ref ?? VAULT_DOCUMENT_REF]
163
+ : []),
164
+ ...(input.rotatable ?? []),
165
+ ]
166
+ : [];
167
+
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
+ });
194
+ }
195
+
196
+ // Any target that addresses a bucket, which deployed means all of them:
197
+ // config, state, the log, memory, skills and attachments are all objects in
198
+ // it, and it is the only stateful thing a deployment has besides Secret
199
+ // Manager. A target on the filesystem adapter has no bucket, and creating
200
+ // one it will never open would be a resource nobody asked for and nobody
201
+ // deletes.
202
+ const usesBucket =
203
+ input.declared.storage.adapter === 'gcs' || input.declared.storage.adapter === 's3';
204
+ const bucket = usesBucket ? input.declared.storage.bucket : undefined;
205
+ if (bucket) {
206
+ steps.push({
207
+ title: `create the bucket gs://${bucket}`,
208
+ argv: [
209
+ 'storage',
210
+ 'buckets',
211
+ 'create',
212
+ `gs://${bucket}`,
213
+ '--project',
214
+ project,
215
+ '--location',
216
+ region,
217
+ // Blobs here are read and written by one instance at a time and never
218
+ // served publicly; uniform access removes per-object ACLs as a way to
219
+ // get that wrong.
220
+ '--uniform-bucket-level-access',
221
+ ],
222
+ tolerateFailure: true,
223
+ });
224
+
225
+ if (serviceAccount) {
226
+ // Two conditioned bindings rather than one blanket objectAdmin, because
227
+ // the bucket now holds the config as well as the data.
228
+ //
229
+ // ADR-007 says a deployed instance never mutates its own configuration.
230
+ // That used to be enforced by the image being read-only, which stopped
231
+ // being true when the workspace moved into the bucket (ADR-023). This is
232
+ // where the guarantee went: the revision may write what it owns and may
233
+ // only read what declares what it is.
234
+ const objectsUnder = (path: string): string =>
235
+ `resource.name.startsWith("projects/_/buckets/${bucket}/objects/${path}")`;
236
+ const objectIs = (path: string): string =>
237
+ `resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
238
+
239
+ // A provider manifest is configuration that happens to live inside the
240
+ // profile's directory (ADR-030), so `data/` alone no longer separates
241
+ // what the revision owns from what declares what it is. Anchored to the
242
+ // profile segment rather than matched loosely: `contains("/providers.d/")`
243
+ // would also catch a blob whose own key happened to spell it.
244
+ const providerManifests =
245
+ `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers\\.d/")`;
246
+
247
+ steps.push({
248
+ title: 'let the revision write its own data, but not the manifests in it',
249
+ argv: [
250
+ 'storage',
251
+ 'buckets',
252
+ 'add-iam-policy-binding',
253
+ `gs://${bucket}`,
254
+ '--member',
255
+ `serviceAccount:${serviceAccount}`,
256
+ // objectAdmin, not objectViewer: state, the log, attachments, memory
257
+ // and skills are all written by the running endpoint.
258
+ '--role',
259
+ 'roles/storage.objectAdmin',
260
+ '--condition',
261
+ `title=owns-its-data,expression=${objectsUnder('data/')} && !${providerManifests}`,
262
+ ],
263
+ tolerateFailure: true,
264
+ });
265
+
266
+ steps.push({
267
+ title: 'let the revision read its config, and only read it',
268
+ argv: [
269
+ 'storage',
270
+ 'buckets',
271
+ 'add-iam-policy-binding',
272
+ `gs://${bucket}`,
273
+ '--member',
274
+ `serviceAccount:${serviceAccount}`,
275
+ '--role',
276
+ 'roles/storage.objectViewer',
277
+ // `expression=true` was here, which is every object in the bucket —
278
+ // the step title and ADR-023 both claim a narrowing this did not do.
279
+ // The config the revision reads is the workspace file, the profiles
280
+ // beside it, and each profile's own manifests, so name exactly those.
281
+ '--condition',
282
+ `title=reads-its-config,expression=${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')} || ${providerManifests}`,
283
+ ],
284
+ tolerateFailure: true,
285
+ });
286
+ }
287
+ }
288
+
289
+ return Promise.resolve(steps);
290
+ }