@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,121 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { ownerPrincipal } from '#auth';
4
+ import { announce, print, style } from '../../output.ts';
5
+ import { openRuntime, type GlobalFlags } from '../../runtime.ts';
6
+
7
+ /**
8
+ * `lanes link attach <file> --connection <provider>.<account>`
9
+ *
10
+ * Stage a file locally and print the handle, so a send can name it.
11
+ *
12
+ * The point is the hosted case. A `path` attachment names the filesystem the
13
+ * *endpoint* can see, which on a laptop is the operator's own and on Cloud Run is
14
+ * a container — so `path` stops meaning anything the moment the endpoint is not
15
+ * where the file is. A handle does not care where either end is.
16
+ *
17
+ * It writes to the store directly rather than posting to `/attachments`, which
18
+ * would need the endpoint running and a token in hand. The CLI already holds the
19
+ * profile, so going through the dispatcher is both shorter and the thing that
20
+ * gets it audited identically — one code path for the upload route and this.
21
+ */
22
+
23
+ export async function attachFile(
24
+ flags: GlobalFlags & { file?: string | undefined; connection?: string | undefined },
25
+ ): Promise<void> {
26
+ if (!flags.file) {
27
+ throw new Error('Which file? Usage: lanes link attach <file> --connection <provider>.<account>');
28
+ }
29
+ if (!flags.connection) {
30
+ throw new Error(
31
+ 'Which account is it for? Usage: lanes link attach <file> --connection <provider>.<account>. A staged file belongs to one account, not to the endpoint.',
32
+ );
33
+ }
34
+
35
+ const [providerId, ...rest] = flags.connection.split('.');
36
+ const connectionId = rest.join('.');
37
+ if (!providerId || !connectionId) {
38
+ throw new Error(`"${flags.connection}" is not a connection. Use <provider>.<account>.`);
39
+ }
40
+
41
+ const runtime = await openRuntime(flags);
42
+ try {
43
+ announce(runtime.resolution);
44
+
45
+ // Checked against config rather than trusted, so a typo names the mistake
46
+ // instead of staging into a namespace nothing will ever read.
47
+ const known = runtime.config.connections.some(
48
+ (connection) => connection.provider === providerId && connection.id === connectionId,
49
+ );
50
+ if (!known) {
51
+ const have = runtime.config.connections.map((c) => `${c.provider}.${c.id}`);
52
+ throw new Error(
53
+ `No connection "${flags.connection}" in this profile. Configured: ${have.join(', ') || 'none'}.`,
54
+ );
55
+ }
56
+
57
+ let bytes: Uint8Array;
58
+ try {
59
+ bytes = new Uint8Array(await readFile(flags.file));
60
+ } catch (failure) {
61
+ throw new Error(`Could not read ${flags.file}: ${(failure as Error).message}`);
62
+ }
63
+
64
+ if (bytes.byteLength === 0) {
65
+ throw new Error(`${flags.file} is empty, so there is nothing to stage.`);
66
+ }
67
+
68
+ const filename = basename(flags.file);
69
+ const staged = await runtime.dispatcher.stageAttachment({
70
+ // The operator, at their own terminal — the same principal every other
71
+ // control-plane command runs as.
72
+ principal: ownerPrincipal(runtime.config.instance.profile),
73
+ providerId,
74
+ connectionId,
75
+ bytes,
76
+ filename,
77
+ contentType: contentTypeFor(filename),
78
+ clientLabel: 'cli',
79
+ });
80
+
81
+ print('');
82
+ print(`${style.bold(staged.handle)} ${filename} ${bytes.byteLength} bytes`);
83
+ print(style.dim(`sha256 ${staged.sha256}`));
84
+ print(style.dim(`expires ${new Date(staged.expiresAt).toISOString()}`));
85
+ print('');
86
+ print('Attach it by handle:');
87
+ print(style.dim(` { "handle": "${staged.handle}" }`));
88
+ } finally {
89
+ await runtime.close();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Enough of a type to be useful, and octet-stream otherwise.
95
+ *
96
+ * Deliberately not a mime database. The resolver guesses from the filename too,
97
+ * and a wrong-but-generic type shows as a file rather than breaking anything.
98
+ */
99
+ const TYPES: Readonly<Record<string, string>> = {
100
+ pdf: 'application/pdf',
101
+ txt: 'text/plain',
102
+ md: 'text/markdown',
103
+ csv: 'text/csv',
104
+ html: 'text/html',
105
+ json: 'application/json',
106
+ png: 'image/png',
107
+ jpg: 'image/jpeg',
108
+ jpeg: 'image/jpeg',
109
+ gif: 'image/gif',
110
+ webp: 'image/webp',
111
+ heic: 'image/heic',
112
+ zip: 'application/zip',
113
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
114
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
115
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
116
+ };
117
+
118
+ function contentTypeFor(filename: string): string {
119
+ const extension = filename.split('.').pop()?.toLowerCase();
120
+ return (extension ? TYPES[extension] : undefined) ?? 'application/octet-stream';
121
+ }
@@ -0,0 +1,119 @@
1
+ import { announce, print, style, table } from '../../output.ts';
2
+ import { openRuntime, type GlobalFlags } from '../../runtime.ts';
3
+
4
+ /** `lanes link audit tail` — the log the dispatcher writes, rendered for a terminal. */
5
+
6
+ export async function auditTail(
7
+ flags: GlobalFlags & {
8
+ limit?: number | undefined;
9
+ deniedOnly?: boolean | undefined;
10
+ format?: string | undefined;
11
+ },
12
+ ): Promise<void> {
13
+ if (flags.format !== undefined && flags.format !== 'text' && flags.format !== 'md') {
14
+ throw new Error(`Unknown audit format "${flags.format}" — expected "text" or "md".`);
15
+ }
16
+ const markdown = flags.format === 'md';
17
+
18
+ const runtime = await openRuntime(flags);
19
+ try {
20
+ // The banner is chrome around a table; in Markdown it is a stray line in
21
+ // the middle of a document someone is pasting somewhere.
22
+ if (!markdown) announce(runtime.resolution);
23
+
24
+ const events = await runtime.audit.tail({
25
+ limit: flags.limit ?? 25,
26
+ ...(flags.deniedOnly ? { deniedOnly: true } : {}),
27
+ });
28
+
29
+ if (events.length === 0) {
30
+ print(markdown ? '_No audit events yet._' : style.dim('No audit events yet.'));
31
+ return;
32
+ }
33
+
34
+ if (markdown) {
35
+ // The log lives in the database; this is a rendering of it, not a second
36
+ // copy of it. See ADR-013 — storage format and display format are not the
37
+ // same decision.
38
+ print(`# Audit — ${runtime.resolution.profile}\n`);
39
+ print('| Time | Result | Capability | Connection | Duration | Arguments |');
40
+ print('|---|---|---|---|---|---|');
41
+ for (const event of events) {
42
+ print(
43
+ `| ${event.timestamp.toISOString()} ` +
44
+ `| ${event.authorization} ` +
45
+ `| \`${event.capability}\` ` +
46
+ `| ${event.connection ?? ''} ` +
47
+ `| ${event.durationMs}ms ` +
48
+ `| ${markdownCell(JSON.stringify(event.arguments))} |`,
49
+ );
50
+ }
51
+ return;
52
+ }
53
+
54
+ table(
55
+ events.map((event) => [
56
+ style.dim(event.timestamp.toISOString().slice(11, 19)),
57
+ event.authorization === 'allowed' ? style.green('allow') : style.red('deny '),
58
+ style.bold(event.capability),
59
+ event.connection ?? '',
60
+ style.dim(`${event.durationMs}ms`),
61
+ style.dim(JSON.stringify(event.arguments)),
62
+ ]),
63
+ );
64
+ } finally {
65
+ await runtime.close();
66
+ }
67
+ }
68
+
69
+ /**
70
+ * `lanes link audit verify` — has anything in the log been altered or removed?
71
+ *
72
+ * The question a log that lives in ordinary files has to be able to answer.
73
+ * Each record carries the hash of the one before it within its run, so this
74
+ * walks every chain and reports the first break in each: an edit shows as a
75
+ * hash mismatch, a removal from the middle as a sequence gap, and a run that
76
+ * was cut short after a clean shutdown as a count that disagrees with its
77
+ * marker.
78
+ *
79
+ * It exits non-zero on a break so a cron job can be built on it, and prints
80
+ * what it checked either way — "nothing to report" is only reassuring if you
81
+ * can see how much was looked at.
82
+ */
83
+ export async function auditVerify(flags: GlobalFlags): Promise<void> {
84
+ const runtime = await openRuntime(flags);
85
+ try {
86
+ announce(runtime.resolution);
87
+ const report = await runtime.audit.verify();
88
+
89
+ const scope = `${report.events} event${report.events === 1 ? '' : 's'} across ${report.runs} run${report.runs === 1 ? '' : 's'}`;
90
+
91
+ if (report.ok) {
92
+ print(`${style.green('intact')} — ${scope}, every chain verified.`);
93
+ return;
94
+ }
95
+
96
+ print(`${style.red('BROKEN')} — ${scope}.\n`);
97
+ for (const found of report.breaks) {
98
+ print(` ${style.bold(found.kind.padEnd(9))} run ${found.run} at seq ${found.seq}`);
99
+ print(` ${style.dim(found.detail)}\n`);
100
+ }
101
+
102
+ // A break means somebody edited the log, or something corrupted it. Either
103
+ // way this must not look like success to whatever ran it.
104
+ process.exitCode = 1;
105
+ } finally {
106
+ await runtime.close();
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A value safe to put in a Markdown table cell.
112
+ *
113
+ * Redacted arguments are still attacker-influenced — a provider that keeps a
114
+ * key verbatim is keeping whatever the caller sent — so a pipe or a newline in
115
+ * one would otherwise break the table apart or forge a row.
116
+ */
117
+ export function markdownCell(value: string): string {
118
+ return `\`${value.replaceAll('\\', '\\\\').replaceAll('|', '\\|').replaceAll(/[\r\n]+/g, ' ')}\``;
119
+ }
@@ -0,0 +1,355 @@
1
+ import { ownerPrincipal } from '#auth';
2
+ import type { DiscoveredCapability } from '#connectivity';
3
+ import { BROKERED } from '#connectivity/auth/index.ts';
4
+ import { allowedConnections } from '#policy';
5
+ import {
6
+ credentialRefFor,
7
+ formatPlan,
8
+ planIsNoop,
9
+ planReconcile,
10
+ toPolicyDocument,
11
+ } from '#registry';
12
+ import { announce, emit, fail, ok, print, warn } from '../../output.ts';
13
+ import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
14
+ import { openRuntime, resolveProfile, type GlobalFlags } from '../../runtime.ts';
15
+
16
+ /**
17
+ * The gate order — check, doctor, plan, start — exists so failures surface in
18
+ * the cheapest place first: static validation before any external call, and a
19
+ * preview of what reconcile would change before it changes anything.
20
+ */
21
+
22
+ /** Static validation only. No external calls, no database, no credentials. */
23
+ export async function check(flags: GlobalFlags): Promise<void> {
24
+ const { resolution, config } = await resolveProfile(flags);
25
+ announce(resolution);
26
+
27
+ // Reaching here means the loader accepted it: contract major, no credential
28
+ // values, referential integrity, and target resolution all passed.
29
+ print(ok(`${resolution.profilePath} is valid`));
30
+ print(
31
+ ` ${config.connections.length} connection(s), ` +
32
+ `${config.policy.allow.length} allow rule(s), ${config.policy.deny.length} deny rule(s)`,
33
+ );
34
+ }
35
+
36
+ /** What reconcile would change, without changing it. */
37
+ export async function plan(flags: GlobalFlags): Promise<void> {
38
+ const runtime = await openRuntime(flags);
39
+ try {
40
+ announce(runtime.resolution);
41
+ const result = await planReconcile(runtime.config, runtime.state, runtime.credentials, runtime.manifestFor);
42
+ print(formatPlan(result));
43
+ } finally {
44
+ await runtime.close();
45
+ }
46
+ }
47
+
48
+ export interface DoctorFlags extends GlobalFlags {
49
+ readonly json?: boolean | undefined;
50
+ }
51
+
52
+ /**
53
+ * One thing doctor found: what is wrong, and the command that fixes it.
54
+ *
55
+ * `fix` carries `--profile` while the printed line does not. A person reading
56
+ * the terminal just resolved a profile to get here and their next command
57
+ * resolves the same one; something parsing this JSON has no shell context at
58
+ * all, so leaving it off would hand an agent a command that acts on whichever
59
+ * profile happens to be the default.
60
+ */
61
+ export interface DoctorFinding {
62
+ readonly kind: string;
63
+ readonly message: string;
64
+ readonly key?: string;
65
+ readonly fix?: string;
66
+ }
67
+
68
+ /** Read-only external checks: credentials resolve, stores reachable. */
69
+ export async function doctor(flags: DoctorFlags): Promise<void> {
70
+ const runtime = await openRuntime(flags);
71
+
72
+ const checks: string[] = [];
73
+ const warnings: DoctorFinding[] = [];
74
+ const problems: DoctorFinding[] = [];
75
+
76
+ try {
77
+ const forProfile = (command: string) =>
78
+ `${command} --profile ${runtime.resolution.profile}`;
79
+
80
+ checks.push('config is valid');
81
+ checks.push('state store is reachable');
82
+
83
+ const token = await runtime.credentials.get(runtime.config.auth.token_ref);
84
+ if (token) {
85
+ checks.push(`profile token present (${runtime.config.auth.token_ref})`);
86
+ } else {
87
+ // Not a failure: `lanes link start` mints one. Reporting it as a problem would
88
+ // make every fresh profile fail doctor for something that fixes itself.
89
+ warnings.push({
90
+ kind: 'no_profile_token',
91
+ message: 'no profile token yet — lanes link start will mint one, or run: lanes link token rotate',
92
+ fix: forProfile('lanes link token rotate'),
93
+ });
94
+ }
95
+
96
+ for (const connection of runtime.config.connections) {
97
+ const key = `${connection.provider}.${connection.id}`;
98
+ const ref = credentialRefFor(connection, runtime.manifestFor(connection.provider));
99
+ if (!ref) {
100
+ checks.push(`${key} needs no credential`);
101
+ continue;
102
+ }
103
+ if (await runtime.credentials.has(ref)) {
104
+ const staleness = await credentialAge(runtime.credentials, ref);
105
+
106
+ // A Google project left in "Testing" expires refresh tokens after seven
107
+ // days. That is a policy setting rather than a fault, but it presents
108
+ // as an authentication failure mid-task — so say it before the call
109
+ // fails rather than after.
110
+ //
111
+ // Only for a client the operator registered. The hosted one is in
112
+ // production and does not expire refresh tokens weekly, so this warning
113
+ // would simply be false there — and a warning that is wrong once is a
114
+ // warning that gets scrolled past every time after.
115
+ if (staleness !== null && !staleness.brokered && staleness.days >= 7) {
116
+ warnings.push({
117
+ kind: 'stale_credential',
118
+ key,
119
+ message:
120
+ `${key} credential is ${staleness.days} days old — a Google app in "Testing" expires at 7. ` +
121
+ `Run: lanes link connect ${key}`,
122
+ fix: forProfile(`lanes link connect ${key}`),
123
+ });
124
+ } else {
125
+ const age = staleness
126
+ ? ` (${staleness.days}d old${staleness.brokered ? ', hosted client' : ''})`
127
+ : '';
128
+ checks.push(`${key} credential resolves${age}`);
129
+ }
130
+ } else {
131
+ problems.push({
132
+ kind: 'missing_credential',
133
+ key,
134
+ message: `${key} has no stored credential — run: lanes link connect ${key}`,
135
+ fix: forProfile(`lanes link connect ${key}`),
136
+ });
137
+ }
138
+ }
139
+
140
+ for (const name of new Set(runtime.config.connections.map((c) => c.provider))) {
141
+ if (!runtime.registry.has(name)) {
142
+ problems.push({
143
+ kind: 'unknown_provider',
144
+ key: name,
145
+ message: `connection names provider "${name}", which is neither built in nor a manifest in providers/`,
146
+ });
147
+ }
148
+ }
149
+
150
+ // A profile written before the setup surface existed has no connection for
151
+ // it, and `allowedConnections` returns nothing for a provider with no
152
+ // connection *before* consulting policy — so the capabilities are simply
153
+ // absent, with nothing saying why. An agent then has no way to see what is
154
+ // configured and starts guessing at commands.
155
+ //
156
+ // Both halves, because either alone is inert: a connection row that no rule
157
+ // grants serves nothing, and a rule naming a provider with no row is what
158
+ // `allowedConnections` drops before policy is consulted. Reporting only the
159
+ // row left the half-repaired profile reading as healthy while serving
160
+ // exactly as little as the untouched one — and both halves are what
161
+ // `ensureSetupConnection` writes, so this is the check that says whether it
162
+ // has run.
163
+ const hasSetupRow = runtime.config.connections.some(
164
+ (connection) => connection.provider === 'setup',
165
+ );
166
+ const grantsSetup = runtime.config.policy.allow.some(
167
+ (rule) => rule.capability === '*' || rule.capability === 'setup.*',
168
+ );
169
+
170
+ if (!hasSetupRow || !grantsSetup) {
171
+ warnings.push({
172
+ kind: 'no_setup_connection',
173
+ message:
174
+ `this profile ${hasSetupRow ? 'does not grant "setup.*"' : 'has no "setup" connection'}` +
175
+ ', so an agent cannot see what is configured — run: lanes link connect setup',
176
+ fix: forProfile('lanes link connect setup'),
177
+ });
178
+ }
179
+
180
+ // Not a failure — nothing here needs the binary on PATH. But it is what
181
+ // turns the registration command into a 401 that looks like a bad token,
182
+ // so it is worth saying before someone is halfway through wiring an agent.
183
+ if (!Bun.which('lanes')) {
184
+ warnings.push({
185
+ kind: 'not_on_path',
186
+ message:
187
+ 'lanes is not on your PATH — "lanes link outputs" will print a longer command. ' +
188
+ 'Fix with: bun link (from the checkout)',
189
+ });
190
+ }
191
+
192
+ await reportCapabilityDrift(runtime, (message) =>
193
+ warnings.push({ kind: 'capability_drift', message }),
194
+ );
195
+
196
+ const drift = await planReconcile(runtime.config, runtime.state, runtime.credentials, runtime.manifestFor);
197
+ if (!planIsNoop(drift)) {
198
+ warnings.push({
199
+ kind: 'reconcile_drift',
200
+ message: 'runtime state differs from config — run: lanes link plan',
201
+ fix: forProfile('lanes link plan'),
202
+ });
203
+ }
204
+
205
+ if (problems.length > 0) process.exitCode = 1;
206
+
207
+ return emit(flags.json, { ok: problems.length === 0, checks, warnings, problems }, () => {
208
+ announce(runtime.resolution);
209
+
210
+ for (const line of checks) print(ok(line));
211
+ for (const finding of warnings) print(warn(finding.message));
212
+ for (const finding of problems) print(fail(finding.message));
213
+
214
+ if (problems.length > 0) {
215
+ print();
216
+ print(fail(`${problems.length} problem(s) found`));
217
+ }
218
+ });
219
+ } finally {
220
+ await runtime.close();
221
+ }
222
+ }
223
+
224
+ /**
225
+ * How old a stored OAuth credential is.
226
+ *
227
+ * Derived from the `expires_at` the OAuth provider stamps when saving tokens.
228
+ * Returns null for anything that is not a token blob — an app password has no
229
+ * meaningful age, and guessing one would produce a confusing warning.
230
+ */
231
+ async function credentialAge(
232
+ credentials: { get(ref: string): Promise<string | null> },
233
+ ref: string,
234
+ ): Promise<{ days: number; brokered: boolean } | null> {
235
+ const raw = await credentials.get(ref);
236
+ if (!raw) return null;
237
+
238
+ try {
239
+ const parsed = JSON.parse(raw) as {
240
+ expires_at?: number;
241
+ expires_in?: number;
242
+ authorized_via?: string;
243
+ };
244
+ if (typeof parsed.expires_at !== 'number') return null;
245
+
246
+ const issued = parsed.expires_at - (parsed.expires_in ?? 3600) * 1000;
247
+ return {
248
+ days: Math.floor((Date.now() - issued) / 86_400_000),
249
+ brokered: parsed.authorized_via === BROKERED,
250
+ };
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Capabilities the upstream has grown since you connected.
258
+ *
259
+ * This is what replaced pinning an allow line per tool. `connect` writes
260
+ * `provider.*`, which is readable and which a vendor can quietly widen by
261
+ * shipping a new tool — so the widening has to be *visible* somewhere, and
262
+ * doctor is where you look when you want to know what changed.
263
+ *
264
+ * Compared against the discovery cache rather than against config, because the
265
+ * cache is what the running server actually serves. Failures are reported and
266
+ * skipped: an upstream being down is not a reason for doctor to fail, and it is
267
+ * already obvious from every other check.
268
+ *
269
+ * This used to open `if (kind !== 'mcp') continue`, which meant it watched the
270
+ * one connector kind whose capabilities this repository does not ship and
271
+ * ignored the two it does. Drive gained three operations in a commit and the
272
+ * endpoint served six for as long as the operator did not re-authorise; the
273
+ * check written to make exactly that visible never ran. `http` is no longer
274
+ * listed here because it no longer can drift — `runtime/discovery.ts` re-derives
275
+ * it at startup — so what is left is the kinds that genuinely still cache.
276
+ */
277
+ async function reportCapabilityDrift(
278
+ runtime: Awaited<ReturnType<typeof openRuntime>>,
279
+ say: (message: string) => void,
280
+ ): Promise<void> {
281
+ const policy = toPolicyDocument(runtime.config);
282
+ const principal = ownerPrincipal(runtime.config.instance.profile).id;
283
+
284
+ for (const entry of runtime.registry.list()) {
285
+ const connection = runtime.config.connections.find((c) => c.provider === entry.manifest.id);
286
+ if (!connection) continue;
287
+
288
+ const connector = runtime.connectorFor(entry.manifest.id, connection.id);
289
+ const probe = discoveryProbe(entry.manifest, connector);
290
+ // Re-derived every startup, so what is served is what is shipped. Probing
291
+ // it here would only ever compare a value against itself.
292
+ if (!probe || probe.cost === 'offline') continue;
293
+
294
+ const cached = runtime.registry.discovered(entry.manifest.id) ?? [];
295
+
296
+ let live: readonly DiscoveredCapability[];
297
+ try {
298
+ live = await probe.run();
299
+ } catch {
300
+ continue;
301
+ }
302
+
303
+ // A provider that was never discovered serves nothing at all, which reads
304
+ // from the outside like the provider being broken. Skipping it silently is
305
+ // how that stays a mystery.
306
+ if (cached.length === 0) {
307
+ say(
308
+ `${entry.manifest.name} has never been discovered, so none of its ${live.length} ` +
309
+ `capability(ies) are served — run: lanes link connect ${entry.manifest.id}`,
310
+ );
311
+ continue;
312
+ }
313
+
314
+ const diff = capabilityDiff(cached, live);
315
+
316
+ if (diff.added.length > 0) {
317
+ // Through the same `allowedConnections` the dispatcher enforces with,
318
+ // rather than reading `policy.allow` directly. The predicate this replaced
319
+ // ignored its own `name` argument, so the answer was all-or-nothing, and
320
+ // it never consulted `deny` — telling an operator their policy covered a
321
+ // capability they had explicitly withheld.
322
+ const reachable = diff.added.filter(
323
+ (name) =>
324
+ allowedConnections(
325
+ `${entry.manifest.id}.${name}`,
326
+ [`${entry.manifest.id}.${connection.id}`],
327
+ principal,
328
+ policy,
329
+ ).length > 0,
330
+ );
331
+
332
+ say(
333
+ `${entry.manifest.name} has ${diff.added.length} new capability(ies) since you connected: ` +
334
+ `${diff.added.slice(0, 5).join(', ')}${diff.added.length > 5 ? ', …' : ''}` +
335
+ (reachable.length > 0
336
+ ? `\n Your policy covers ${reachable.length} of them — lanes link policy deny ${entry.manifest.id}.<name> to withhold one.`
337
+ : ''),
338
+ );
339
+ }
340
+
341
+ if (diff.removed.length > 0) {
342
+ say(`${entry.manifest.name} no longer offers: ${diff.removed.slice(0, 5).join(', ')}`);
343
+ }
344
+
345
+ // The case ADR-017 said `plan` structurally could not report: a schema or a
346
+ // description moved, the name did not, and the endpoint is serving the old
347
+ // shape until it restarts.
348
+ if (diff.changed.length > 0) {
349
+ say(
350
+ `${entry.manifest.name} changed the shape of: ${diff.changed.slice(0, 5).join(', ')}` +
351
+ `\n Run: lanes link connect ${entry.manifest.id}, then restart the endpoint.`,
352
+ );
353
+ }
354
+ }
355
+ }