@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,96 @@
1
+ #!/usr/bin/env bun
2
+ import { startEndpoint } from './endpoint.ts';
3
+ import { streamLogger } from './logging.ts';
4
+
5
+ /**
6
+ * The container entrypoint — `src/deployments/gcp/Dockerfile` runs this.
7
+ *
8
+ * Not `lanes link start`. That command is a terminal UI: it announces the
9
+ * resolved profile with colour, mints a token if none exists, and expects
10
+ * someone to be reading. A container needs the opposite of each — plain lines
11
+ * on stdout for Cloud Logging, a refusal rather than an invention when the
12
+ * token is missing, and a clean SIGTERM. Everything between those two is the
13
+ * same `startEndpoint`, which is the point: the deployed instance runs the
14
+ * same bootstrap as the local one, against a different adapter set.
15
+ *
16
+ * What the environment has to provide:
17
+ *
18
+ * LANES_LINK_HOME the workspace root, as `gs://<bucket>`. Set by
19
+ * `lanes link deploy` at rollout rather than baked into
20
+ * the image, because the bucket is the operator's and the
21
+ * image is meant to serve any workspace (ADR-023).
22
+ * Unset, `resolveWorkspaceRoot` falls back to
23
+ * `~/.lanes-link`, which in a container is a directory
24
+ * nobody wrote — so the endpoint refuses rather than
25
+ * serving an empty workspace.
26
+ * LANES_LINK_TARGET which target's adapters to open. Baked to `cloud`.
27
+ * LANES_LINK_PROFILE optional; the workspace default otherwise.
28
+ * PORT injected by Cloud Run. 8080 is its default.
29
+ */
30
+
31
+ const env = process.env;
32
+
33
+ // Cloud Run routes to whatever the container listens on at $PORT, and health
34
+ // checks fail against a process bound to loopback.
35
+ const port = Number(env['PORT'] ?? 8080);
36
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
37
+ // Refuse rather than fall back to a default: a container listening somewhere
38
+ // other than where its platform routes is a startup probe that never passes,
39
+ // reported as a health failure with nothing to explain it.
40
+ process.stderr.write(`PORT is ${JSON.stringify(env['PORT'])}, which is not a port number.\n`);
41
+ process.exit(1);
42
+ }
43
+ const host = env['LANES_LINK_HOST'] ?? '0.0.0.0';
44
+
45
+ const log = (message: string): void => {
46
+ process.stdout.write(`${new Date().toISOString()} ${message}\n`);
47
+ };
48
+
49
+ try {
50
+ const endpoint = await startEndpoint({
51
+ flags: {
52
+ ...(env['LANES_LINK_PROFILE'] ? { profile: env['LANES_LINK_PROFILE'] } : {}),
53
+ ...(env['LANES_LINK_TARGET'] ? { target: env['LANES_LINK_TARGET'] } : {}),
54
+ quiet: true,
55
+ },
56
+ port,
57
+ host,
58
+ // A deployed instance never mints its own token: see `endpoint.ts`.
59
+ mintToken: false,
60
+ // Stdout is where Cloud Run collects logs, and a rejected credential on a
61
+ // public URL is the event this exists for.
62
+ log: streamLogger((line) => process.stdout.write(`${line}\n`)),
63
+ reporter: {
64
+ reconciled({ profile, plan }) {
65
+ // Reconcile output is the record of what a deploy changed, and it is
66
+ // the only place that record exists — the config it applied is baked
67
+ // into an image that will be replaced.
68
+ log(`reconciled ${profile}\n${plan}`);
69
+ },
70
+ tokenMinted() {},
71
+ },
72
+ });
73
+
74
+ log(`serving ${endpoint.url}`);
75
+ log(`profiles: ${endpoint.profiles.join(', ')}`);
76
+
77
+ // Cloud Run sends SIGTERM and then waits. Closing the runtimes is what writes
78
+ // each audit run's close marker, which is the difference between a run that
79
+ // ended and one whose tail was cut off — see ADR-020.
80
+ const shutdown = async (): Promise<void> => {
81
+ log('shutting down');
82
+ await endpoint.stop();
83
+ process.exit(0);
84
+ };
85
+
86
+ process.on('SIGTERM', () => void shutdown());
87
+ process.on('SIGINT', () => void shutdown());
88
+
89
+ await new Promise(() => {});
90
+ } catch (error) {
91
+ // Exit non-zero so the revision fails to go healthy and Cloud Run keeps
92
+ // serving the previous one, rather than rolling out an endpoint that answers
93
+ // every request with an error.
94
+ process.stderr.write(`${(error as Error).message}\n`);
95
+ process.exit(1);
96
+ }
@@ -0,0 +1,53 @@
1
+ import { RateLimiter } from '#policy';
2
+
3
+ /**
4
+ * A ceiling on failed authentication.
5
+ *
6
+ * Every credential that does not match the cached one costs a re-read of the
7
+ * credential store — a decrypt locally, a network call on a deployed instance.
8
+ * That re-read is what makes a rotated-in token work on its first call, and it
9
+ * is also a way to make the endpoint do expensive work on behalf of a caller
10
+ * holding no credential at all. This bounds it.
11
+ *
12
+ * Not a security boundary, and `#policy`'s own note says so: 256 bits behind
13
+ * `llk_` is what makes guessing hopeless. This is about cost, and about giving
14
+ * a sustained probe a shape in the log rather than an unbroken run of 401s.
15
+ */
16
+ export const FAILED_AUTH_PER_MINUTE = 30;
17
+
18
+ /**
19
+ * Who an attempt is counted against.
20
+ *
21
+ * `x-forwarded-for`'s first hop is the client as the platform's front door saw
22
+ * it, which is the only address that means anything on Cloud Run — the socket
23
+ * peer there is Google's frontend, identical for every caller. It is trivially
24
+ * spoofable by anyone talking to the endpoint directly, which is why the
25
+ * fallback is a single shared bucket rather than something that looks
26
+ * per-caller and is not: a loopback endpoint has one caller, and an attacker
27
+ * who can already reach it can read the config file holding the token anyway.
28
+ */
29
+ export function callerKey(request: Request): string {
30
+ return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'direct';
31
+ }
32
+
33
+ /** Refused for rate rather than for credential — deliberately a different status. */
34
+ export function tooManyAttempts(retryAfterMs: number): Response {
35
+ return new Response(
36
+ JSON.stringify({
37
+ error: 'too_many_requests',
38
+ hint: 'Too many failed authentication attempts. Wait, then retry.',
39
+ }),
40
+ {
41
+ status: 429,
42
+ headers: {
43
+ 'content-type': 'application/json',
44
+ 'retry-after': String(Math.max(1, Math.ceil(retryAfterMs / 1000))),
45
+ },
46
+ },
47
+ );
48
+ }
49
+
50
+ /** One bucket set per endpoint. Idle callers are dropped so keys do not accumulate. */
51
+ export function failedAuthLimiter(): RateLimiter {
52
+ return new RateLimiter();
53
+ }
@@ -0,0 +1,352 @@
1
+ import { MCP_PATH, serve } from './index.ts';
2
+ import { serveOverStdio } from './stdio.ts';
3
+ import { Generations, type OpenedWorkspace } from './generations.ts';
4
+ import type { AuthorizationSurface } from './oauth.ts';
5
+ import type { ProfileRuntime } from './mcp/index.ts';
6
+ import {
7
+ AuthenticatorChain,
8
+ IssuedTokenAuthenticator,
9
+ OAuthServer,
10
+ OAuthStore,
11
+ OidcAuthenticator,
12
+ OidcVerifier,
13
+ tokensMatch,
14
+ type Authenticator,
15
+ } from '#auth';
16
+ import type { Logger } from '#connectivity';
17
+ import { silentLogger } from './logging.ts';
18
+ import { listProfiles } from '#profile';
19
+ import {
20
+ applyReconcile,
21
+ formatPlan,
22
+ planIsNoop,
23
+ planReconcile,
24
+ toPolicyDocument,
25
+ } from '#registry';
26
+ import { ensureProfileToken, openRuntime, type GlobalFlags, type Runtime } from '#cli/runtime.ts';
27
+
28
+ /**
29
+ * Bringing the endpoint up: open a runtime per profile, reconcile, and serve.
30
+ *
31
+ * This is the bootstrap, and it exists as its own function because there are
32
+ * now two callers with genuinely different needs. `lanes link start` runs it on
33
+ * an operator's machine, prints to a terminal, and mints a profile token when
34
+ * none exists. The container entrypoint runs it under Cloud Run, logs to
35
+ * stdout, and must *not* mint anything — a token invented inside a container
36
+ * that scales to zero is one nobody can read back, so the endpoint would come
37
+ * up healthy and reject every agent.
38
+ *
39
+ * The alternative was for the image to shell out to `lanes link start`, which
40
+ * would have made the container's behaviour a side effect of a CLI command's
41
+ * presentation code. Everything below the reporter is identical on both paths,
42
+ * which is what makes "the same config with only the target switched" true.
43
+ */
44
+
45
+ export interface EndpointReporter {
46
+ /** A profile whose runtime state differed from its config, and what was applied. */
47
+ reconciled(input: { profile: string; plan: string; ofMany: boolean }): void;
48
+ /** No profile token existed and one was minted. */
49
+ tokenMinted(minted: { target: string }): void;
50
+ }
51
+
52
+ const SILENT: EndpointReporter = { reconciled() {}, tokenMinted() {} };
53
+
54
+ export interface EndpointOptions {
55
+ readonly flags: GlobalFlags;
56
+ readonly port?: number | undefined;
57
+ readonly host?: string | undefined;
58
+ /** Serve just the resolved profile, rather than every profile in the workspace. */
59
+ readonly only?: boolean | undefined;
60
+ /**
61
+ * Mint a profile token when the store has none. True for `lanes link start`,
62
+ * false in a container — see the note above.
63
+ */
64
+ readonly mintToken?: boolean | undefined;
65
+ readonly reporter?: EndpointReporter | undefined;
66
+ /** Operational events. Silent when absent, which is what the tests want. */
67
+ readonly log?: Logger | undefined;
68
+ }
69
+
70
+ export interface RunningEndpoint {
71
+ readonly url: string;
72
+ readonly profiles: readonly string[];
73
+ stop(): Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * Every profile this workspace serves, opened and reconciled.
78
+ *
79
+ * Shared by both transports because it is the whole of what "bring the
80
+ * workspace up" means — which profiles, in what state. What differs after it is
81
+ * only how they are reached: a port and a token, or a pipe.
82
+ */
83
+ async function openReconciled(options: {
84
+ readonly flags: GlobalFlags;
85
+ readonly only?: boolean | undefined;
86
+ readonly reporter?: EndpointReporter | undefined;
87
+ }): Promise<{ primary: Runtime; runtimes: Map<string, Runtime> }> {
88
+ const reporter = options.reporter ?? SILENT;
89
+ const primary = await openRuntime(options.flags);
90
+
91
+ // One endpoint, every profile. The alternative — a port each — meant an agent
92
+ // registering N servers to reach N profiles, and the ports leaked a detail
93
+ // nobody wants to think about. What it costs is that a single token now
94
+ // reaches every profile and the caller names which, so the boundary between
95
+ // them is a policy decision per call rather than a separate URL.
96
+ const names = options.only
97
+ ? [primary.resolution.profile]
98
+ : await listProfiles(primary.resolution.workspaceRoot);
99
+
100
+ const runtimes = new Map<string, Runtime>();
101
+ runtimes.set(primary.resolution.profile, primary);
102
+
103
+ try {
104
+ for (const name of names) {
105
+ if (runtimes.has(name)) continue;
106
+ runtimes.set(name, await openRuntime({ ...options.flags, profile: name }));
107
+ }
108
+
109
+ for (const [name, runtime] of runtimes) {
110
+ const result = await planReconcile(
111
+ runtime.config,
112
+ runtime.state,
113
+ runtime.credentials,
114
+ runtime.manifestFor,
115
+ );
116
+ if (!planIsNoop(result)) {
117
+ reporter.reconciled({
118
+ profile: name,
119
+ plan: formatPlan(result),
120
+ ofMany: runtimes.size > 1,
121
+ });
122
+ await applyReconcile(runtime.config, runtime.state, result);
123
+ }
124
+ }
125
+
126
+ return { primary, runtimes };
127
+ } catch (error) {
128
+ // A runtime opened before the failure holds a state handle, and in a
129
+ // container a leaked pool keeps connections checked out of the pooler
130
+ // while the process crash-loops.
131
+ await closeAll(runtimes);
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /** What the transports need from a runtime, which is less than a runtime. */
137
+ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, ProfileRuntime> {
138
+ return new Map(
139
+ [...runtimes].map(([name, runtime]) => [
140
+ name,
141
+ {
142
+ config: runtime.config,
143
+ registry: runtime.registry,
144
+ dispatcher: runtime.dispatcher,
145
+ policy: toPolicyDocument(runtime.config),
146
+ // So a skill written since this endpoint started — by an agent
147
+ // through `skills.manage.write`, or by `lanes link skills add` in another
148
+ // terminal — is a prompt without a restart (ADR-014).
149
+ refreshSkills: runtime.refreshSkills,
150
+ },
151
+ ]),
152
+ );
153
+ }
154
+
155
+ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
156
+ return Promise.all([...runtimes.values()].map((runtime) => runtime.close()));
157
+ }
158
+
159
+ /**
160
+ * The remote-client gate, if this profile declares one.
161
+ *
162
+ * Endpoint-scoped rather than per profile, like the bearer token and for the
163
+ * same reason (ADR-009): one URL serves every profile in the workspace, so
164
+ * there is one place a client authorises and one set of tokens.
165
+ *
166
+ * Returns null when `auth.authorization` is absent, and everything downstream
167
+ * treats null as "exactly as before" — no metadata published, no pointer on the
168
+ * `401`, one authenticator instead of a chain.
169
+ */
170
+ async function openAuthorization(
171
+ primary: Runtime,
172
+ ): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
173
+ const declared = primary.config.auth.authorization;
174
+ if (!declared) return null;
175
+
176
+ const profile = primary.resolution.profile;
177
+
178
+ if (declared.mode === 'oidc') {
179
+ const audience = await primary.credentials.get(declared.client_id_ref);
180
+ if (!audience) {
181
+ // Refuse rather than verify without an audience. A verifier that cannot
182
+ // check who a token was issued for accepts every token the issuer minted
183
+ // for anything, which is the failure this mode exists to prevent.
184
+ throw new Error(
185
+ `auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
186
+ `this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
187
+ );
188
+ }
189
+
190
+ const verifier = new OidcVerifier({
191
+ issuer: declared.issuer,
192
+ audience,
193
+ allowedSubjects: declared.allowed_subjects,
194
+ ...(declared.introspection_endpoint
195
+ ? { introspectionEndpoint: declared.introspection_endpoint }
196
+ : {}),
197
+ });
198
+
199
+ return {
200
+ // The issuer is somebody else's origin, so it is a constant here rather
201
+ // than derived from the request.
202
+ surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
203
+ authenticator: new OidcAuthenticator(verifier, profile),
204
+ };
205
+ }
206
+
207
+ const store = new OAuthStore(primary.state.kv);
208
+ const expected = primary.config.auth.token_ref;
209
+
210
+ const server = new OAuthServer({
211
+ store,
212
+ accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
213
+ // Approval is proof of holding the endpoint token, compared the same way
214
+ // the request path compares it. There is one person behind this endpoint
215
+ // and they already have exactly one credential; a second one invented for
216
+ // the consent screen would be a password to lose.
217
+ verifyOwner: async (presented) => {
218
+ const token = await primary.credentials.get(expected);
219
+ return token !== null && tokensMatch(presented, token);
220
+ },
221
+ });
222
+
223
+ return {
224
+ surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
225
+ authenticator: new IssuedTokenAuthenticator(store, profile),
226
+ };
227
+ }
228
+
229
+ export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
230
+ const reporter = options.reporter ?? SILENT;
231
+ const { primary, runtimes } = await openReconciled(options);
232
+
233
+ try {
234
+ if (options.mintToken) {
235
+ const { created } = await ensureProfileToken(
236
+ primary.credentials,
237
+ primary.config.auth.token_ref,
238
+ );
239
+ if (created) reporter.tokenMinted({ target: primary.target });
240
+ } else {
241
+ // Deployed, the token is written by the operator's CLI into the target's
242
+ // credential store and read back here. Refusing to start beats serving an
243
+ // endpoint whose token nobody holds.
244
+ const token = await primary.credentials.get(primary.config.auth.token_ref);
245
+ if (!token) {
246
+ throw new Error(
247
+ `No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
248
+ 'A deployed instance never mints its own — run `lanes link token rotate --target <target>` ' +
249
+ 'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
250
+ );
251
+ }
252
+ }
253
+
254
+ const gate = await openAuthorization(primary);
255
+
256
+ // The authenticator and the authorization gate are built once, from the
257
+ // runtime this endpoint booted with, and are deliberately not part of what
258
+ // a reload replaces (ADR-029). Retiring that generation stays safe for them
259
+ // because `Runtime.close()` ends connector sessions and the audit log — it
260
+ // does not close the credential store or the state handle these hold.
261
+ const generations = new Generations(
262
+ { profiles: profileRuntimes(runtimes), close: () => closeAll(runtimes).then(() => {}) },
263
+ async (): Promise<OpenedWorkspace> => {
264
+ const reopened = await openReconciled(options);
265
+ return {
266
+ profiles: profileRuntimes(reopened.runtimes),
267
+ close: () => closeAll(reopened.runtimes).then(() => {}),
268
+ };
269
+ },
270
+ { primary: primary.resolution.profile, log: { debug() {}, info() {}, warn() {}, error() {} } },
271
+ );
272
+
273
+ const server = serve({
274
+ generations,
275
+ primary: primary.resolution.profile,
276
+ authenticator: gate
277
+ ? new AuthenticatorChain([primary.authenticator, gate.authenticator])
278
+ : primary.authenticator,
279
+ log: options.log ?? silentLogger(),
280
+ ...(gate ? { authorization: gate.surface } : {}),
281
+ ...(options.port !== undefined ? { port: options.port } : {}),
282
+ ...(options.host !== undefined ? { host: options.host } : {}),
283
+ });
284
+
285
+ return {
286
+ url: server.url,
287
+ profiles: [...runtimes.keys()],
288
+ // `server.stop()` closes the request handler, which closes whichever
289
+ // generation is current — and a generation owns the runtimes it opened.
290
+ // Closing `runtimes` here too would reach past a reload and close a set
291
+ // nothing is serving from any more.
292
+ stop: () => server.stop(),
293
+ };
294
+ } catch (error) {
295
+ await closeAll(runtimes);
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ export interface StdioEndpointOptions {
301
+ readonly flags: GlobalFlags;
302
+ /** Serve just the resolved profile, rather than every profile in the workspace. */
303
+ readonly only?: boolean | undefined;
304
+ /**
305
+ * Where reconcile output goes.
306
+ *
307
+ * Not the terminal, on this path: stdout carries JSON-RPC, and a single line
308
+ * written to it corrupts the stream for the client. The caller reports to
309
+ * stderr or not at all.
310
+ */
311
+ readonly reporter?: EndpointReporter | undefined;
312
+ readonly log?: Logger | undefined;
313
+ readonly clientLabel?: string | undefined;
314
+ }
315
+
316
+ export interface RunningStdioEndpoint {
317
+ readonly profiles: readonly string[];
318
+ stop(): Promise<void>;
319
+ }
320
+
321
+ /**
322
+ * The same workspace, served over this process's stdin and stdout.
323
+ *
324
+ * No port, no token, no reachability question — see `stdio.ts` for why the pipe
325
+ * replaces the authenticator. Used by clients that cannot be handed a URL at
326
+ * all, which is most desktop applications.
327
+ */
328
+ export async function startStdioEndpoint(
329
+ options: StdioEndpointOptions,
330
+ ): Promise<RunningStdioEndpoint> {
331
+ const { primary, runtimes } = await openReconciled(options);
332
+
333
+ try {
334
+ const surface = serveOverStdio({
335
+ profiles: profileRuntimes(runtimes),
336
+ primary: primary.resolution.profile,
337
+ log: options.log ?? silentLogger(),
338
+ ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}),
339
+ });
340
+
341
+ return {
342
+ profiles: [...runtimes.keys()],
343
+ async stop() {
344
+ await surface.close();
345
+ await closeAll(runtimes);
346
+ },
347
+ };
348
+ } catch (error) {
349
+ await closeAll(runtimes);
350
+ throw error;
351
+ }
352
+ }