@lanes-sh/link 0.2.2 → 0.3.1

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 (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,125 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /**
4
+ * Gmail over IMAP, for the account that cannot use either other route.
5
+ *
6
+ * A personal Google account has exactly one way to a mailbox that does not
7
+ * expire, and this is it. The two alternatives both fail for a reason that is
8
+ * not about effort:
9
+ *
10
+ * - The REST provider (`gmail`) authorises in a browser, and an OAuth client
11
+ * left in "Testing" has every refresh token it issues expired after seven
12
+ * days. Publishing the client fixes that and is the better answer where it is
13
+ * available — see ADR-038 and `docs/detailed/setup/google.md`.
14
+ * - The key route (`auth.assertion` on `gmail`) does not apply at all. A service
15
+ * account has no mailbox of its own, so it can only reach one by acting as
16
+ * somebody, and that grant is domain-wide delegation — made in a Workspace
17
+ * admin console that a personal account does not have.
18
+ *
19
+ * An app password has neither problem. It is issued by the account holder to
20
+ * themselves, it does not expire, and IMAP asks no authorization server for
21
+ * anything. What it costs is reach and shape: this is a mailbox over IMAP and
22
+ * SMTP, so it is the mail capability set rather than Gmail's API — no labels
23
+ * vocabulary, no threads resource, no drafts. Searching, reading, flagging,
24
+ * moving and sending, which is most of what a mailbox is for.
25
+ *
26
+ * The mirror image of `../shared/service-account.ts`, and deliberately so: that
27
+ * one covers Workspace and not personal accounts, this one covers personal
28
+ * accounts and not Workspace. Google turned off basic authentication for
29
+ * Workspace in March 2025, and an administrator can disable app passwords
30
+ * outright, so an account under a domain should take one of the other two.
31
+ *
32
+ * A separate manifest rather than a route on `gmail` because a manifest has one
33
+ * connector, and IMAP is a different protocol from HTTPS — the same reason
34
+ * iCloud is three providers rather than one. It also makes the policy line
35
+ * separable: `gmail_imap.*` can be allowed without granting the REST surface,
36
+ * or the other way round.
37
+ */
38
+ export const gmailImap = defineProvider({
39
+ id: 'gmail_imap',
40
+ name: 'Gmail (IMAP)',
41
+ description:
42
+ 'Read, search, and send mail in a personal Gmail mailbox over IMAP and SMTP, with an app password that does not expire.',
43
+ connector: {
44
+ kind: 'imap',
45
+ host: 'imap.gmail.com',
46
+ port: 993,
47
+ smtp: {
48
+ host: 'smtp.gmail.com',
49
+ port: 587,
50
+ starttls: true,
51
+ // Gmail's limit is 25 MB for the whole encoded message rather than the
52
+ // 20 MB default, and the send path derives the usable weight of the files
53
+ // from it. Declaring the real number is what makes an oversized message
54
+ // refused before dialling rather than part-way through DATA.
55
+ max_message_bytes: 25 * 1024 * 1024,
56
+ },
57
+ },
58
+ // Not a choice. `defineProvider` refuses anything else on an imap connector,
59
+ // because every mail host that matters issues an app password and expects it
60
+ // over Basic — and Gmail's OAuth path belongs to the REST provider.
61
+ auth: { kind: 'basic' },
62
+ // No `app`: this is one provider rather than a family, so the credential
63
+ // lands at `gmail_imap/<connection>` and is shared with nothing. An app
64
+ // password is issued per app rather than per account, so there would be
65
+ // nothing to share it with even if there were siblings.
66
+ identity: { kind: 'connector' },
67
+ setup: {
68
+ summary:
69
+ 'Gmail over IMAP uses an app password — a sixteen-character password you issue to yourself, ' +
70
+ 'which is not your Google account password and does not expire. Nothing is authorised in a ' +
71
+ 'browser and nothing has to be re-approved later. This is the personal-account route: ' +
72
+ 'Google turned off basic authentication for Workspace accounts in March 2025, and a Workspace ' +
73
+ 'administrator can disable app passwords for the whole domain.',
74
+ docs: 'docs/detailed/setup/google.md',
75
+ docs_url: 'https://support.google.com/accounts/answer/185833',
76
+ steps: [
77
+ 'Two-Step Verification has to be on. Without it Google does not offer app passwords at all, and the page below returns "the setting you are looking for is not available for your account" rather than saying why. Turn it on at https://myaccount.google.com/signinoptions/twosv.',
78
+ 'Open https://myaccount.google.com/apppasswords and create one. Name it "Lanes Link" — the name is the only way to revoke this one later without cutting off your other devices.',
79
+ 'Copy the sixteen characters. Google shows them once, in four groups of four; the spaces are cosmetic and it is accepted either way.',
80
+ 'You are asked for your full address next, which is the one you sign in with, ending @gmail.com.',
81
+ 'IMAP is on by default. If a login is refused with a mailbox error rather than an authentication error, check Gmail → Settings → See all settings → Forwarding and POP/IMAP → IMAP access.',
82
+ ],
83
+ // What a transport cannot say for itself, because it must not know which
84
+ // vendor it is talking to. The first sentence is the mistake this route
85
+ // actually produces: an app password looks enough like a password that the
86
+ // account password gets pasted instead, and IMAP reports both identically.
87
+ troubleshooting:
88
+ 'For Gmail this is almost always a Google account password used where an app password belongs, ' +
89
+ 'or an account without Two-Step Verification — app passwords do not exist without it. Generate ' +
90
+ 'one at https://myaccount.google.com/apppasswords and re-run: lanes link connect gmail_imap --replace.',
91
+ prompts: [
92
+ {
93
+ key: 'username',
94
+ label: 'Google account (the full email address)',
95
+ secret: false,
96
+ scope: 'connection' as const,
97
+ field: 'username' as const,
98
+ },
99
+ {
100
+ key: 'password',
101
+ label: 'App password (sixteen characters)',
102
+ secret: true,
103
+ scope: 'connection' as const,
104
+ field: 'password' as const,
105
+ },
106
+ ],
107
+ },
108
+ // The mail capability set, so the same reasoning as `icloud_mail` applies
109
+ // verbatim: opted back in one key at a time, everything unlisted withheld.
110
+ redact: {
111
+ // Never the search terms — a query is content, and "who did I email about
112
+ // the diagnosis" is the whole message.
113
+ search_messages: ['mailbox', 'limit', 'unseen', 'flagged'],
114
+ get_message: ['mailbox', 'uid', 'include_body'],
115
+ mark_messages: ['mailbox', 'add_flags', 'remove_flags'],
116
+ // `destination_flag` alongside `destination`: two spellings of the same
117
+ // fact, and keeping only one means a junk move logs with no destination.
118
+ move_messages: ['mailbox', 'destination', 'destination_flag'],
119
+ // Nothing. The recipients and the body are the message, and `attachments`
120
+ // may literally contain a file. What was attached is recorded by the send
121
+ // path itself through `audit.annotate` — filename, size, type, SHA-256 and
122
+ // origin. Identifiers, not content.
123
+ send_message: [],
124
+ },
125
+ });
@@ -1,10 +1,11 @@
1
- /** Google's nine providers. Each folder holds all of its own vendor knowledge. */
1
+ /** Google's ten providers. Each folder holds all of its own vendor knowledge. */
2
2
  export { calendar } from './calendar/index.ts';
3
3
  export { contacts } from './contacts/index.ts';
4
4
  export { docs } from './docs/index.ts';
5
5
  export { drive, DRIVE_SCOPES } from './drive/index.ts';
6
6
  export { driveMcp } from './drive-mcp/index.ts';
7
7
  export { gmail, GMAIL_SCOPES } from './gmail/index.ts';
8
+ export { gmailImap } from './gmail-imap/index.ts';
8
9
  export { gmailMcp } from './gmail-mcp/index.ts';
9
10
  export { sheets } from './sheets/index.ts';
10
11
  export { tasks } from './tasks/index.ts';
@@ -24,10 +24,18 @@ export const GOOGLE_APP = 'google';
24
24
  * and there is no way to withdraw one copy of a secret.
25
25
  *
26
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.
27
+ * console, and no scope list to transcribe. What it costs is recorded in
28
+ * ADR-028 and in the guarantee table in `docs/detailed/security.md` chiefly
29
+ * that the exchange stops being local.
30
+ *
31
+ * What it does *not* buy, and used to claim to: escape from the seven-day
32
+ * refresh-token expiry. That expiry is a property of the client's publishing
33
+ * status rather than of its verification, and a client under review has the
34
+ * status it has — so a connection made this way is re-authorised weekly until
35
+ * the review lands, exactly like one made against a client of the operator's
36
+ * own that was left in "Testing". The way around it is not a different client.
37
+ * It is `auth.assertion`: a key does not expire because nothing consented, and
38
+ * `./service-account.ts` is that route.
31
39
  */
32
40
  const BROKER_ORIGIN = 'https://api.lanes.sh';
33
41
  const BROKER_PATH = '/v1/auth/link/google';
@@ -62,8 +70,12 @@ export const GOOGLE_OAUTH = {
62
70
  authorize_params: { access_type: 'offline', prompt: 'select_account consent' },
63
71
  // Every REST provider here spreads this block, so one line turns brokering on
64
72
  // 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.
73
+ // not spread it, which is what keeps them bring-your-own.
74
+ //
75
+ // That is now a choice rather than a constraint. ADR-040 made a broker legal
76
+ // on an mcp connector that names its own endpoints, so these two could follow
77
+ // Slack — what stops them is that nobody has established which client Google
78
+ // would have us use for its MCP servers, not that the machinery refuses.
67
79
  broker: GOOGLE_BROKER,
68
80
  } as const;
69
81
 
@@ -0,0 +1,110 @@
1
+ import { GOOGLE_APP } from './oauth.ts';
2
+
3
+ /**
4
+ * The other way into a Google account: a key, instead of a browser.
5
+ *
6
+ * Google issues service accounts, and a service account holds a private key
7
+ * that does not expire. It is the answer to the one complaint the browser flow
8
+ * cannot fix — that a client left in "Testing" has its refresh tokens expired
9
+ * after seven days, so every connection has to be re-approved weekly until
10
+ * verification lands. A key is not subject to that, or to any other policy the
11
+ * issuer applies to consent, because nobody consented.
12
+ *
13
+ * What it costs is reach, and the cost is different per product, which is why
14
+ * `delegation` is a parameter rather than a constant. A service account is an
15
+ * identity in its own right: it has a Drive, and a calendar, and no mailbox and
16
+ * no contacts. So Drive, Sheets, Docs and Calendar work by *sharing* something
17
+ * with its address, and Gmail, Contacts and Tasks work only if a Workspace
18
+ * administrator lets the key act as a person. A personal Google account has no
19
+ * administrator, so for those three there is no key route at all and the
20
+ * walkthrough says so rather than letting someone find out later.
21
+ */
22
+
23
+ /** Where the key lives. One file per profile, covering every Google provider. */
24
+ export const GOOGLE_KEY_REF = `${GOOGLE_APP}/service_account_key`;
25
+
26
+ const SHARE_HINT: Record<string, string> = {
27
+ Drive: 'a folder (Share → paste the address → Editor)',
28
+ Sheets: 'a spreadsheet (Share → paste the address → Editor)',
29
+ Docs: 'a document (Share → paste the address → Editor)',
30
+ Calendar:
31
+ 'a calendar (Settings for that calendar → "Share with specific people" → paste the address)',
32
+ };
33
+
34
+ /**
35
+ * Where a personal account should go instead, for the products that have
36
+ * somewhere.
37
+ *
38
+ * Only Gmail does. A key needs delegation for mail, contacts and task lists
39
+ * alike, and delegation needs an administrator — but mail is the one Google
40
+ * still serves over a protocol that takes a password, so `gmail_imap` is a real
41
+ * answer rather than a consolation. Contacts and Tasks have none, and saying so
42
+ * plainly beats sending somebody to look.
43
+ */
44
+ export const googleServiceAccount = (
45
+ product: string,
46
+ scopes: readonly string[],
47
+ delegation: 'optional' | 'required',
48
+ apis: readonly string[],
49
+ instead?: string,
50
+ ) => ({
51
+ method: 'service_account',
52
+ label: 'Service account key',
53
+ delegation,
54
+ key_ref: GOOGLE_KEY_REF,
55
+ reach:
56
+ delegation === 'optional'
57
+ ? `a JSON key that never expires, and no browser. Reaches only what you share with the ` +
58
+ `key's own address, so nothing in the account moves until you share it.`
59
+ : `a JSON key that never expires, and no browser. ${product} has nothing that belongs to ` +
60
+ `a key, so this needs a Google Workspace administrator to let it act as you — a personal ` +
61
+ `Google account cannot do it` +
62
+ (instead ? `, and wants ${instead} instead.` : '.'),
63
+ subject_label:
64
+ delegation === 'optional'
65
+ ? 'Google account to act as, if an administrator has granted it'
66
+ : 'Google account to act as',
67
+ setup: {
68
+ summary:
69
+ delegation === 'optional'
70
+ ? `${product} can authenticate with a service account key instead of signing in. The key ` +
71
+ `does not expire, so this is connected once and stays connected. It reaches only what ` +
72
+ `is shared with it, which is the trade: you pick what it can see, one resource at a time.`
73
+ : `${product} can authenticate with a service account key instead of signing in. The key ` +
74
+ `does not expire — but a key has no mailbox, contacts or task lists of its own, so this ` +
75
+ `route works only where a Google Workspace administrator has authorised the key to act ` +
76
+ `as a user in their domain. On a personal Google account, use the browser instead` +
77
+ (instead ? `, or ${instead}, which is an app password over IMAP and does not expire either.` : '.'),
78
+ docs: 'docs/detailed/setup/google.md',
79
+ docs_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
80
+ steps: [
81
+ 'Create or pick a project at https://console.cloud.google.com',
82
+ `Enable the APIs:\n gcloud services enable ${apis.join(' ')} --project=YOUR_PROJECT\n Without gcloud: APIs & Services → Library, and search for each by name.`,
83
+ 'IAM & Admin → Service Accounts → Create service account. Name it "Lanes Link". Grant it no project roles — the roles page governs Google Cloud resources, and nothing here is one.',
84
+ 'Open the account → Keys → Add key → Create new key → JSON. It downloads once. You are asked for the path to that file next; its contents go to the credential store and the file itself is not read again.',
85
+ ...(delegation === 'required'
86
+ ? [
87
+ 'THIS PRODUCT NEEDS DOMAIN-WIDE DELEGATION, which only a Google Workspace administrator can grant. Copy the service account\'s numeric "Unique ID" (the client ID, not the email) from its Details tab.',
88
+ `In the Workspace Admin console → Security → Access and data control → API controls → Domain-wide delegation → Add new. Paste that client ID, and paste this scope list exactly:\n ${scopes.join(',\n ')}\n All of them, comma-separated, in one field. A partial list is refused the same way a missing one is, and the refusal does not say which scope was short.`,
89
+ 'Delegation can take a few minutes to take effect. If the first connect is refused with "unauthorized_client", wait and run it again — nothing was stored.',
90
+ ]
91
+ : [
92
+ `Share what you want reachable with the service account's email address — it ends in .iam.gserviceaccount.com and is printed once the key is stored. For ${product} that means ${SHARE_HINT[product] ?? 'the resource itself'}.`,
93
+ 'Nothing else in the account is reachable, including files the same person owns. That is the point of this route, and it is also the thing to remember when something is "missing" — it has not been shared yet.',
94
+ 'A Workspace administrator can instead grant domain-wide delegation, which lets the key act as a user and reach everything they can. The connect prompt asks which account to act as; leaving it blank means the key acts as itself.',
95
+ ]),
96
+ ],
97
+ prompts: [
98
+ {
99
+ key: 'service_account_key',
100
+ label: 'Path to the downloaded JSON key (or paste its contents)',
101
+ // Read from a path in the ordinary case, so this is not a secret typed
102
+ // into a terminal — but it may be pasted, and a pasted private key must
103
+ // not land in scrollback.
104
+ secret: true,
105
+ scope: 'shared' as const,
106
+ credential_ref: GOOGLE_KEY_REF,
107
+ },
108
+ ],
109
+ },
110
+ });
@@ -40,6 +40,21 @@ export const googleSetup = (
40
40
  summary: `${product} signs in with Google. By default it authorises against the OAuth client Lanes operates, so there is nothing to register and no client secret on this machine — the code is exchanged for a token by the Lanes API, which holds that secret. Pass --own-client to register a client of your own instead; the steps below are that path, asked once per profile and then covering every Google account you connect.`,
41
41
  docs: 'docs/detailed/setup/google.md',
42
42
  docs_url: 'https://console.cloud.google.com/auth',
43
+ // AUDIENCE leads with Internal, and the ordering is the point rather than a
44
+ // preference. The seven-day `invalid_grant` everyone hits is a property of
45
+ // publishing status, and an Internal app has no publishing status to have —
46
+ // so it also has no expiry, no verification question, no unverified-app
47
+ // warning, no test-user list, and no scope registration. Steering a
48
+ // Workspace user to External costs them every one of those, to buy reach
49
+ // they do not want: Internal admits only their own domain, which is the
50
+ // whole set of accounts they were going to connect.
51
+ //
52
+ // What it cannot be is the only path. "Internal" is Google's word for
53
+ // "inside my Workspace organisation", not "private to me", and the option
54
+ // is simply absent on a project with no organisation behind it. So a
55
+ // personal @gmail.com still needs External and still has to publish, and
56
+ // the steps say which half applies to whom rather than making everyone read
57
+ // both.
43
58
  steps: [
44
59
  ...(options.preview
45
60
  ? [
@@ -56,11 +71,14 @@ export const googleSetup = (
56
71
  }\n Without gcloud: APIs & Services → Library, and search for each by name.`,
57
72
  'The rest is under Google Auth Platform — https://console.cloud.google.com/auth — in this order:',
58
73
  ' BRANDING — app name and a support email. Seen by nobody but you.',
59
- ' AUDIENCE — User type: EXTERNAL (even with a Workspace domain: "Internal" admits only that one domain, so a mix of personal and Workspace accounts needs External). Add every account you will connect under "Test users". LEAVE the status as "Testing".',
60
- ` DATA ACCESSwhere scopes live now. Add:\n ${scopes.join('\n ')}\n Note drive.file is filed under "sensitive" rather than "restricted", so it appears in a different section of that page.`,
74
+ ' AUDIENCE — User type, and this is the step that decides how much of the rest you do.\n INTERNAL, if the project sits in a Google Workspace organisation and every account you will connect is on that domain. An Internal app has no publishing status at all — so no seven-day expiry, no verification, no test-user list, no unverified-app warning screen, and no scopes to register. Skip to CLIENTS.\n EXTERNAL, for a personal @gmail.com, or a mix of personal and Workspace accounts. Internal is not offered without a Workspace organisation, and admits only your own domain. Add every account you will connect under "Test users", then do the two steps below.',
75
+ ' AUDIENCE, againEXTERNAL ONLY: PUBLISH the app. This is the setting that decides whether your connections survive the week, and it is not the same thing as verification: a client left in "Testing" has every refresh token it issues expired after exactly seven days, and one set to "In production" does not, review pending or not. Internal apps have no publishing status, which is why they never expire.',
76
+ ` DATA ACCESS — EXTERNAL ONLY. Where scopes live now. Add:\n ${scopes.join('\n ')}\n Note drive.file is filed under "sensitive" rather than "restricted", so it appears in a different section of that page. An Internal app does not need this: it authorises scopes that were never registered here, so the page can stay empty.`,
61
77
  ' CLIENTS — Create OAuth client → type: DESKTOP APP. Google\'s docs say "Web application" with a redirect URI for Claude or Antigravity, because they assume the agent host runs the OAuth. Here the CLI does, on a loopback port — which is also why this stays Desktop even when the server runs on Cloud Run.',
62
78
  'Copy the client ID and secret — you are asked for them next.',
63
- 'Not publishing is deliberate: these are restricted scopes, and publishing them means Google verification with a CASA assessment taking months. Testing needs none. The cost is that refresh tokens expire after 7 days, so expect to re-run "lanes link connect" weekly "lanes link doctor" says which are stale.',
79
+ 'EXTERNAL ONLY — what publishing unverified costs, so it is a decision rather than a surprise: everyone you connect sees a "Google hasn\'t verified this app" screen and has to click through Advanced, and the project gains a cap of 100 new users granted these scopes. That cap is for the lifetime of the project and cannot be reset — which does not matter for a client only you use, and matters a great deal for one you intend to hand out. None of this applies to an Internal app.',
80
+ 'EXTERNAL ONLY — verification itself is the other path and a much longer one: restricted scopes mean a review with a security assessment measured in months. It is worth starting and not worth waiting on, since publishing above removes the weekly re-authorisation today. If you have a Workspace domain, Internal skips this question entirely.',
81
+ 'If none of that suits — an organisation that forbids publishing, or a client that must stay in Testing — connect with a service account key instead. It does not expire at all: lanes link connect ' + product.toLowerCase().split(' ')[0] + ' --auth service_account',
64
82
  ],
65
83
  prompts: [
66
84
  {
@@ -1,5 +1,6 @@
1
1
  import { defineProvider } from '#connectivity';
2
2
  import { DRIVE_IDENTITY, GOOGLE_APP, GOOGLE_OAUTH, specPath } from '../shared/oauth.ts';
3
+ import { googleServiceAccount } from '../shared/service-account.ts';
3
4
  import { googleSetup } from '../shared/setup.ts';
4
5
  import { SHEETS_HINTS } from './hints.ts';
5
6
  import { SHEETS_REDACT } from './redact.ts';
@@ -60,6 +61,7 @@ export const sheets = defineProvider({
60
61
  app: GOOGLE_APP,
61
62
  scopes: SHEETS_SCOPES,
62
63
  ...GOOGLE_OAUTH,
64
+ assertion: googleServiceAccount('Sheets', SHEETS_SCOPES, 'optional', ['sheets.googleapis.com', 'drive.googleapis.com']),
63
65
  },
64
66
  identity: DRIVE_IDENTITY,
65
67
  setup: googleSetup('Sheets', SHEETS_SCOPES, {
@@ -1,5 +1,6 @@
1
1
  import { defineProvider } from '#connectivity';
2
2
  import { GOOGLE_APP, GOOGLE_OAUTH, specPath } from '../shared/oauth.ts';
3
+ import { googleServiceAccount } from '../shared/service-account.ts';
3
4
  import { googleSetup } from '../shared/setup.ts';
4
5
  import { TASKS_REDACT } from './redact.ts';
5
6
 
@@ -47,6 +48,7 @@ export const tasks = defineProvider({
47
48
  app: GOOGLE_APP,
48
49
  scopes: TASKS_SCOPES,
49
50
  ...GOOGLE_OAUTH,
51
+ assertion: googleServiceAccount('Tasks', TASKS_SCOPES, 'required', ['tasks.googleapis.com']),
50
52
  },
51
53
  setup: googleSetup('Tasks', TASKS_SCOPES, { apis: ['tasks.googleapis.com'] }),
52
54
  redact: TASKS_REDACT,
@@ -0,0 +1,166 @@
1
+ import { z } from 'zod';
2
+ import { defineLocalProvider, type ProviderDefinition } from '#connectivity';
3
+ import type { IdentityEntry } from '#profile';
4
+
5
+ /**
6
+ * `identity` — who the owner is, for anything written as them.
7
+ *
8
+ * **Read-only by construction, for the same reason `setup` is.** ADR-007 keeps
9
+ * configuration mutation off the MCP surface, and this block *is* configuration:
10
+ * an agent that could rewrite whose name it signs with would be rewriting the
11
+ * one fact that stops it signing wrong. Declaring it is the owner's, in a
12
+ * terminal. So there is one capability, it is a read, and `provider.test.ts`
13
+ * asserts the capability list holds nothing else.
14
+ *
15
+ * Reporting it authorises nothing, which is the ADR-019 argument again. A name
16
+ * and an address are disclosed by the first message of any mailbox this endpoint
17
+ * serves — a caller holding a grant on a mail connection already has them —
18
+ * so withholding them here while serving the mailbox would be theatre. The
19
+ * difference is that reading them *here* means not having to guess, and a guess
20
+ * is what this exists to replace.
21
+ *
22
+ * Why it is its own provider rather than a third section of `setup_overview`:
23
+ * policy. `identity.*` is a grant an owner can give or withhold on its own, so
24
+ * an endpoint can describe what is connected without naming its owner, or name
25
+ * its owner without describing what is connected. Folding it into `setup` would
26
+ * have made those one decision, and they are not.
27
+ *
28
+ * `identity.list` is clean against the seven patterns in
29
+ * `dispatch/control-plane.test.ts`. So is `identity` as a prefix. Neither is a
30
+ * cosmetic name.
31
+ */
32
+
33
+ export interface IdentityProviderOptions {
34
+ /**
35
+ * Which profile this instance serves.
36
+ *
37
+ * Stamped at construction for the reason recorded on `SetupProviderOptions`:
38
+ * `makeHandler` strips `profile` off the arguments before dispatch and
39
+ * `ProviderContext` does not carry it, so a handler cannot learn it. One
40
+ * registry is built per profile, so each instance gets its own.
41
+ */
42
+ readonly profile: string;
43
+ /**
44
+ * Which target it is serving, for the command in the empty case.
45
+ *
46
+ * Needed for the same reason `setup`'s plans need it: `--profile` and
47
+ * `--target` are required flags (ADR-037), and a command handed to an agent
48
+ * without both is a paste that refuses. An agent pastes what it is given.
49
+ */
50
+ readonly target?: string;
51
+ /**
52
+ * What that profile declares, in declaration order.
53
+ *
54
+ * A snapshot rather than a function, unlike `setup`'s `reachable`. That one
55
+ * has to be re-evaluated per call because policy is; this is config, and the
56
+ * registry holding it is rebuilt whenever config is re-read — so an entry
57
+ * added by the CLI is served after the next reload, exactly as a new
58
+ * connection is, and a function here would only imply a freshness it could
59
+ * not deliver.
60
+ */
61
+ readonly entries?: readonly IdentityEntry[];
62
+ }
63
+
64
+ export function createIdentityProvider(options: IdentityProviderOptions): ProviderDefinition {
65
+ const entries = options.entries ?? [];
66
+
67
+ return defineLocalProvider({
68
+ id: 'identity',
69
+ name: 'Identity',
70
+ version: '1.0.0',
71
+ description:
72
+ 'The names, addresses, and handles this profile declares for its owner, and a note on ' +
73
+ 'when each applies. Read-only: what is declared is set in the CLI, because an agent able ' +
74
+ 'to change whose name it signs with could change the one fact that stops it signing wrong.',
75
+
76
+ configSchema: z.object({}),
77
+ connectionSchema: z.object({}),
78
+
79
+ bundles: [
80
+ {
81
+ name: 'read',
82
+ description: 'Read the declared identity. There is no write bundle, by design.',
83
+ oauth_scopes: [],
84
+ capabilities: ['list'],
85
+ default: true,
86
+ },
87
+ ],
88
+
89
+ capabilities: [
90
+ {
91
+ kind: 'tool',
92
+ name: 'list',
93
+ title: 'Who the owner is, in this profile',
94
+ description:
95
+ 'The names, addresses, and handles this profile declares for its owner, each with a ' +
96
+ 'note on when it applies. Call this before writing as them — signing a message, ' +
97
+ 'addressing one, choosing an account to act as — rather than inferring one from ' +
98
+ 'the conversation. A profile may declare several of a kind deliberately.',
99
+ inputSchema: z.object({}),
100
+ // No arguments to redact.
101
+ async handler(_input, _handlerContext) {
102
+ return {
103
+ content: [{ type: 'text', text: render(options.profile, options.target, entries) }],
104
+ };
105
+ },
106
+ },
107
+ ],
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Grouped by kind, in order of first appearance, and self-describing per line.
113
+ *
114
+ * Grouped because the failure this exists to prevent is a name used where an
115
+ * address was wanted, and adjacency is most of what stops that. The kind is
116
+ * repeated on every line anyway rather than written once as a heading: a line
117
+ * lifted out of this block on its own then still says what it is, and a model
118
+ * quoting one line is exactly what happens next.
119
+ *
120
+ * Order within a kind is declaration order, which is the owner's ranking.
121
+ */
122
+ function render(
123
+ profile: string,
124
+ target: string | undefined,
125
+ entries: readonly IdentityEntry[],
126
+ ): string {
127
+ if (entries.length === 0) {
128
+ // Both flags, spelled out. They are required (ADR-037), so a command
129
+ // missing either is one the owner pastes and watches refuse — and this is
130
+ // handed to an agent, which relays it verbatim.
131
+ const where = `--profile ${profile}${target ? ` --target ${target}` : ''}`;
132
+ return (
133
+ `Profile "${profile}" declares no identity.\n\n` +
134
+ 'Nothing here says what name or address to use, so do not invent one — ask. ' +
135
+ `The owner declares them with \`lanes link identity add <kind> <value> ${where}\`.`
136
+ );
137
+ }
138
+
139
+ const kinds = [...new Set(entries.map((entry) => entry.kind))];
140
+ const ordered = kinds.flatMap((kind) => entries.filter((entry) => entry.kind === kind));
141
+
142
+ const kindWidth = Math.max(...ordered.map((entry) => entry.kind.length));
143
+ const valueWidth = Math.max(...ordered.map((entry) => entry.value.length));
144
+
145
+ const lines = ordered.map((entry) => {
146
+ const head = ` ${entry.kind.padEnd(kindWidth)} ${entry.value}`;
147
+ return entry.note ? `${head.padEnd(kindWidth + valueWidth + 4)} — ${entry.note}` : head;
148
+ });
149
+
150
+ // Said here rather than left to the reader because "several names" is
151
+ // otherwise ambiguous in the one direction that matters: a model handed two
152
+ // with no ranking picks by position anyway, and may as well be told that
153
+ // position is what it means.
154
+ const several = kinds.some((kind) => entries.filter((entry) => entry.kind === kind).length > 1);
155
+
156
+ return [
157
+ `Identity for profile "${profile}".`,
158
+ '',
159
+ ...lines,
160
+ '',
161
+ several
162
+ ? 'Where a kind holds more than one, the first is the default and the notes say when to ' +
163
+ 'prefer another. If none of them fits what you are doing, ask rather than combining them.'
164
+ : 'Use these as written. If what you need is not here, ask rather than inferring it.',
165
+ ].join('\n');
166
+ }
@@ -6,6 +6,7 @@ import { github } from './github/index.ts';
6
6
  import { drive } from './google/drive/index.ts';
7
7
  import { driveMcp } from './google/drive-mcp/index.ts';
8
8
  import { gmail } from './google/gmail/index.ts';
9
+ import { gmailImap } from './google/gmail-imap/index.ts';
9
10
  import { gmailMcp } from './google/gmail-mcp/index.ts';
10
11
  import { sheets } from './google/sheets/index.ts';
11
12
  import { tasks } from './google/tasks/index.ts';
@@ -59,6 +60,7 @@ export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
59
60
  calendar,
60
61
  tasks,
61
62
  contacts,
63
+ gmailImap,
62
64
  gmailMcp,
63
65
  driveMcp,
64
66
  icloudMail,
@@ -87,6 +89,7 @@ export {
87
89
  drive,
88
90
  driveMcp,
89
91
  gmail,
92
+ gmailImap,
90
93
  gmailMcp,
91
94
  sheets,
92
95
  tasks,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The owner layer — memory, skills, vault.
2
+ * The owner layer — memory, skills, vault, setup, identity.
3
3
  *
4
4
  * Three providers that hold no third-party account: no OAuth, no vendor API, no
5
5
  * rate limit anyone else imposes. They are ordinary `defineLocalProvider`
@@ -19,7 +19,14 @@
19
19
  * read-only by construction — see ADR-019 for why describing setup is not one
20
20
  * of ADR-007's control-plane exclusions.
21
21
  *
22
- * The ids `memory`, `skills`, `vault`, and `setup` are reserved (`RESERVED_PROVIDER_IDS`)
22
+ * `identity` is the fifth and holds no account either. It says who the owner is
23
+ * — the names and addresses to write as them — and is read-only for the reason
24
+ * `setup` is: what it reports is configuration, and configuration is changed in
25
+ * the CLI. It is a provider of its own rather than a section of `setup` so that
26
+ * naming the owner and describing what is connected are two policy decisions
27
+ * instead of one.
28
+ *
29
+ * The ids `memory`, `skills`, `vault`, `setup`, and `identity` are reserved (`RESERVED_PROVIDER_IDS`)
23
30
  * and still refused by default — the registry has to be built with
24
31
  * `allowReserved` to hold them, so a third-party provider cannot claim a
25
32
  * namespace whose policy rules would then mean something else.
@@ -29,6 +36,7 @@ export { memoryProvider, memoryStorage, assertEntryId, type MemoryEntry } from '
29
36
  export { createSkillsProvider, type SkillsProviderOptions } from './skills/provider.ts';
30
37
  export { createVaultProvider, type VaultProviderOptions } from './vault/provider.ts';
31
38
  export { createSetupProvider, type SetupProviderOptions } from './setup/provider.ts';
39
+ export { createIdentityProvider, type IdentityProviderOptions } from './identity/provider.ts';
32
40
  export { planAll, planFor, type PlanContext, type ProviderPlan } from './setup/plan.ts';
33
41
  // The vault's *store* is not here: it is `#secrets`, beside the system
34
42
  // credential store it must never become. What lives in `./vault/` is the
@@ -1,5 +1,6 @@
1
1
  import { GOOGLE_SCOPE_MEANINGS } from './google/shared/scopes.ts';
2
2
  import { LINEAR_SCOPE_MEANINGS } from './linear/scopes.ts';
3
+ import { SLACK_SCOPE_MEANINGS } from './slack/scopes.ts';
3
4
 
4
5
  /**
5
6
  * What a scope actually permits, contributed by the provider that requests it.
@@ -23,4 +24,5 @@ export interface ScopeMeaning {
23
24
  export const SCOPE_MEANINGS: Record<string, ScopeMeaning> = {
24
25
  ...GOOGLE_SCOPE_MEANINGS,
25
26
  ...LINEAR_SCOPE_MEANINGS,
27
+ ...SLACK_SCOPE_MEANINGS,
26
28
  };