@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,362 @@
1
+ import {
2
+ createMcpHandler,
3
+ type McpHttpHandler,
4
+ type McpRequestContext,
5
+ } from '@modelcontextprotocol/server';
6
+ import { ownerPrincipal, type Principal } from '#auth';
7
+ import type { Logger } from '#connectivity';
8
+ import { clearUpstreamTokens } from '#connectivity/auth/index.ts';
9
+ import {
10
+ buildMcpServer,
11
+ toolNameFor,
12
+ visibleCapabilities,
13
+ type ProfileRuntime,
14
+ } from '#server/mcp';
15
+
16
+ /**
17
+ * Which runtimes are current, and when the ones they replaced are closed.
18
+ *
19
+ * This is a different subject from what an HTTP request does, which is why it
20
+ * is not in `index.ts`. The endpoint used to hold one map of profile runtimes
21
+ * for the life of the process, and every cache derived from it — the handler
22
+ * memo, the visible-tool set, the capability-id list, the skill poll clock —
23
+ * hung off the request handler beside it. That was correct while the map could
24
+ * not change.
25
+ *
26
+ * It can now: `connect` writes a connection and tells the endpoint, and the
27
+ * endpoint re-reads its whole config (ADR-029). Once the map can be replaced,
28
+ * every one of those caches becomes a separate thing that must be invalidated
29
+ * at exactly the same moment, and the handler memo is the one that bites — it
30
+ * closes over the map *inside* `buildMcpServer`, so a stale entry serves the
31
+ * old config from a handler that looks fresh.
32
+ *
33
+ * So the caches move onto the generation they were derived from. Replacing the
34
+ * generation replaces all of them together, and nothing stale can outlive its
35
+ * generation by construction rather than by remembering to clear it.
36
+ *
37
+ * ## Why a generation is not closed when it is replaced
38
+ *
39
+ * `Runtime.close()` ends connector sessions and writes the audit log's end
40
+ * marker. A request that started against a generation is still using both, and
41
+ * requests do not stop at a reload — so a generation is *retired* when it is
42
+ * replaced and closed when its last in-flight request has drained. `fetch`
43
+ * pins one for its whole lifetime; that pin is the reference count.
44
+ */
45
+
46
+ /** A workspace opened and reconciled: what it serves, and how to let it go. */
47
+ export interface OpenedWorkspace {
48
+ readonly profiles: ReadonlyMap<string, ProfileRuntime>;
49
+ close(): Promise<void>;
50
+ }
51
+
52
+ /** Re-open and re-reconcile the whole workspace. Supplied by `endpoint.ts`. */
53
+ export type OpenWorkspace = () => Promise<OpenedWorkspace>;
54
+
55
+ export interface GenerationDeps {
56
+ /** Which profile's principal the visible-tool set is computed for. */
57
+ readonly primary: string;
58
+ readonly log: Logger;
59
+ readonly version?: string | undefined;
60
+ }
61
+
62
+ /**
63
+ * One boot's worth of runtimes, and everything derived from them.
64
+ *
65
+ * Immutable in what it serves. The memos inside still recompute on
66
+ * `registry.revision`, because skills can be replaced *within* a generation
67
+ * (ADR-014) — that is the one mutable surface the registry has, and it predates
68
+ * this.
69
+ */
70
+ export class Generation {
71
+ readonly epoch: number;
72
+ readonly profiles: ReadonlyMap<string, ProfileRuntime>;
73
+
74
+ readonly #opened: OpenedWorkspace;
75
+ readonly #deps: GenerationDeps;
76
+ readonly #handlers = new Map<string, McpHttpHandler>();
77
+
78
+ /** In-flight requests pinned to this generation. */
79
+ #pins = 0;
80
+ /** Replaced by a newer generation, so it closes when the last pin drops. */
81
+ #retired = false;
82
+ #closed = false;
83
+
84
+ /** How stale a registry may be before the next request re-reads its skills. */
85
+ static readonly SKILL_POLL_MS = 2_000;
86
+ #polledAt = 0;
87
+
88
+ /**
89
+ * Every capability id across every profile, granted or not.
90
+ *
91
+ * Used only to spell a refusal correctly: a tool that exists but is not
92
+ * permitted should appear in the audit under its real id.
93
+ */
94
+ readonly allCapabilityIds: () => readonly string[];
95
+
96
+ /**
97
+ * Wire names the endpoint advertises.
98
+ *
99
+ * M1 has a single principal per profile, so this set does not vary by caller.
100
+ * When delegated principals arrive it becomes a per-principal lookup; the call
101
+ * site already reads as one.
102
+ */
103
+ readonly visible: () => ReadonlySet<string>;
104
+
105
+ constructor(epoch: number, opened: OpenedWorkspace, deps: GenerationDeps) {
106
+ this.epoch = epoch;
107
+ this.profiles = opened.profiles;
108
+ this.#opened = opened;
109
+ this.#deps = deps;
110
+
111
+ this.allCapabilityIds = this.#memo(() => [
112
+ ...new Set(
113
+ [...this.profiles.values()].flatMap((runtime) =>
114
+ runtime.registry.capabilities().map(({ id }) => id),
115
+ ),
116
+ ),
117
+ ]);
118
+
119
+ this.visible = this.#memo(
120
+ () =>
121
+ new Set(
122
+ visibleCapabilities({
123
+ profiles: this.profiles,
124
+ principal: ownerPrincipal(deps.primary),
125
+ }).map(toolNameFor),
126
+ ),
127
+ );
128
+ }
129
+
130
+ /** The profile names this generation serves, in declaration order. */
131
+ names(): string[] {
132
+ return [...this.profiles.keys()];
133
+ }
134
+
135
+ pin(): void {
136
+ this.#pins += 1;
137
+ }
138
+
139
+ /** Drop a pin, closing the generation if it was retired and this was the last. */
140
+ async unpin(): Promise<void> {
141
+ this.#pins -= 1;
142
+ if (this.#retired && this.#pins <= 0) await this.close();
143
+ }
144
+
145
+ /** Mark superseded. Closes immediately when nothing is using it. */
146
+ async retire(): Promise<void> {
147
+ this.#retired = true;
148
+ if (this.#pins <= 0) await this.close();
149
+ }
150
+
151
+ async close(): Promise<void> {
152
+ if (this.#closed) return;
153
+ this.#closed = true;
154
+
155
+ await Promise.all([...this.#handlers.values()].map((handler) => handler.close()));
156
+ this.#handlers.clear();
157
+ await this.#opened.close();
158
+ }
159
+
160
+ /**
161
+ * Work derived from the registries, recomputed when one of them changes.
162
+ *
163
+ * Skills can be replaced in a registry (ADR-014), and a stale `visible()` is
164
+ * not cosmetic: it gates the refusal-audit path, so a newly added skill would
165
+ * be recorded as a refusal on its first `prompts/get` even though the call
166
+ * succeeded.
167
+ */
168
+ #generation(): number {
169
+ return [...this.profiles.values()].reduce(
170
+ (total, runtime) => total + runtime.registry.revision,
171
+ 0,
172
+ );
173
+ }
174
+
175
+ #memo<T>(compute: () => T): () => T {
176
+ let at = -1;
177
+ let value: T;
178
+ return () => {
179
+ const now = this.#generation();
180
+ if (now !== at) {
181
+ value = compute();
182
+ at = now;
183
+ }
184
+ return value;
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Re-read the skills, at most once per poll interval.
190
+ *
191
+ * A skill written elsewhere — `lanes link skills add` in another terminal —
192
+ * cannot announce itself, so the endpoint has to look. Bounded rather than
193
+ * per-request because looking costs a `list()`, which on S3 is a network call.
194
+ * A write made *through* MCP does not wait for this; it refreshes directly.
195
+ */
196
+ async refreshSkills(): Promise<void> {
197
+ const now = Date.now();
198
+ if (now - this.#polledAt < Generation.SKILL_POLL_MS) return;
199
+ this.#polledAt = now;
200
+
201
+ await Promise.all(
202
+ [...this.profiles.values()].map(async (runtime) => {
203
+ try {
204
+ await runtime.refreshSkills?.();
205
+ } catch (error) {
206
+ // A skills directory that has gone unreadable, or one skill file
207
+ // someone is mid-edit, must not take the endpoint down with it. The
208
+ // previously loaded skills stay registered.
209
+ this.#deps.log.warn('could not refresh skills', { message: (error as Error).message });
210
+ }
211
+ }),
212
+ );
213
+ }
214
+
215
+ /**
216
+ * One handler per (principal, client label), memoised within this generation.
217
+ *
218
+ * The MCP surface depends only on resolved policy, so rebuilding the wiring
219
+ * per request would be pure waste. Reuse is safe because `createMcpHandler`
220
+ * still constructs a fresh server instance per request — what is memoised is
221
+ * the factory wiring, never session state. Memoised *here* rather than on the
222
+ * request handler because the factory closes over this generation's profiles:
223
+ * a handler outliving its generation is the stale-config bug.
224
+ */
225
+ handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
226
+ const key = `${principal.id}\u0000${clientLabel ?? ''}`;
227
+ const existing = this.#handlers.get(key);
228
+ if (existing) return existing;
229
+
230
+ const handler = createMcpHandler(
231
+ // The principal is closed over rather than read back out of `authInfo`:
232
+ // this handler is already keyed on it, and re-deriving identity from a
233
+ // field the SDK treats as opaque pass-through would create a second
234
+ // source of truth for who is calling.
235
+ (_context: McpRequestContext) =>
236
+ buildMcpServer({
237
+ profiles: this.profiles,
238
+ principal,
239
+ clientLabel,
240
+ ...(this.#deps.version ? { version: this.#deps.version } : {}),
241
+ }),
242
+ {
243
+ onerror: (error: Error) =>
244
+ this.#deps.log.error('mcp handler error', { message: error.message }),
245
+ },
246
+ );
247
+
248
+ this.#handlers.set(key, handler);
249
+ return handler;
250
+ }
251
+ }
252
+
253
+ /** What a reload did, as the `/reload` route reports it. */
254
+ export interface ReloadResult {
255
+ readonly reloaded: boolean;
256
+ readonly epoch: number;
257
+ readonly profiles: readonly string[];
258
+ /** Why it did not reload. Absent on success. */
259
+ readonly reason?: string;
260
+ }
261
+
262
+ /**
263
+ * The current generation, and the reload that replaces it.
264
+ *
265
+ * One instance per endpoint. `acquire`/`release` bracket a request; `reload`
266
+ * swaps in a freshly opened workspace and retires the one it replaced.
267
+ */
268
+ export class Generations {
269
+ #current: Generation;
270
+ #epoch: number;
271
+ #inFlight: Promise<ReloadResult> | null = null;
272
+
273
+ readonly #open: OpenWorkspace;
274
+ readonly #deps: GenerationDeps;
275
+
276
+ constructor(initial: OpenedWorkspace, open: OpenWorkspace, deps: GenerationDeps) {
277
+ this.#epoch = 0;
278
+ this.#current = new Generation(0, initial, deps);
279
+ this.#open = open;
280
+ this.#deps = deps;
281
+ }
282
+
283
+ get current(): Generation {
284
+ return this.#current;
285
+ }
286
+
287
+ /** Pin the current generation for the life of one request. */
288
+ acquire(): Generation {
289
+ const generation = this.#current;
290
+ generation.pin();
291
+ return generation;
292
+ }
293
+
294
+ release(generation: Generation): Promise<void> {
295
+ return generation.unpin();
296
+ }
297
+
298
+ /**
299
+ * Re-open the workspace and serve what it now says.
300
+ *
301
+ * Serialised behind one in-flight promise: two concurrent `/reload` calls
302
+ * must not each open a full set of runtimes, and the second caller wants the
303
+ * same answer as the first rather than a second reload of the same edit.
304
+ *
305
+ * A failure is reported, never fatal. A config file caught mid-write, a
306
+ * bucket that briefly refuses a read, a profile someone is editing by hand —
307
+ * none of those are a reason to take down an endpoint that is serving. The
308
+ * previous generation stays current and the caller is told why.
309
+ */
310
+ reload(): Promise<ReloadResult> {
311
+ if (this.#inFlight) return this.#inFlight;
312
+
313
+ const attempt = this.#reload().finally(() => {
314
+ this.#inFlight = null;
315
+ });
316
+ this.#inFlight = attempt;
317
+ return attempt;
318
+ }
319
+
320
+ async #reload(): Promise<ReloadResult> {
321
+ const previous = this.#current;
322
+
323
+ let opened: OpenedWorkspace;
324
+ try {
325
+ opened = await this.#open();
326
+ } catch (error) {
327
+ const reason = error instanceof Error ? error.message : String(error);
328
+ this.#deps.log.warn('could not reload config', { message: reason });
329
+ return {
330
+ reloaded: false,
331
+ epoch: previous.epoch,
332
+ profiles: previous.names(),
333
+ reason,
334
+ };
335
+ }
336
+
337
+ this.#epoch += 1;
338
+ this.#current = new Generation(this.#epoch, opened, this.#deps);
339
+
340
+ // Module-global and keyed per connection, so it survives a reload that
341
+ // replaced everything else. Re-connecting `<provider>.<id>` to a different
342
+ // account would otherwise serve the previous account's access token until
343
+ // it expired — up to an hour after the config said otherwise. Unchanged
344
+ // connections pay one refresh.
345
+ clearUpstreamTokens();
346
+
347
+ // After the swap: a request arriving during the retire already gets the new
348
+ // generation, and this only waits on requests that started before it.
349
+ await previous.retire();
350
+
351
+ return {
352
+ reloaded: true,
353
+ epoch: this.#current.epoch,
354
+ profiles: this.#current.names(),
355
+ };
356
+ }
357
+
358
+ /** Close whatever is current. In-flight pins are not waited on. */
359
+ close(): Promise<void> {
360
+ return this.#current.close();
361
+ }
362
+ }