@lanes-sh/link 0.2.2 → 0.3.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.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -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
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -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,
|
package/src/providers/owner.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
package/src/providers/scopes.ts
CHANGED
|
@@ -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
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderManifest } from '#connectivity';
|
|
2
|
-
import { setupRequirements, type SetupRequirement } from '#connectivity';
|
|
2
|
+
import { hasOwnClientPath, setupRequirements, type SetupRequirement } from '#connectivity';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What connecting a provider involves, assembled from its manifest.
|
|
@@ -68,10 +68,22 @@ export interface ProviderPlan {
|
|
|
68
68
|
readonly clientOperator?: string;
|
|
69
69
|
/** The line that opts out of it and registers one of your own instead. */
|
|
70
70
|
readonly ownClientCommand?: string;
|
|
71
|
+
/** What `--auth pasted_token` asks for, where that is a way in. */
|
|
72
|
+
readonly pastedCredential?: string;
|
|
73
|
+
/** The line that takes that way in. */
|
|
74
|
+
readonly tokenCommand?: string;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
export interface PlanContext {
|
|
74
78
|
readonly profile: string;
|
|
79
|
+
/**
|
|
80
|
+
* Which target's stores the emitted command should act on.
|
|
81
|
+
*
|
|
82
|
+
* Stamped like `profile` is, and for a stronger reason: a connection's
|
|
83
|
+
* credential lives in one target's store, so a `connect` that lands in the
|
|
84
|
+
* wrong one authorises an account the endpoint asking for it cannot read.
|
|
85
|
+
*/
|
|
86
|
+
readonly target: string;
|
|
75
87
|
/** Every configured connection this caller may see, as `provider.id`. */
|
|
76
88
|
readonly connections: readonly string[];
|
|
77
89
|
/**
|
|
@@ -88,21 +100,22 @@ export function planFor(
|
|
|
88
100
|
context: PlanContext,
|
|
89
101
|
connectionId?: string,
|
|
90
102
|
): ProviderPlan {
|
|
91
|
-
const { requirements, needsId, brokered } = setupRequirements(
|
|
103
|
+
const { requirements, needsId, brokered, pastedCredential } = setupRequirements(
|
|
92
104
|
manifest,
|
|
93
105
|
connectionId,
|
|
94
|
-
context.profile,
|
|
106
|
+
{ profile: context.profile, target: context.target },
|
|
95
107
|
{ ...(context.ownClients ? { ownClients: context.ownClients } : {}) },
|
|
96
108
|
);
|
|
97
109
|
|
|
98
110
|
const connected = context.connections.filter((key) => key.startsWith(`${manifest.id}.`));
|
|
99
111
|
|
|
100
|
-
//
|
|
101
|
-
// and the shell this
|
|
102
|
-
//
|
|
103
|
-
//
|
|
112
|
+
// Both, always, never conditionally. One endpoint serves every profile and
|
|
113
|
+
// each profile may declare several targets, and the shell this is pasted into
|
|
114
|
+
// supplies neither — nothing but the command line does. An emitted command
|
|
115
|
+
// missing either is one that refuses, or worse, writes a credential into a
|
|
116
|
+
// store the endpoint that asked for it does not read.
|
|
104
117
|
const command =
|
|
105
|
-
`lanes link connect ${manifest.id} --profile ${context.profile}` +
|
|
118
|
+
`lanes link connect ${manifest.id} --profile ${context.profile} --target ${context.target}` +
|
|
106
119
|
(needsId ? ' --id <name>' : connectionId ? ` --id ${connectionId}` : '');
|
|
107
120
|
|
|
108
121
|
return {
|
|
@@ -119,13 +132,22 @@ export function planFor(
|
|
|
119
132
|
needsId,
|
|
120
133
|
command,
|
|
121
134
|
brokered,
|
|
135
|
+
...(pastedCredential
|
|
136
|
+
? { pastedCredential, tokenCommand: `${command} --auth pasted_token` }
|
|
137
|
+
: {}),
|
|
122
138
|
...(brokered && manifest.auth.kind === 'oauth' && manifest.auth.broker
|
|
123
139
|
? {
|
|
124
140
|
clientOperator: manifest.auth.broker.operator,
|
|
125
141
|
// The steps stay in `steps` either way. A renderer decides whether to
|
|
126
142
|
// show a console walkthrough for a path nobody has asked for; the
|
|
127
143
|
// plan's job is to say the path exists and what opens it.
|
|
128
|
-
|
|
144
|
+
//
|
|
145
|
+
// Offered only where the manifest actually describes a client to
|
|
146
|
+
// register. Slack's does not — it asks for a token, never for a
|
|
147
|
+
// client id and secret — and `resolveOAuthClient` refuses the flag on
|
|
148
|
+
// exactly that ground, so printing it here would be handing somebody
|
|
149
|
+
// a command that answers back with "there is no such path".
|
|
150
|
+
...(hasOwnClientPath(manifest) ? { ownClientCommand: `${command} --own-client` } : {}),
|
|
129
151
|
}
|
|
130
152
|
: {}),
|
|
131
153
|
};
|
|
@@ -47,6 +47,13 @@ export interface SetupProviderOptions {
|
|
|
47
47
|
* here — one registry is built per profile, so each instance gets its own.
|
|
48
48
|
*/
|
|
49
49
|
readonly profile: string;
|
|
50
|
+
/**
|
|
51
|
+
* Which target this instance's stores came from.
|
|
52
|
+
*
|
|
53
|
+
* Stamped at construction for the same reason `profile` is, and it travels no
|
|
54
|
+
* further than the commands this provider emits — nothing here opens a store.
|
|
55
|
+
*/
|
|
56
|
+
readonly target: string;
|
|
50
57
|
/** Sibling profile names on this endpoint. Names only; already at `/health`. */
|
|
51
58
|
readonly profiles?: readonly string[];
|
|
52
59
|
/**
|
|
@@ -79,6 +86,7 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
|
|
|
79
86
|
|
|
80
87
|
const context = () => ({
|
|
81
88
|
profile: options.profile,
|
|
89
|
+
target: options.target,
|
|
82
90
|
connections: reachable().map((connection) => connection.key),
|
|
83
91
|
...(options.ownClients ? { ownClients: options.ownClients } : {}),
|
|
84
92
|
});
|
|
@@ -309,6 +317,21 @@ function renderProvider(plan: ProviderPlan): string {
|
|
|
309
317
|
);
|
|
310
318
|
}
|
|
311
319
|
|
|
320
|
+
// An alternative, said as one. It is not a value the command above needs, and
|
|
321
|
+
// rendering it beside the requirements — which is what it did before there
|
|
322
|
+
// was anywhere else to put it — reads as a second mandatory step in a setup
|
|
323
|
+
// whose whole selling point is that it has none.
|
|
324
|
+
if (plan.tokenCommand && plan.pastedCredential) {
|
|
325
|
+
lines.push(
|
|
326
|
+
'',
|
|
327
|
+
'If the browser path is refused — a workspace that has not approved this app, which an ' +
|
|
328
|
+
`admin decides — the same command takes --auth pasted_token and asks for the ` +
|
|
329
|
+
`${plan.pastedCredential}:`,
|
|
330
|
+
` ${plan.tokenCommand}`,
|
|
331
|
+
' The console steps for obtaining one are in the setup documentation above.',
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
312
335
|
if (plan.browser) {
|
|
313
336
|
lines.push(
|
|
314
337
|
'',
|
|
@@ -1,68 +1,116 @@
|
|
|
1
1
|
import { defineProvider } from '#connectivity';
|
|
2
2
|
import { SLACK_REDACT } from './redact.ts';
|
|
3
|
+
import { SLACK_APP, SLACK_BROKER, SLACK_SCOPES } from './oauth.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Slack, through the server Slack runs.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* loopback listener HTTPS. A broker would answer it and `defineProvider`
|
|
13
|
-
* refuses one on an mcp connector, because the SDK owns that exchange.
|
|
8
|
+
* Slack does not offer Dynamic Client Registration and is not going to: it
|
|
9
|
+
* would let a client authenticate a user without an app existing, and on
|
|
10
|
+
* Enterprise Grid an admin approves each app first. So a client has to be
|
|
11
|
+
* pre-registered — and the question this provider used to answer wrongly is
|
|
12
|
+
* *whose*.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* It was the operator's: create an app, transcribe sixteen user-token scopes,
|
|
15
|
+
* install it, paste the `xoxp-` it mints. That was the honest shape of it only
|
|
16
|
+
* while the alternative was believed impossible. It was not. Every client that
|
|
17
|
+
* reaches Slack without a console visit does the same thing — registers one app
|
|
18
|
+
* and ships its id — and this now does too, with the secret behind the broker
|
|
19
|
+
* ADR-028 already built for Google and the redirect on a port Slack has been
|
|
20
|
+
* told about. ADR-040 records what changed and why the reasoning in ADR-033 no
|
|
21
|
+
* longer holds.
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* except Google's, and that is the honest shape of it.
|
|
23
|
+
* The paste is still here, behind `--auth pasted_token`. A workspace whose admin has not
|
|
24
|
+
* approved the Lanes app cannot use the flow above, and that is not a decision
|
|
25
|
+
* the person running this command can make.
|
|
24
26
|
*/
|
|
25
27
|
export const slack = defineProvider({
|
|
26
28
|
id: 'slack',
|
|
27
29
|
name: 'Slack',
|
|
28
30
|
description: 'Messages, threads, channels, files, and canvases, via Slack\'s official MCP server.',
|
|
29
31
|
connector: { kind: 'mcp', endpoint: 'https://mcp.slack.com/mcp' },
|
|
30
|
-
auth: { kind: 'bearer' },
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
+
* An mcp connector that names its own endpoints, which is what takes it off
|
|
34
|
+
* the SDK's flow and onto the one this repository drives.
|
|
33
35
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Not an override of discovery for its own sake — Slack publishes perfectly
|
|
37
|
+
* good metadata at `/.well-known/oauth-authorization-server` and these two
|
|
38
|
+
* values are copied from it. It is that the SDK ends its flow by posting to
|
|
39
|
+
* the token endpoint with the client *it* holds, and the client here is held
|
|
40
|
+
* by a broker. Declaring the endpoints is how a manifest says the exchange is
|
|
41
|
+
* ours to route. See `defineProvider` and ADR-040.
|
|
42
|
+
*/
|
|
43
|
+
auth: {
|
|
44
|
+
kind: 'oauth',
|
|
45
|
+
registration: 'manual',
|
|
46
|
+
app: SLACK_APP,
|
|
47
|
+
authorize_url: 'https://slack.com/oauth/v2_user/authorize',
|
|
48
|
+
token_url: 'https://slack.com/api/oauth.v2.user.access',
|
|
49
|
+
scopes: [...SLACK_SCOPES],
|
|
50
|
+
broker: SLACK_BROKER,
|
|
51
|
+
/**
|
|
52
|
+
* Slack returns no refresh token, and that is the successful answer.
|
|
53
|
+
*
|
|
54
|
+
* A user token is long-lived unless token rotation is enabled on the app.
|
|
55
|
+
* Demanding one here would refuse every connection that worked — the
|
|
56
|
+
* default exists for Google, where a missing refresh token means the grant
|
|
57
|
+
* already existed and the connection would die in an hour.
|
|
58
|
+
*/
|
|
59
|
+
refresh_token: 'optional',
|
|
60
|
+
},
|
|
61
|
+
/**
|
|
62
|
+
* The person *and* the workspace, because either alone collides.
|
|
63
|
+
*
|
|
64
|
+
* `settleIdentity` matches a resolved account against existing connections to
|
|
65
|
+
* decide whether this is a reconnect or a new account, so the string has to
|
|
66
|
+
* be unique per credential. Neither half of `auth.test` is:
|
|
67
|
+
*
|
|
68
|
+
* - `team` alone — two people in one workspace look like one account, and
|
|
69
|
+
* the second connect overwrites the first's token.
|
|
70
|
+
* - `user` alone — Slack's "user" is a workspace-scoped handle, so one
|
|
71
|
+
* person in two workspaces looks like a reconnect and the second
|
|
72
|
+
* workspace overwrites the first. Connecting more than one workspace is
|
|
73
|
+
* the ordinary case here, which made this the more likely of the two.
|
|
74
|
+
*
|
|
75
|
+
* Together they are unique, and `alice (Acme)` is a row somebody can read.
|
|
39
76
|
*
|
|
40
77
|
* Slack answers a bad token with HTTP 200 and `{ok: false}`, so a wrong token
|
|
41
78
|
* reaches `connect`'s "which account is this?" fallback rather than a clear
|
|
42
79
|
* refusal. Discovery fails loudly one step later, which is where the real
|
|
43
80
|
* error is.
|
|
44
81
|
*/
|
|
45
|
-
identity: {
|
|
82
|
+
identity: {
|
|
83
|
+
kind: 'http',
|
|
84
|
+
url: 'https://slack.com/api/auth.test',
|
|
85
|
+
field: 'user',
|
|
86
|
+
qualifier: 'team',
|
|
87
|
+
},
|
|
46
88
|
redact: SLACK_REDACT,
|
|
89
|
+
/**
|
|
90
|
+
* Read only by `--auth pasted_token`. The browser route asks for nothing.
|
|
91
|
+
*
|
|
92
|
+
* `connection` scope rather than `shared` is what makes `--own-client` refuse
|
|
93
|
+
* with "no bring-your-own client path", which is true: this asks for a token,
|
|
94
|
+
* never for a client of the operator's to register.
|
|
95
|
+
*/
|
|
47
96
|
setup: {
|
|
48
97
|
summary:
|
|
49
|
-
'Slack needs
|
|
50
|
-
'
|
|
51
|
-
'
|
|
98
|
+
'Slack normally needs nothing set up — one browser round trip against the app Lanes ' +
|
|
99
|
+
'registered. Pasting a token is the way past a workspace whose admin has not approved ' +
|
|
100
|
+
'that app, using one from an app the workspace already trusts.',
|
|
52
101
|
docs: 'docs/detailed/setup/slack.md',
|
|
53
102
|
docs_url: 'https://api.slack.com/apps',
|
|
54
103
|
steps: [
|
|
55
|
-
'Open https://api.slack.com/apps and choose "Create New App" → "From scratch". Name it
|
|
56
|
-
'Open "OAuth & Permissions" and
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --replace.',
|
|
104
|
+
'Open https://api.slack.com/apps and choose "Create New App" → "From scratch". Name it and pick the workspace.',
|
|
105
|
+
'Open "OAuth & Permissions" and add the scopes you need under USER TOKEN SCOPES — not Bot Token Scopes; the MCP server reads the user token. The full set this provider asks for in the browser is listed in docs/detailed/setup/slack.md.',
|
|
106
|
+
'Choose "Install to Workspace" and approve. A Slack admin may have to approve it for you.',
|
|
107
|
+
'Copy the "User OAuth Token". It starts with xoxp- — not the bot token, which starts with xoxb- and will not work here.',
|
|
108
|
+
'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --profile personal --target local --auth pasted_token --replace.',
|
|
61
109
|
],
|
|
62
110
|
troubleshooting:
|
|
63
111
|
'Slack refused the token. The usual causes are a bot token (xoxb-) pasted where the user token (xoxp-) belongs, ' +
|
|
64
112
|
'a scope missing from USER TOKEN SCOPES, or an app that was reinstalled since — reinstalling mints a new token. ' +
|
|
65
|
-
'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --replace.',
|
|
113
|
+
'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --profile personal --target local --auth pasted_token --replace.',
|
|
66
114
|
prompts: [
|
|
67
115
|
{
|
|
68
116
|
key: 'token',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { AuthBroker } from '#connectivity';
|
|
2
|
+
import { brokerOriginOverride } from '#connectivity/auth/index.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where Slack's client comes from, and why it cannot come from here.
|
|
6
|
+
*
|
|
7
|
+
* Slack refuses Dynamic Client Registration and says so in its documentation.
|
|
8
|
+
* That is not an omission waiting to be filled: DCR would let a client
|
|
9
|
+
* authenticate a user without an app existing, and on Enterprise Grid an admin
|
|
10
|
+
* approves each app before it can authenticate anyone. Waiting for it is
|
|
11
|
+
* waiting for something that is not coming.
|
|
12
|
+
*
|
|
13
|
+
* So somebody has to be pre-registered. Until now that somebody was the
|
|
14
|
+
* operator, once per person, in a browser tab, transcribing sixteen scopes. It
|
|
15
|
+
* is this project instead now — one app, registered once, exactly the
|
|
16
|
+
* arrangement every other client that connects to Slack without a console visit
|
|
17
|
+
* uses. See ADR-040.
|
|
18
|
+
*/
|
|
19
|
+
export const SLACK_APP = 'slack';
|
|
20
|
+
|
|
21
|
+
const BROKER_ORIGIN = 'https://api.lanes.sh';
|
|
22
|
+
const BROKER_PATH = '/v1/auth/link/slack';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The client Lanes operates, reached at an origin an override can move.
|
|
26
|
+
*
|
|
27
|
+
* Same shape as Google's and for the same reasons — see
|
|
28
|
+
* `../google/shared/oauth.ts`. What differs is what a broker outage costs.
|
|
29
|
+
* Slack issues a long-lived user token and no refresh token unless token
|
|
30
|
+
* rotation is switched on for the app, so this is consulted at `connect` and
|
|
31
|
+
* never again: an outage here cannot interrupt an agent mid-request the way
|
|
32
|
+
* ADR-028 warned a shared dependency can. That is the whole of why the fallback
|
|
33
|
+
* below is a reasonable second answer rather than a necessary one.
|
|
34
|
+
*/
|
|
35
|
+
export function slackBroker(env?: Record<string, string | undefined>): AuthBroker {
|
|
36
|
+
return {
|
|
37
|
+
url: `${brokerOriginOverride(env) ?? BROKER_ORIGIN}${BROKER_PATH}`,
|
|
38
|
+
operator: 'Lanes',
|
|
39
|
+
docs_url: 'https://lanes.sh/link#slack',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const SLACK_BROKER: AuthBroker = slackBroker();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where Slack sends the browser back — and why it is not this machine.
|
|
47
|
+
*
|
|
48
|
+
* Slack refuses to register a Redirect URL that is not HTTPS. Verified against
|
|
49
|
+
* a real app: the field rejects `http://localhost:<port>/callback` outright, so
|
|
50
|
+
* a loopback listener cannot be named to Slack at all. A CLI cannot be HTTPS
|
|
51
|
+
* either — there is no certificate for 127.0.0.1 a browser will accept.
|
|
52
|
+
*
|
|
53
|
+
* So the redirect goes to the broker, which bounces it straight down to the
|
|
54
|
+
* listener `connect` opened, carrying the port in `state`. The URL itself is
|
|
55
|
+
* not written here: `/config` publishes it, because which one is correct
|
|
56
|
+
* depends on which deployment answered, and a broker running on loopback for a
|
|
57
|
+
* test would otherwise need its own spelling of it.
|
|
58
|
+
*
|
|
59
|
+
* The cost is that Slack cannot be connected without the broker. Google's is
|
|
60
|
+
* optional — a profile may register its own client and never call it — and
|
|
61
|
+
* Slack's is not, because the broker *is* the HTTPS origin. Recorded in
|
|
62
|
+
* ADR-040, and softened by Slack issuing no refresh token: an outage stops
|
|
63
|
+
* `connect`, never a connection already made.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the browser grant asks for.
|
|
68
|
+
*
|
|
69
|
+
* These are user-token scopes: Slack's MCP server reads the user token, and a
|
|
70
|
+
* bot token is a different credential that does not work there at all. The list
|
|
71
|
+
* is what the setup page used to ask the operator to transcribe by hand, moved
|
|
72
|
+
* to where it can be shown before consent instead — which is what restores the
|
|
73
|
+
* scope-disclosure gate ADR-033 recorded as permanently absent for Slack.
|
|
74
|
+
*
|
|
75
|
+
* The last four are what `reactions`, `canvases`, and channel creation need.
|
|
76
|
+
* They are requested rather than left out because the tools appear in the list
|
|
77
|
+
* either way and fail at call time without them, and a tool that is visible and
|
|
78
|
+
* always fails is worse than a scope on the consent screen. What an agent may
|
|
79
|
+
* actually call is bounded by policy, where `connect` grants read and nothing
|
|
80
|
+
* else by default.
|
|
81
|
+
*/
|
|
82
|
+
export const SLACK_SCOPES = [
|
|
83
|
+
'search:read.public',
|
|
84
|
+
'search:read.private',
|
|
85
|
+
'search:read.im',
|
|
86
|
+
'search:read.mpim',
|
|
87
|
+
'search:read.users',
|
|
88
|
+
'search:read.files',
|
|
89
|
+
'channels:history',
|
|
90
|
+
'groups:history',
|
|
91
|
+
'im:history',
|
|
92
|
+
'mpim:history',
|
|
93
|
+
'channels:read',
|
|
94
|
+
'groups:read',
|
|
95
|
+
'mpim:read',
|
|
96
|
+
'users:read',
|
|
97
|
+
'chat:write',
|
|
98
|
+
'files:read',
|
|
99
|
+
'reactions:write',
|
|
100
|
+
'canvases:read',
|
|
101
|
+
'canvases:write',
|
|
102
|
+
'channels:write',
|
|
103
|
+
] as const;
|