@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,365 @@
1
+ import { z } from 'zod';
2
+ import type { ProviderContext, ToolCapability, ToolResult } from '#connectivity';
3
+ import {
4
+ attachmentRefSchema,
5
+ composeMime,
6
+ receiptFor,
7
+ resolveAttachments,
8
+ type ResolvedAttachment,
9
+ } from '#connectivity/mail';
10
+ import {
11
+ GMAIL_API,
12
+ GMAIL_JSON_LIMIT_BYTES,
13
+ GMAIL_MAX_MESSAGE_BYTES,
14
+ GMAIL_UPLOAD,
15
+ } from './api.ts';
16
+ import { gmailAttachments } from './attachment.ts';
17
+
18
+ /**
19
+ * Sending mail, composed here rather than by the caller.
20
+ *
21
+ * The one capability on this provider that is code instead of data, and the
22
+ * reason is narrow: Gmail's API takes a whole assembled RFC 2822 message as one
23
+ * base64url field. Nothing in an OpenAPI document describes composing one, so the
24
+ * generated tools hand that job to the caller — which for a plain message is
25
+ * merely absurd, and for a message with an attachment is impossible. A 239 KB PDF
26
+ * is about 320,000 characters of base64, past what a model can write in one
27
+ * message. That is a real failure someone hit, not a hypothetical.
28
+ *
29
+ * Note there was no send tool here at all before this. The omission was
30
+ * deliberate — "the token can send, the tool surface cannot" — and reversing it
31
+ * is the point of the change rather than an accident of it. `gmail.compose`
32
+ * already permits it, `draft_only` keeps the review step available, and
33
+ * `lanes link policy deny gmail.send_message` still takes it away.
34
+ *
35
+ * `draft_id` extends the same argument to revising one. The generated
36
+ * `drafts.update` had the identical defect its sibling `drafts.create` was
37
+ * removed for — a `Draft` body whose `message.raw` the caller assembles — so
38
+ * correcting a draft that carried a file meant deleting it and composing the
39
+ * whole thing again, re-fetching every attachment on the way. Here the message
40
+ * is recomposed as it always was and the only difference is the address it goes
41
+ * to: a `PUT` at the draft rather than a `POST` at the collection.
42
+ *
43
+ * Sending after a revision is two requests rather than one. Gmail's
44
+ * `drafts.send` does accept a whole `Draft`, which would fold the update into
45
+ * the send, but the merge semantics are not something this code can verify from
46
+ * here — and the failure mode if they are not what one assumed is that the
47
+ * *previous* draft goes out, silently, to real recipients. Updating and then
48
+ * sending by id costs a round trip on a path already measured in seconds, and
49
+ * both halves are documented single-purpose calls.
50
+ */
51
+
52
+ const schema = z.object({
53
+ to: z.array(z.string()).min(1).describe('Recipients.'),
54
+ cc: z.array(z.string()).optional(),
55
+ bcc: z.array(z.string()).optional(),
56
+ subject: z.string(),
57
+ text: z.string().optional().describe('Plain-text body.'),
58
+ html: z.string().optional().describe('HTML body. Send text as well where you can.'),
59
+ in_reply_to: z
60
+ .string()
61
+ .optional()
62
+ .describe('Message-ID being replied to, which threads the reply.'),
63
+ from_name: z
64
+ .string()
65
+ .optional()
66
+ .describe(
67
+ 'Name to show beside the address. Rarely needed here — omitted, Gmail fills in the account\'s own name — so pass it only to send under a different one.',
68
+ ),
69
+ attachments: z
70
+ .array(attachmentRefSchema)
71
+ .optional()
72
+ .describe(
73
+ 'Files to attach, each named by reference. This endpoint reads the bytes itself — never encode a file into this call.',
74
+ ),
75
+ // The warning in the description is there because the failure happened. A
76
+ // draft carrying a 204 KB PDF took 19 seconds — the bytes were pulled back
77
+ // out of the mailbox and re-uploaded — and the client, having no result it
78
+ // trusted, listed the drafts and then sent the identical call twice more.
79
+ // Three drafts, all of them ours to explain. Nothing here retries, so the
80
+ // repair is telling the caller what a slow call means and giving it
81
+ // `drafts.list` and `drafts.delete` to act on, rather than inventing an
82
+ // idempotency key this endpoint has nowhere to keep.
83
+ draft_only: z
84
+ .boolean()
85
+ .optional()
86
+ .describe(
87
+ 'Save as a draft instead of sending, so it can be reviewed first. This call is not idempotent and a message with an attachment can take tens of seconds, because the bytes are fetched and uploaded here: if it appears to time out, the draft may already exist. List drafts to check before calling again, and delete the surplus one rather than leaving both.',
88
+ ),
89
+ draft_id: z
90
+ .string()
91
+ .optional()
92
+ .describe(
93
+ 'Revise this existing draft instead of writing a new one — pass the id a previous call returned. Every other argument describes the message as it should now read, because the draft is replaced rather than merged into: name the attachments again to keep them. With draft_only the revision stays a draft; without it, the revised draft is sent.',
94
+ ),
95
+ });
96
+
97
+ /**
98
+ * Built rather than declared, so `fetch` can be substituted in a test.
99
+ *
100
+ * The same seam every transport here offers. Without it a test has to reach for
101
+ * the global, and a test that patches `globalThis` leaks into whatever runs
102
+ * beside it.
103
+ */
104
+ export function gmailSend(
105
+ options: { readonly fetch?: typeof globalThis.fetch } = {},
106
+ ): ToolCapability<typeof schema> {
107
+ const doFetch = options.fetch ?? globalThis.fetch;
108
+
109
+ return {
110
+ kind: 'tool',
111
+ name: 'send_message',
112
+ description:
113
+ 'Send a message, with attachments, or save it as a draft. Attachments are named by reference — a path on this machine, an HTTPS URL, a staged handle, or another message in this mailbox — and this endpoint fetches the bytes itself, so never encode a file into the call.',
114
+ inputSchema: schema,
115
+
116
+ async handler(input, context): Promise<ToolResult> {
117
+ const authorize = context.authorize;
118
+ if (!authorize) {
119
+ // Only reachable if this capability is ever registered on a provider with
120
+ // no credential, which the manifest makes impossible. Stated rather than
121
+ // asserted non-null, so the failure names its cause.
122
+ return fail('This connection has no credential, so it cannot send.');
123
+ }
124
+
125
+ let attachments: ResolvedAttachment[];
126
+ try {
127
+ attachments = await resolveAttachments(input.attachments, {
128
+ // Three quarters of the ceiling: attachments travel base64, so this is
129
+ // the raw weight that fits once encoded. The composed message is measured
130
+ // exactly below.
131
+ maxTotalBytes: Math.floor((GMAIL_MAX_MESSAGE_BYTES * 3) / 4),
132
+ signal: context.signal,
133
+ mailbox: gmailAttachments({ authorize, fetch: doFetch, signal: context.signal }),
134
+ storage: context.storage,
135
+ });
136
+ } catch (failure) {
137
+ return fail((failure as Error).message);
138
+ }
139
+
140
+ // Normally no `From` at all: Gmail fills it from the credential, name
141
+ // included, which is better than anything guessable here — and inventing an
142
+ // address risks writing one this account may not send as. A name is written
143
+ // only when someone asked for one, and then the address has to come with it,
144
+ // since a display name alone is not a header.
145
+ const configured = context.connection.config['from_name'];
146
+ const fromName =
147
+ input.from_name ??
148
+ (typeof configured === 'string' && configured.trim() !== '' ? configured : undefined);
149
+ const address = context.connection.displayName;
150
+
151
+ const composed = await composeMime({
152
+ ...(fromName && address.includes('@') ? { from: address, fromName } : {}),
153
+ message: {
154
+ to: input.to,
155
+ cc: input.cc,
156
+ bcc: input.bcc,
157
+ subject: input.subject,
158
+ text: input.text,
159
+ html: input.html,
160
+ inReplyTo: input.in_reply_to,
161
+ ...(attachments.length > 0 ? { attachments } : {}),
162
+ },
163
+ });
164
+
165
+ if (composed.raw.byteLength > GMAIL_MAX_MESSAGE_BYTES) {
166
+ return fail(
167
+ `The composed message is ${composed.raw.byteLength} bytes and Gmail accepts ${GMAIL_MAX_MESSAGE_BYTES}. Attachments are base64 in transit, so they weigh about a third more than on disk.`,
168
+ );
169
+ }
170
+
171
+ // Recorded before submitting, so a file read off disk is in the log even if
172
+ // the send then fails. `redact` cannot express this — it keeps an argument
173
+ // verbatim, and `attachments` may itself hold a base64 file.
174
+ if (attachments.length > 0) {
175
+ context.audit.annotate({
176
+ attachments: attachments.map((attachment) => ({
177
+ filename: attachment.filename,
178
+ bytes: attachment.bytes.byteLength,
179
+ content_type: attachment.contentType,
180
+ sha256: attachment.sha256,
181
+ origin: attachment.origin,
182
+ })),
183
+ });
184
+ }
185
+
186
+ const drafting = input.draft_only === true;
187
+
188
+ try {
189
+ let sent = await submit({
190
+ raw: composed.raw,
191
+ draftOnly: drafting,
192
+ draftId: input.draft_id,
193
+ authorize,
194
+ fetch: doFetch,
195
+ context,
196
+ });
197
+
198
+ // A revision that is not staying a draft is sent by id, now that the
199
+ // draft holds the new message. Its own failure is reported separately
200
+ // from the update's, because the two leave the mailbox in different
201
+ // states and only one of them is worth retrying as composed.
202
+ if (input.draft_id !== undefined && !drafting) {
203
+ try {
204
+ sent = await sendDraft({
205
+ draftId: input.draft_id,
206
+ authorize,
207
+ fetch: doFetch,
208
+ context,
209
+ });
210
+ } catch (failure) {
211
+ return fail(
212
+ `The draft was revised but not sent: ${(failure as Error).message} The new message is saved as draft ${input.draft_id}, so send that rather than composing it again.`,
213
+ );
214
+ }
215
+ }
216
+
217
+ return {
218
+ content: [
219
+ {
220
+ type: 'text',
221
+ text: JSON.stringify(
222
+ {
223
+ ...(drafting
224
+ ? { drafted: true, draft_id: sent['id'] ?? input.draft_id ?? null }
225
+ : { sent: true }),
226
+ message_id: messageIdOf(sent) ?? composed.messageId,
227
+ recipients: input.to.length + (input.cc?.length ?? 0) + (input.bcc?.length ?? 0),
228
+ // Names, sizes and digests, never content — see `receiptFor`.
229
+ ...(attachments.length > 0
230
+ ? { attachments: attachments.map(receiptFor) }
231
+ : {}),
232
+ },
233
+ null,
234
+ 2,
235
+ ),
236
+ },
237
+ ],
238
+ };
239
+ } catch (failure) {
240
+ return fail((failure as Error).message);
241
+ }
242
+ },
243
+ };
244
+ }
245
+
246
+ /** The wired capability, as the provider declares it. */
247
+ export const gmailSendMessage = gmailSend();
248
+
249
+ /**
250
+ * Hand the assembled message to Gmail.
251
+ *
252
+ * Two routes, chosen by size. A small message goes as base64url in a JSON field,
253
+ * which is the ordinary endpoint and the overwhelmingly common case. A large one
254
+ * goes to the upload host as `message/rfc822` with the raw MIME as the body —
255
+ * `uploadType=media`, no multipart assembly, no resumable session — which is what
256
+ * lifts the ceiling from "what fits in a JSON string" to Gmail's own 35 MiB.
257
+ */
258
+ async function submit(input: {
259
+ readonly raw: Uint8Array;
260
+ readonly draftOnly: boolean;
261
+ readonly draftId: string | undefined;
262
+ readonly authorize: (request: Request) => Promise<Request>;
263
+ readonly fetch: typeof globalThis.fetch;
264
+ readonly context: ProviderContext;
265
+ }): Promise<Record<string, unknown>> {
266
+ const { raw, draftOnly, draftId, authorize, context } = input;
267
+ const large = raw.byteLength > GMAIL_JSON_LIMIT_BYTES;
268
+
269
+ // Revising is a `PUT` at the draft's own address; the other two are a `POST`
270
+ // at a collection. The upload host takes the same path either way, so size
271
+ // still chooses the host and nothing else.
272
+ const revising = draftId !== undefined;
273
+ const path = revising
274
+ ? `/drafts/${encodeURIComponent(draftId)}`
275
+ : draftOnly
276
+ ? '/drafts'
277
+ : '/messages/send';
278
+ const url = large ? `${GMAIL_UPLOAD}${path}?uploadType=media` : `${GMAIL_API}${path}`;
279
+
280
+ const encoded = large ? undefined : Buffer.from(raw).toString('base64url');
281
+ // A draft nests the message, whether it is being created or replaced; only a
282
+ // straight send carries `raw` at the top level.
283
+ const body: Uint8Array | string = large
284
+ ? raw
285
+ : JSON.stringify(draftOnly || revising ? { message: { raw: encoded } } : { raw: encoded });
286
+
287
+ const request = await authorize(
288
+ new Request(url, {
289
+ method: revising ? 'PUT' : 'POST',
290
+ headers: {
291
+ 'content-type': large ? 'message/rfc822' : 'application/json',
292
+ accept: 'application/json',
293
+ },
294
+ body,
295
+ ...(context.signal ? { signal: context.signal } : {}),
296
+ }),
297
+ );
298
+
299
+ const response = await input.fetch(request);
300
+ const text = await response.text();
301
+
302
+ if (!response.ok) {
303
+ throw new Error(`Gmail refused the message with ${response.status}: ${text}`);
304
+ }
305
+
306
+ try {
307
+ return JSON.parse(text) as Record<string, unknown>;
308
+ } catch {
309
+ return {};
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Send a draft that already exists, by id.
315
+ *
316
+ * Always small — the body is one identifier — so there is no upload-host branch
317
+ * here even when the draft it names carries 30 MiB. The bytes were already
318
+ * handed over by the `PUT` that preceded this.
319
+ */
320
+ async function sendDraft(input: {
321
+ readonly draftId: string;
322
+ readonly authorize: (request: Request) => Promise<Request>;
323
+ readonly fetch: typeof globalThis.fetch;
324
+ readonly context: ProviderContext;
325
+ }): Promise<Record<string, unknown>> {
326
+ const request = await input.authorize(
327
+ new Request(`${GMAIL_API}/drafts/send`, {
328
+ method: 'POST',
329
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
330
+ body: JSON.stringify({ id: input.draftId }),
331
+ ...(input.context.signal ? { signal: input.context.signal } : {}),
332
+ }),
333
+ );
334
+
335
+ const response = await input.fetch(request);
336
+ const text = await response.text();
337
+
338
+ if (!response.ok) {
339
+ throw new Error(`Gmail refused to send the draft with ${response.status}: ${text}`);
340
+ }
341
+
342
+ try {
343
+ return JSON.parse(text) as Record<string, unknown>;
344
+ } catch {
345
+ return {};
346
+ }
347
+ }
348
+
349
+ /** Gmail answers with its own id; a draft nests the message inside. */
350
+ function messageIdOf(body: Record<string, unknown>): string | null {
351
+ const id = body['id'];
352
+ if (typeof id === 'string' && !('message' in body)) return id;
353
+
354
+ const message = body['message'];
355
+ if (message !== null && typeof message === 'object') {
356
+ const nested = (message as { id?: unknown }).id;
357
+ if (typeof nested === 'string') return nested;
358
+ }
359
+
360
+ return typeof id === 'string' ? id : null;
361
+ }
362
+
363
+ function fail(message: string): ToolResult {
364
+ return { content: [{ type: 'text', text: message }], isError: true };
365
+ }
@@ -0,0 +1,35 @@
1
+ import { defineProvider } from '#connectivity';
2
+ import { GMAIL_IDENTITY, GOOGLE_APP } from '../shared/oauth.ts';
3
+ import { googleSetup } from '../shared/setup.ts';
4
+
5
+ /**
6
+ * Gmail via Google's MCP server. Workspace Developer Preview members only.
7
+ *
8
+ * Deliberately not `GMAIL_SCOPES`. The server *advertises* five, including
9
+ * `mail.google.com` — read, send, and permanently delete. Requesting the full
10
+ * advertised set was tried, on the theory that the servers reject a subset, and
11
+ * it made no difference: with all five granted, `tools/list` succeeds and every
12
+ * `tools/call` still answers "The caller does not have permission". The gate is
13
+ * enrolment in Google's Workspace Developer Preview, not scope.
14
+ *
15
+ * That is the opposite of the REST provider's situation, where scope is exactly
16
+ * the gate — hence two lists. Widening this one buys nothing that has been
17
+ * demonstrated, so it stays at what Google documents. If calls still fail once
18
+ * preview access is granted, widening is the next thing to try — `connect` warns
19
+ * about the gap either way.
20
+ */
21
+ const GMAIL_MCP_SCOPES = [
22
+ 'https://www.googleapis.com/auth/gmail.readonly',
23
+ 'https://www.googleapis.com/auth/gmail.compose',
24
+ ];
25
+
26
+ export const gmailMcp = defineProvider({
27
+ id: 'gmail_mcp',
28
+ name: 'Gmail (Google MCP)',
29
+ description:
30
+ 'Read and compose mail via Google\'s official Gmail MCP server. Requires Workspace Developer Preview enrolment — use "gmail" otherwise.',
31
+ connector: { kind: 'mcp', endpoint: 'https://gmailmcp.googleapis.com/mcp/v1' },
32
+ auth: { kind: 'oauth', registration: 'manual', app: GOOGLE_APP, scopes: GMAIL_MCP_SCOPES },
33
+ identity: GMAIL_IDENTITY,
34
+ setup: googleSetup('Gmail', GMAIL_MCP_SCOPES, { preview: true }),
35
+ });
@@ -0,0 +1,10 @@
1
+ /** Google's nine providers. Each folder holds all of its own vendor knowledge. */
2
+ export { calendar } from './calendar/index.ts';
3
+ export { contacts } from './contacts/index.ts';
4
+ export { docs } from './docs/index.ts';
5
+ export { drive, DRIVE_SCOPES } from './drive/index.ts';
6
+ export { driveMcp } from './drive-mcp/index.ts';
7
+ export { gmail, GMAIL_SCOPES } from './gmail/index.ts';
8
+ export { gmailMcp } from './gmail-mcp/index.ts';
9
+ export { sheets } from './sheets/index.ts';
10
+ export { tasks } from './tasks/index.ts';
@@ -0,0 +1,122 @@
1
+ import type { AuthBroker } from '#connectivity';
2
+ import { brokerOriginOverride } from '#connectivity/auth/index.ts';
3
+
4
+ /**
5
+ * Google's OAuth endpoints, shared by every Google provider.
6
+ *
7
+ * A REST API announces nothing about where its authorization server lives, so
8
+ * an `http` connector has to be told. `access_type=offline` and
9
+ * `prompt=consent` are what make Google return a refresh token at all — without
10
+ * them the connection works for an hour and then dies.
11
+ */
12
+ export const GOOGLE_APP = 'google';
13
+
14
+ /**
15
+ * The Google OAuth client Lanes operates, and where its secret stays.
16
+ *
17
+ * Its own Cloud project, separate from the one that signs people into Lanes:
18
+ * this client asks for Gmail and Drive and carries a verification review that
19
+ * can be rejected, and a rejection must not be able to take sign-in with it.
20
+ *
21
+ * The secret is not confidential in Google's sense — an installed app cannot
22
+ * hold one — and is held back anyway. Shipping it would hand anyone the shared
23
+ * quota and standing of a single client that every Lanes Link user depends on,
24
+ * and there is no way to withdraw one copy of a secret.
25
+ *
26
+ * What this buys the operator is the whole of `setup/google.md`: no project, no
27
+ * console, no scope list to transcribe, and no seven-day refresh-token expiry,
28
+ * because that expiry is a property of a project left in "Testing" and this one
29
+ * is not. What it costs is recorded in ADR-028 and in the guarantee table in
30
+ * `docs/detailed/security.md` — chiefly that the exchange stops being local.
31
+ */
32
+ const BROKER_ORIGIN = 'https://api.lanes.sh';
33
+ const BROKER_PATH = '/v1/auth/link/google';
34
+
35
+ /**
36
+ * The same client, reached at a different origin.
37
+ *
38
+ * Two callers need it: the CLI performing the first exchange and the dispatcher
39
+ * refreshing while it serves. Both read `manifest.auth.broker.url`, so building
40
+ * it once here is what keeps them from disagreeing about where the broker is —
41
+ * and what makes `LANES_LINK_BROKER_ORIGIN` reach both at once. The path stays
42
+ * fixed; only the host in front of it moves. See `../../../connectivity/auth/
43
+ * oauth-authcode/broker.ts` for what an override is allowed to be.
44
+ */
45
+ export function googleBroker(env?: Record<string, string | undefined>): AuthBroker {
46
+ return {
47
+ url: `${brokerOriginOverride(env) ?? BROKER_ORIGIN}${BROKER_PATH}`,
48
+ operator: 'Lanes',
49
+ docs_url: 'https://lanes.sh/link#google',
50
+ };
51
+ }
52
+
53
+ export const GOOGLE_BROKER: AuthBroker = googleBroker();
54
+
55
+ export const GOOGLE_OAUTH = {
56
+ authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth',
57
+ token_url: 'https://oauth2.googleapis.com/token',
58
+ //
59
+ // `select_account` matters as much as the rest: with it omitted Google reuses
60
+ // whichever account the browser is already signed into and never offers a
61
+ // choice, so connecting a second mailbox silently re-authorises the first.
62
+ authorize_params: { access_type: 'offline', prompt: 'select_account consent' },
63
+ // Every REST provider here spreads this block, so one line turns brokering on
64
+ // for all seven. `gmail_mcp` and `drive_mcp` write their auth longhand and do
65
+ // not spread it, which is what keeps them bring-your-own — the SDK owns their
66
+ // exchange and `defineProvider` refuses a broker on an mcp connector.
67
+ broker: GOOGLE_BROKER,
68
+ } as const;
69
+
70
+ /**
71
+ * Where a Google provider reads its account label from.
72
+ *
73
+ * Drive's endpoint, deliberately, for Sheets and Docs as well: neither has a
74
+ * "who am I", and `drive.readonly` is requested anyway, so labelling the
75
+ * connection costs no extra consent.
76
+ */
77
+ export const DRIVE_IDENTITY = {
78
+ kind: 'http',
79
+ url: 'https://www.googleapis.com/drive/v3/about?fields=user',
80
+ field: 'user.emailAddress',
81
+ } as const;
82
+
83
+ export const GMAIL_IDENTITY = {
84
+ kind: 'http',
85
+ url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile',
86
+ field: 'emailAddress',
87
+ } as const;
88
+
89
+ /**
90
+ * Calendar answers this itself, rather than borrowing Drive's endpoint.
91
+ *
92
+ * The entry for someone's own calendar is keyed by their address, so its `id`
93
+ * *is* the label — and it reads under `calendar.readonly`, which this provider
94
+ * requests anyway. Following Sheets and Docs to `DRIVE_IDENTITY` would mean
95
+ * asking for `drive.readonly` purely to print a name.
96
+ */
97
+ export const CALENDAR_IDENTITY = {
98
+ kind: 'http',
99
+ url: 'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary',
100
+ field: 'id',
101
+ } as const;
102
+
103
+ /**
104
+ * Contacts asks People who the token belongs to — using the very operation that
105
+ * cannot be a *tool*.
106
+ *
107
+ * `people.people.get` is unreachable through the generated path, because
108
+ * `{resourceName}` has its slash percent-encoded and `people/me` stops being a
109
+ * path (see the note in `../specs/vendor.ts`). A literal URL has no placeholder
110
+ * to encode, so the same call works here. `pluck` steps through the first array
111
+ * element, which is what resolves `emailAddresses.value`.
112
+ */
113
+ export const PEOPLE_IDENTITY = {
114
+ kind: 'http',
115
+ url: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses',
116
+ field: 'emailAddresses.value',
117
+ } as const;
118
+
119
+ /** A vendored spec, addressed from this folder rather than by counting `..`. */
120
+ export function specPath(name: string): string {
121
+ return new URL(`../specs/${name}`, import.meta.url).pathname;
122
+ }
@@ -0,0 +1,99 @@
1
+ import type { ScopeMeaning } from '../../scopes.ts';
2
+
3
+ /**
4
+ * What the Google scopes we ship actually permit, in plain words.
5
+ *
6
+ * Vendor knowledge, so it lives with the vendor. It used to sit in the CLI,
7
+ * which is where it is *displayed* — but a table of Google scope semantics in
8
+ * `src/cli/` is exactly the leak this layout exists to close: `lanes link
9
+ * connect` should be able to explain any provider's grant without the CLI
10
+ * having learned that provider's vocabulary.
11
+ *
12
+ * Confined to display. Nothing routes on it, and a scope missing from this table
13
+ * is printed unannotated rather than mis-described — silence is the safe failure
14
+ * here.
15
+ */
16
+ export const GOOGLE_SCOPE_MEANINGS: Record<string, ScopeMeaning> = {
17
+ // Asked for only when authorising against the client Lanes operates, which
18
+ // needs to tell one caller's refresh from another's. Neither reaches any
19
+ // Google service, and describing them matters precisely because they are the
20
+ // two the operator did not ask for.
21
+ openid: { meaning: 'a signed statement of which Google account this is — no access to anything' },
22
+ email: {
23
+ meaning: 'your address, so the hosted client can tell whose connection this is. Not the mailbox',
24
+ },
25
+ 'https://mail.google.com/': {
26
+ meaning: 'full mailbox — read, send, and permanently delete any message',
27
+ broad: true,
28
+ },
29
+ 'https://www.googleapis.com/auth/gmail.modify': {
30
+ meaning: 'read, send, and modify any message; no permanent delete',
31
+ broad: true,
32
+ },
33
+ 'https://www.googleapis.com/auth/gmail.readonly': { meaning: 'read mail and settings' },
34
+ 'https://www.googleapis.com/auth/gmail.compose': { meaning: 'create and send drafts' },
35
+ 'https://www.googleapis.com/auth/gmail.settings.basic': {
36
+ // Broad for what it *outlives*, not for how much it touches. A filter keeps
37
+ // acting on mail that has not arrived yet, so this is the only grant here
38
+ // that survives the session, the token, and the connection being disabled.
39
+ meaning: 'create and delete filters, and change your send-as identities — a filter is a standing rule that keeps acting after the session ends',
40
+ broad: true,
41
+ },
42
+ 'https://www.googleapis.com/auth/gmail.metadata': {
43
+ meaning: 'headers and labels only, no message bodies',
44
+ },
45
+ 'https://www.googleapis.com/auth/drive': {
46
+ meaning: 'every file in the account — read, write, and delete',
47
+ broad: true,
48
+ },
49
+ 'https://www.googleapis.com/auth/drive.readonly': { meaning: 'read every file in the account' },
50
+ 'https://www.googleapis.com/auth/drive.file': {
51
+ meaning: 'only files this app creates or you pick',
52
+ },
53
+ // Broad, and narrower than `auth/drive` — one file type rather than the whole
54
+ // account. Still every document of that type, including ones this app has
55
+ // never seen, which is the line `drive.file` does not cross.
56
+ 'https://www.googleapis.com/auth/spreadsheets': {
57
+ meaning: 'every spreadsheet in the account — read, edit, create, and delete',
58
+ broad: true,
59
+ },
60
+ 'https://www.googleapis.com/auth/documents': {
61
+ meaning: 'every document in the account — read, edit, create, and delete',
62
+ broad: true,
63
+ },
64
+ // Calendar's `auth/drive`: it does not merely edit what is on a calendar, it
65
+ // creates and deletes calendars and changes who they are shared with. Listed
66
+ // so the table can say what is refused, never requested.
67
+ 'https://www.googleapis.com/auth/calendar': {
68
+ meaning: 'every calendar you can access — read, edit, share, and delete',
69
+ broad: true,
70
+ },
71
+ 'https://www.googleapis.com/auth/calendar.readonly': {
72
+ meaning: 'read every calendar you can access',
73
+ },
74
+ // Broad on the same reading as `spreadsheets` — every event on every
75
+ // calendar, including ones this app has never seen. Narrower than
76
+ // `auth/calendar`, which also reaches the calendars themselves.
77
+ 'https://www.googleapis.com/auth/calendar.events': {
78
+ meaning: 'every event on every calendar — read, create, change, and delete',
79
+ broad: true,
80
+ },
81
+ // Broad because Google publishes nothing narrower that can write. `tasks` and
82
+ // `tasks.readonly` are the entire vocabulary, so adding one task means
83
+ // holding write and delete over every list in the account.
84
+ 'https://www.googleapis.com/auth/tasks': {
85
+ meaning: 'every task list — read, create, edit, organise, and delete',
86
+ broad: true,
87
+ },
88
+ 'https://www.googleapis.com/auth/tasks.readonly': { meaning: 'read your task lists and tasks' },
89
+ // Refused, and listed for the same reason as `auth/calendar`: a contact card
90
+ // deleted through the API is gone.
91
+ 'https://www.googleapis.com/auth/contacts': {
92
+ meaning: 'read, edit, and permanently delete your contacts',
93
+ broad: true,
94
+ },
95
+ 'https://www.googleapis.com/auth/contacts.readonly': { meaning: 'read your saved contacts' },
96
+ 'https://www.googleapis.com/auth/contacts.other.readonly': {
97
+ meaning: 'read the addresses Gmail saved automatically in "Other contacts"',
98
+ },
99
+ };