@lanes-sh/link 0.9.2 → 0.9.3
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 +10 -4
- package/instructions/skills/lanes-link/SKILL.md +73 -48
- package/package.json +1 -1
- package/src/auth/index.ts +127 -26
- package/src/cli/accepts.ts +13 -3
- package/src/cli/commands/connect/declare.ts +16 -7
- package/src/cli/commands/connect/index.ts +4 -4
- package/src/cli/commands/connect/settle.ts +33 -7
- package/src/cli/commands/connection-list.ts +25 -1
- package/src/cli/commands/mcp/harnesses.ts +36 -22
- package/src/cli/commands/mcp/register.ts +27 -8
- package/src/cli/commands/mcp/stdio.ts +0 -1
- package/src/cli/commands/operate/inspect.ts +21 -10
- package/src/cli/commands/operate/outputs.ts +94 -61
- package/src/cli/commands/operate/serve.ts +0 -4
- package/src/cli/commands/operate/token.ts +305 -35
- package/src/cli/commands/operate/tools.ts +28 -5
- package/src/cli/commands/operate.ts +7 -1
- package/src/cli/commands/profile/removal.ts +10 -9
- package/src/cli/config-repair-sweep.ts +18 -4
- package/src/cli/config-repair.ts +1 -1
- package/src/cli/config-templates.ts +14 -7
- package/src/cli/contract3-credentials.ts +8 -8
- package/src/cli/contract4.ts +7 -2
- package/src/cli/contract5.ts +234 -0
- package/src/cli/endpoint-url.ts +17 -3
- package/src/cli/main.ts +28 -4
- package/src/cli/publish.ts +13 -4
- package/src/cli/runtime/open.ts +19 -2
- package/src/cli/runtime/select.ts +0 -12
- package/src/cli/runtime.ts +0 -1
- package/src/cli/selection.ts +40 -10
- package/src/cli/usage.ts +12 -7
- package/src/cli/workspace-migrate.ts +18 -12
- package/src/connectivity/context.ts +17 -0
- package/src/connectivity/manifest/provider.ts +9 -1
- package/src/deployments/prepare.ts +8 -33
- package/src/deployments/report.ts +6 -3
- package/src/dispatch/context.ts +3 -0
- package/src/dispatch/dispatch.ts +5 -0
- package/src/profile/connections.ts +32 -0
- package/src/profile/index.ts +9 -0
- package/src/profile/schema.ts +47 -3
- package/src/profile/tokens.ts +137 -0
- package/src/profile/workspace.ts +1 -1
- package/src/providers/harness.ts +1 -0
- package/src/providers/setup/plan.ts +16 -0
- package/src/providers/setup/provider.ts +39 -12
- package/src/server/container.ts +3 -3
- package/src/server/endpoint.ts +17 -29
- package/src/server/harness.ts +28 -3
- package/src/server/index.ts +8 -8
- package/src/server/mcp/visibility.ts +11 -3
- package/src/server/read/deployed.ts +4 -0
- package/src/server/read/open.ts +4 -0
- package/src/server/read/routes.ts +15 -2
- package/src/server/read/state.ts +30 -2
package/src/server/endpoint.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
planReconcile,
|
|
19
19
|
toPolicyDocument,
|
|
20
20
|
} from '#registry';
|
|
21
|
-
import {
|
|
21
|
+
import { openRuntime, type GlobalFlags, type Runtime } from '#cli/runtime.ts';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Bringing the endpoint up: open a runtime per profile, reconcile, and serve.
|
|
@@ -40,8 +40,6 @@ import { ensureProfileToken, openRuntime, type GlobalFlags, type Runtime } from
|
|
|
40
40
|
export interface EndpointReporter {
|
|
41
41
|
/** A profile whose runtime state differed from its config, and what was applied. */
|
|
42
42
|
reconciled(input: { profile: string; plan: string; ofMany: boolean }): void;
|
|
43
|
-
/** No profile token existed and one was minted. */
|
|
44
|
-
tokenMinted(minted: { target: string }): void;
|
|
45
43
|
/**
|
|
46
44
|
* A sibling profile that could not be opened against this target.
|
|
47
45
|
*
|
|
@@ -51,7 +49,7 @@ export interface EndpointReporter {
|
|
|
51
49
|
skipped?(input: { profile: string; reason: string }): void;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
const SILENT: EndpointReporter = { reconciled() {}
|
|
52
|
+
const SILENT: EndpointReporter = { reconciled() {} };
|
|
55
53
|
|
|
56
54
|
/** The first line of an error, which is the part fit to print beside a name. */
|
|
57
55
|
function message(error: unknown): string {
|
|
@@ -64,11 +62,6 @@ export interface EndpointOptions {
|
|
|
64
62
|
readonly host?: string | undefined;
|
|
65
63
|
/** Serve just the resolved profile, rather than every profile in the workspace. */
|
|
66
64
|
readonly only?: boolean | undefined;
|
|
67
|
-
/**
|
|
68
|
-
* Mint a profile token when the store has none. True for `lanes link start`,
|
|
69
|
-
* false in a container — see the note above.
|
|
70
|
-
*/
|
|
71
|
-
readonly mintToken?: boolean | undefined;
|
|
72
65
|
readonly reporter?: EndpointReporter | undefined;
|
|
73
66
|
/** Operational events. Silent when absent, which is what the tests want. */
|
|
74
67
|
readonly log?: Logger | undefined;
|
|
@@ -203,26 +196,21 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
203
196
|
const { primary, runtimes } = await openReconciled(options);
|
|
204
197
|
|
|
205
198
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
199
|
+
// **No token is minted or required here any more** (ADR-068). Both halves of
|
|
200
|
+
// what used to be at this point are gone, and for the same reason.
|
|
201
|
+
//
|
|
202
|
+
// Minting: `start` did it because there was one token per endpoint and it
|
|
203
|
+
// had to exist for anything to connect. A token names the person it was
|
|
204
|
+
// issued to now, and `start` does not know who is about to connect — so
|
|
205
|
+
// inventing one would be binding a credential to a subject nobody chose.
|
|
206
|
+
//
|
|
207
|
+
// Refusing: a deployed revision used to fail to boot without one, on the
|
|
208
|
+
// reasoning that an endpoint whose token nobody holds is no use. Since
|
|
209
|
+
// ADR-062 that is backwards. A client discovers the protected-resource
|
|
210
|
+
// document, signs its owner in, and comes back with a token of its own; a
|
|
211
|
+
// static row is what CI uses because it has no browser. Zero rows is the
|
|
212
|
+
// ordinary state of a healthy endpoint, and refusing to serve was refusing
|
|
213
|
+
// the case the endpoint is now built for.
|
|
226
214
|
// Read through a holder rather than closed over `runtimes`, because a
|
|
227
215
|
// reload replaces that map and the gate is deliberately built once
|
|
228
216
|
// (ADR-029). Without the indirection, a member added after start would
|
package/src/server/harness.ts
CHANGED
|
@@ -53,6 +53,16 @@ function harnessConnections(config: Config): ConnectionConfig[] {
|
|
|
53
53
|
|
|
54
54
|
export const TEST_TOKEN = 'llk_test_token_value';
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The subject the harness's one token row is issued to.
|
|
58
|
+
*
|
|
59
|
+
* A real subject shape rather than a placeholder, because `subjectRef` validates
|
|
60
|
+
* it wherever a config carries one — and the harness's `profilesFor` returns
|
|
61
|
+
* every wired profile for it, which is what the static token used to reach by
|
|
62
|
+
* having no list at all.
|
|
63
|
+
*/
|
|
64
|
+
export const HARNESS_SUBJECT = 'lanes:harness0000';
|
|
65
|
+
|
|
56
66
|
/** A signed-in person no profile lists. See the federation stub below. */
|
|
57
67
|
export const STRANGER = 'NOBODY_LISTS_THIS_PERSON';
|
|
58
68
|
|
|
@@ -75,7 +85,7 @@ export function configFor(profile: string, port: number, policy: string): Config
|
|
|
75
85
|
` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
|
|
76
86
|
|
|
77
87
|
return parseConfig(`
|
|
78
|
-
contract:
|
|
88
|
+
contract: 5
|
|
79
89
|
instance:
|
|
80
90
|
profile: ${profile}
|
|
81
91
|
port: ${port}
|
|
@@ -133,6 +143,15 @@ export interface HarnessOptions {
|
|
|
133
143
|
config?: Config;
|
|
134
144
|
/** Extra profiles this one endpoint also serves, each with its own policy. */
|
|
135
145
|
alsoServe?: ReadonlyArray<{ profile: string; policy: string }>;
|
|
146
|
+
/**
|
|
147
|
+
* The profiles the static token's subject is a member of.
|
|
148
|
+
*
|
|
149
|
+
* Absent means every profile wired, which is what a workspace owner's token
|
|
150
|
+
* reaches. Naming fewer is how a test expresses a delegated member — the
|
|
151
|
+
* caller ADR-068 exists for, and the one every disclosure surface has to be
|
|
152
|
+
* checked against.
|
|
153
|
+
*/
|
|
154
|
+
reaches?: readonly string[];
|
|
136
155
|
/**
|
|
137
156
|
* What the endpoint calls to re-read skills before serving a request.
|
|
138
157
|
*
|
|
@@ -192,7 +211,9 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
192
211
|
|
|
193
212
|
const state = createMemoryState();
|
|
194
213
|
const audit = createBlobAuditStore({ storage: createMemoryBlobStore() });
|
|
195
|
-
|
|
214
|
+
// `tokens/tok1`, matching the row `startHarness` declares below. The old key
|
|
215
|
+
// was `profile/token`, the constant ADR-068 removed.
|
|
216
|
+
const credentials = createMemoryCredentials({ 'tokens/tok1': token, ...options.credentials });
|
|
196
217
|
// `allowReserved` for the same reason `buildRegistry` passes it: the owner
|
|
197
218
|
// layer claims `memory`, `skills`, and `vault`, and the guard still refuses
|
|
198
219
|
// them everywhere else.
|
|
@@ -267,10 +288,14 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
267
288
|
export function startHarness(options: HarnessOptions): Harness {
|
|
268
289
|
const { profiles, state, audit, dispatcher, credentials, token } = wireProfiles(options);
|
|
269
290
|
|
|
291
|
+
// One row, bound to a subject the harness's profiles list, so the static
|
|
292
|
+
// token resolves the way it does in production (ADR-068) rather than through
|
|
293
|
+
// a special case that would stop the tests covering the real path.
|
|
270
294
|
const bearer = new BearerAuthenticator({
|
|
271
295
|
profile: options.profile,
|
|
272
|
-
|
|
296
|
+
tokens: async () => [{ id: 'tok1', subject: HARNESS_SUBJECT, ref: 'tokens/tok1' }],
|
|
273
297
|
credentials,
|
|
298
|
+
profilesFor: async () => options.reaches ?? [...profiles.keys()],
|
|
274
299
|
});
|
|
275
300
|
|
|
276
301
|
// The real wiring from `endpoint.ts`, not a stand-in: the flow under test is
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { mayReach, type Authenticator } from '#auth';
|
|
2
2
|
import type { Logger } from '#connectivity';
|
|
3
3
|
import { capabilityIdForToolName } from '#server/mcp';
|
|
4
4
|
import { ATTACHMENTS_PATH, handleAttachments } from './attachments.ts';
|
|
@@ -186,18 +186,18 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
186
186
|
if (url.pathname === HEALTH_PATH) {
|
|
187
187
|
// `status` is unauthenticated because the platform's own probe reads it
|
|
188
188
|
// and a deploy waits on it. The profile *names* are not: on a public URL
|
|
189
|
-
// that is a list of what this endpoint holds, handed to anyone who asks
|
|
190
|
-
//
|
|
191
|
-
//
|
|
189
|
+
// that is a list of what this endpoint holds, handed to anyone who asks.
|
|
190
|
+
// And they are the *caller's* since ADR-068: this listed every profile
|
|
191
|
+
// served to anybody holding a credential, so a delegated member read the
|
|
192
|
+
// ones `mayReach` keeps out of their own enum.
|
|
192
193
|
const named = await options.authenticator.authenticate(
|
|
193
194
|
request.headers.get('authorization'),
|
|
194
195
|
);
|
|
195
|
-
|
|
196
|
+
const who = named.ok ? named.principal : null;
|
|
197
|
+
const mine = options.generations.current.names().filter((n) => who && mayReach(who, n));
|
|
196
198
|
return Response.json({
|
|
197
199
|
status: 'ok',
|
|
198
|
-
...(named.ok
|
|
199
|
-
? { profile: options.primary, profiles: options.generations.current.names() }
|
|
200
|
-
: {}),
|
|
200
|
+
...(named.ok ? { profile: options.primary, profiles: mine } : {}),
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -185,7 +185,15 @@ export function visibleToolCount(options: BuildServerOptions): number {
|
|
|
185
185
|
* entities: ordering is not selection, and a caller that cannot tell two
|
|
186
186
|
* candidates apart must be given what tells them apart.
|
|
187
187
|
*
|
|
188
|
-
* The
|
|
188
|
+
* **The key is the grant ref, not the bare id.** `connectionsOf` fills the enum
|
|
189
|
+
* from the grant rows (ADR-058), so `reachable` carries `gmail.con1`, and
|
|
190
|
+
* `accountsByProfile` keys on that same `ref` — one string from one source, so
|
|
191
|
+
* the two sides cannot drift. Keyed on `connection.id` instead it missed every
|
|
192
|
+
* lookup, and no served description carried an account at all, while
|
|
193
|
+
* `connection-choice.test.ts` passed bare ids in by hand and stayed green.
|
|
194
|
+
* Contract 4 is what turned that from cosmetic into a real loss: the ids used to
|
|
195
|
+
* carry the account and are `con1`, `con2` now, so the id says nothing about
|
|
196
|
+
* which mailbox it is and this annotation is the only thing that does.
|
|
189
197
|
*/
|
|
190
198
|
export function describeWithConnections(
|
|
191
199
|
description: string,
|
|
@@ -215,9 +223,9 @@ export function accountsByProfile(
|
|
|
215
223
|
|
|
216
224
|
for (const [name, runtime] of options.profiles) {
|
|
217
225
|
const rows = new Map<string, string>();
|
|
218
|
-
for (const { connection } of runtime.connections ?? []) {
|
|
226
|
+
for (const { ref, connection } of runtime.connections ?? []) {
|
|
219
227
|
rows.set(
|
|
220
|
-
|
|
228
|
+
ref,
|
|
221
229
|
connection.label === undefined
|
|
222
230
|
? connection.account
|
|
223
231
|
: `${connection.account} (${connection.label})`,
|
|
@@ -40,6 +40,10 @@ export function deployedReadDeps(input: {
|
|
|
40
40
|
audit: primary.audit,
|
|
41
41
|
connections: async () =>
|
|
42
42
|
(await readConnections(primary.resolution.workspaceRoot)).connections,
|
|
43
|
+
// The names the dashboard shows for a row nobody has labelled. From the
|
|
44
|
+
// registry rather than a catalogue, so the owner layer, the vendors and a
|
|
45
|
+
// workspace's own manifests are all named the same way.
|
|
46
|
+
providerName: (id) => primary.registry.manifest(id)?.name,
|
|
43
47
|
// Cached, unlike loopback's. `GcpSecretManagerStore` holds nothing between
|
|
44
48
|
// calls, so a dashboard polling `/state` would be one network round trip per
|
|
45
49
|
// poll and a stranger sending a wrong token one per request — which is the
|
package/src/server/read/open.ts
CHANGED
|
@@ -62,6 +62,10 @@ export async function openReadListener(
|
|
|
62
62
|
audit: primary.audit,
|
|
63
63
|
connections: async () =>
|
|
64
64
|
(await readConnections(primary.resolution.workspaceRoot)).connections,
|
|
65
|
+
// The names the dashboard shows for a row nobody has labelled. From the
|
|
66
|
+
// registry rather than a catalogue, so the owner layer, the vendors and a
|
|
67
|
+
// workspace's own manifests are all named the same way.
|
|
68
|
+
providerName: (id) => primary.registry.manifest(id)?.name,
|
|
65
69
|
// Read on every presentation, so `pair --rotate` takes effect on a
|
|
66
70
|
// running endpoint. Affordable here because the store is a local file;
|
|
67
71
|
// the deployed bind caches for exactly this reason. `refresh()` drops the
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { Logger } from '#connectivity';
|
|
2
2
|
import type { ProfileRuntime } from '../mcp/visibility.ts';
|
|
3
3
|
import type { PairingCredential } from './credential.ts';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
readState,
|
|
6
|
+
type ConnectionRow,
|
|
7
|
+
type ProviderNames,
|
|
8
|
+
type ReadEndpoint,
|
|
9
|
+
} from './state.ts';
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* The two routes a browser origin may read, and everything that guards them.
|
|
@@ -100,6 +105,14 @@ export interface ReadDeps {
|
|
|
100
105
|
*/
|
|
101
106
|
readonly connections: () => Promise<readonly ConnectionRow[]>;
|
|
102
107
|
readonly credential: PairingCredential;
|
|
108
|
+
/**
|
|
109
|
+
* What each provider is called, for the row nobody has labelled.
|
|
110
|
+
*
|
|
111
|
+
* Optional so a harness can omit it: absent, an unlabelled row reports a null
|
|
112
|
+
* label and its reader falls back to the id, which is what every reader did
|
|
113
|
+
* before. Both real binds pass their runtime's registry.
|
|
114
|
+
*/
|
|
115
|
+
readonly providerName?: ProviderNames | undefined;
|
|
103
116
|
/** What this endpoint says about itself. Fixed for the life of the bind. */
|
|
104
117
|
readonly endpoint: ReadEndpoint;
|
|
105
118
|
readonly allowedOrigins?: readonly string[] | undefined;
|
|
@@ -166,7 +179,7 @@ export async function readRoutes(request: Request, deps: ReadDeps): Promise<Resp
|
|
|
166
179
|
if (url.pathname === STATE_PATH) {
|
|
167
180
|
const rows = await deps.connections().catch(() => []);
|
|
168
181
|
return json(
|
|
169
|
-
readState(deps.workspace, deps.profiles(), rows, deps.endpoint),
|
|
182
|
+
readState(deps.workspace, deps.profiles(), rows, deps.endpoint, deps.providerName),
|
|
170
183
|
200,
|
|
171
184
|
cors(origin, allowed),
|
|
172
185
|
);
|
package/src/server/read/state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { allowedConnections } from '#policy';
|
|
2
|
+
import { defaultConnectionLabel } from '#profile';
|
|
2
3
|
import type { ProfileRuntime } from '../mcp/visibility.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,11 +20,20 @@ export interface ReadConnection {
|
|
|
19
20
|
readonly provider: string;
|
|
20
21
|
readonly id: string;
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
+
* What a reader should be shown: the operator's own word for it, or the name
|
|
24
|
+
* derived from the provider and the account when they never gave one.
|
|
23
25
|
*
|
|
24
26
|
* `gmail.ada_lovelace` is an address, not a name. A dashboard listing refs is
|
|
25
27
|
* asking somebody to read identifiers when they gave the thing a label
|
|
26
28
|
* precisely so they would not have to.
|
|
29
|
+
*
|
|
30
|
+
* **Filled rather than left null**, which reverses what this said. Null meant
|
|
31
|
+
* "they never gave one" and every consumer answered it the same way, by
|
|
32
|
+
* showing the id — so a dashboard's whole Label column read `con8`, `lan7`,
|
|
33
|
+
* `con5`. `connect` writes no label that only repeats the lines above it
|
|
34
|
+
* (`declareConnection`), so an unlabelled row is the ordinary case and not an
|
|
35
|
+
* omission worth reporting. `null` survives for the row nothing can name: a
|
|
36
|
+
* grant pointing at a connection the workspace no longer holds.
|
|
27
37
|
*/
|
|
28
38
|
readonly label: string | null;
|
|
29
39
|
/** The identity the provider reported at connect time. */
|
|
@@ -32,6 +42,20 @@ export interface ReadConnection {
|
|
|
32
42
|
readonly profiles: readonly string[];
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
/**
|
|
46
|
+
* What a provider is called, asked of whoever built the registry.
|
|
47
|
+
*
|
|
48
|
+
* A function rather than a catalogue, because `server` may not import
|
|
49
|
+
* `#providers` — the read surface has no business knowing the vendor list, and
|
|
50
|
+
* the rule that says so is `architecture.test.ts`. Both binds already hold a
|
|
51
|
+
* `Runtime`, whose registry names the owner layer, the catalogue and a
|
|
52
|
+
* workspace's own manifests alike, so the caller passes a closure over that.
|
|
53
|
+
*
|
|
54
|
+
* Defaulted to naming nothing. A caller that supplies none gets `label: null`
|
|
55
|
+
* on an unlabelled row, which is what every reader saw before this existed.
|
|
56
|
+
*/
|
|
57
|
+
export type ProviderNames = (provider: string) => string | undefined;
|
|
58
|
+
|
|
35
59
|
/** The connection rows as `connections.yaml` holds them. */
|
|
36
60
|
export interface ConnectionRow {
|
|
37
61
|
readonly provider: string;
|
|
@@ -97,6 +121,7 @@ export function readState(
|
|
|
97
121
|
profiles: ReadonlyMap<string, ProfileRuntime>,
|
|
98
122
|
rows: readonly ConnectionRow[],
|
|
99
123
|
endpoint: ReadEndpoint,
|
|
124
|
+
providerName: ProviderNames = () => undefined,
|
|
100
125
|
): ReadState {
|
|
101
126
|
// The workspace's own list is the source of truth for *which connections
|
|
102
127
|
// exist*. Deriving it from the grants instead made an account that no profile
|
|
@@ -138,11 +163,14 @@ export function readState(
|
|
|
138
163
|
|
|
139
164
|
const connections: ReadConnection[] = rows.map((row) => {
|
|
140
165
|
const ref = `${row.provider}.${row.id}`;
|
|
166
|
+
const named = providerName(row.provider);
|
|
141
167
|
return {
|
|
142
168
|
ref,
|
|
143
169
|
provider: row.provider,
|
|
144
170
|
id: row.id,
|
|
145
|
-
|
|
171
|
+
// The same rule `connect` offers and `connection list` prints, so one
|
|
172
|
+
// connection is called one thing wherever somebody reads it.
|
|
173
|
+
label: row.label ?? (named ? defaultConnectionLabel(named, row.account) : null),
|
|
146
174
|
account: row.account ?? null,
|
|
147
175
|
profiles: grantedBy.get(ref) ?? [],
|
|
148
176
|
};
|