@lanes-sh/link 0.9.1 → 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/agents/lanes-link-scout.md +3 -3
- package/instructions/skills/lanes-link/SKILL.md +95 -57
- 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 +63 -16
- 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/adapters/audit-blob.ts +22 -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
|
@@ -109,7 +109,15 @@ export const RESERVED_PROVIDER_IDS: readonly string[] = [
|
|
|
109
109
|
'lanes_entities',
|
|
110
110
|
];
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Old id to new, for a refusal that can name what a stale client is asking for.
|
|
114
|
+
*
|
|
115
|
+
* Nothing consumes it yet. The migration builds its own map from
|
|
116
|
+
* `C3_OWNER_PROVIDERS` (`src/cli/contract4-rename.ts`), and a `tools/call` on a
|
|
117
|
+
* pre-0.9.0 name is answered by the SDK's exact-match lookup before anything
|
|
118
|
+
* here sees it — so the refusal this exists for is still unwritten. ADR-066
|
|
119
|
+
* records the failure it would address.
|
|
120
|
+
*/
|
|
113
121
|
export const RENAMED_OWNER_PROVIDERS: ReadonlyMap<string, string> = new Map(
|
|
114
122
|
RESERVED_PROVIDER_IDS.map((id) => [id.slice('lanes_'.length), id]),
|
|
115
123
|
);
|
|
@@ -59,6 +59,25 @@ const TAIL_YEAR_WINDOW = 10;
|
|
|
59
59
|
|
|
60
60
|
const MARKER_PREFIX = 'runs.closed/';
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether a key is something this store wrote, rather than something the OS did.
|
|
64
|
+
*
|
|
65
|
+
* `verify` enumerates the whole prefix and feeds every non-marker key to the
|
|
66
|
+
* chain, so a `.DS_Store` that Finder dropped in the audit directory arrived as
|
|
67
|
+
* a record, failed to decode, and was reported as `malformed run ? at seq -1` —
|
|
68
|
+
* the whole log **BROKEN** because somebody opened the folder. Seen on a real
|
|
69
|
+
* workspace, where the file had ridden through two contract migrations.
|
|
70
|
+
*
|
|
71
|
+
* A dotfile is the narrowest rule that covers it: no key this store writes
|
|
72
|
+
* begins with a dot, in either segment. Deliberately not "does it look like an
|
|
73
|
+
* event" — a corrupt event must still fail loudly, because reporting `ok` for a
|
|
74
|
+
* record it could not read is the one thing `verify` must never do.
|
|
75
|
+
*/
|
|
76
|
+
function isOurs(key: string): boolean {
|
|
77
|
+
return !key.split('/').some((segment) => segment.startsWith('.'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
62
81
|
export interface BlobAuditOptions {
|
|
63
82
|
/** Scoped to the audit root — `data/<profile>/audit.log` or its bucket prefix. */
|
|
64
83
|
readonly storage: BlobStore;
|
|
@@ -113,7 +132,7 @@ export function createBlobAuditStore(options: BlobAuditOptions): AuditStore {
|
|
|
113
132
|
for (let year = start; year >= floor && found.length < limit; year -= 1) {
|
|
114
133
|
const keys = (await storage.list(`${year}/`))
|
|
115
134
|
.map((entry) => entry.key)
|
|
116
|
-
.filter((key) => !key.startsWith(MARKER_PREFIX))
|
|
135
|
+
.filter((key) => !key.startsWith(MARKER_PREFIX) && isOurs(key))
|
|
117
136
|
// Keys are compact ISO within a day and the day is in the path, so
|
|
118
137
|
// lexicographic order is chronological — no parsing to sort.
|
|
119
138
|
.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
|
@@ -160,6 +179,8 @@ export function createBlobAuditStore(options: BlobAuditOptions): AuditStore {
|
|
|
160
179
|
const markers: RunMarker[] = [];
|
|
161
180
|
|
|
162
181
|
for (const entry of await storage.list('')) {
|
|
182
|
+
if (!isOurs(entry.key)) continue;
|
|
183
|
+
|
|
163
184
|
const bytes = await storage.get(entry.key);
|
|
164
185
|
if (bytes === null) continue;
|
|
165
186
|
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
readConnections, listProfiles, loadProfileConfig, vaultRef, type Config, type TargetConfig } from '#profile';
|
|
5
5
|
import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
|
|
6
6
|
import { ok, print, style, warn } from '#cli/output.ts';
|
|
7
|
-
import { buildRegistryWithWorkspace
|
|
7
|
+
import { buildRegistryWithWorkspace } from '#cli/runtime.ts';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Getting the target's credential store to the state a revision can boot from.
|
|
@@ -198,7 +198,6 @@ export async function readableRefs(
|
|
|
198
198
|
continue;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
refs.add(config.auth.token_ref);
|
|
202
201
|
if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
|
|
203
202
|
// The OIDC audience check reads this on every verify (`server/endpoint.ts`).
|
|
204
203
|
if (config.auth.authorization?.mode === 'oidc') {
|
|
@@ -209,6 +208,13 @@ export async function readableRefs(
|
|
|
209
208
|
|
|
210
209
|
// Once, for the reason `rotatableRefs` gives.
|
|
211
210
|
const connectionsFile = await readConnections(root);
|
|
211
|
+
|
|
212
|
+
// The endpoint's own tokens, which are the workspace's rather than any
|
|
213
|
+
// profile's since ADR-068. Pushed so a deployed revision can authenticate a
|
|
214
|
+
// headless caller; the *rows* travel with `connections.yaml`, so a ref here
|
|
215
|
+
// without its value is a row that matches nothing and reads to the client
|
|
216
|
+
// exactly like a wrong token.
|
|
217
|
+
for (const issued of connectionsFile.tokens) refs.add(issued.ref);
|
|
212
218
|
const registry = await buildRegistryWithWorkspace(root);
|
|
213
219
|
|
|
214
220
|
for (const connection of connectionsFile.connections) {
|
|
@@ -227,7 +233,6 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
|
|
|
227
233
|
const blocking: string[] = [];
|
|
228
234
|
const warnings: string[] = [];
|
|
229
235
|
|
|
230
|
-
await seedProfileToken({ config, credentials, readOnly, blocking });
|
|
231
236
|
await seedVaultKey({ declared: input.declared, credentials, readOnly });
|
|
232
237
|
|
|
233
238
|
// Connection credentials are written by `connect`, against a real account, in
|
|
@@ -280,34 +285,4 @@ async function seedVaultKey(input: {
|
|
|
280
285
|
print(ok(`minted the vault key at "${VAULT_KEY_REF}" — the revision reads it from there`));
|
|
281
286
|
}
|
|
282
287
|
|
|
283
|
-
/**
|
|
284
|
-
* The endpoint's own bearer token.
|
|
285
|
-
*
|
|
286
|
-
* Minted rather than demanded, and not even asked about: it is a random string
|
|
287
|
-
* this process generates correctly and the operator cannot usefully choose, so a
|
|
288
|
-
* prompt would be a question with one answer. `ensureProfileToken` is the same
|
|
289
|
-
* call `outputs` makes for a local target, which is what keeps "the token" one
|
|
290
|
-
* thing rather than a per-command notion.
|
|
291
|
-
*
|
|
292
|
-
* The deployed container deliberately will *not* do this — a token invented
|
|
293
|
-
* inside something that scales to zero is a token nobody can read back, and the
|
|
294
|
-
* endpoint would come up healthy while rejecting every agent.
|
|
295
|
-
*/
|
|
296
|
-
async function seedProfileToken(input: {
|
|
297
|
-
config: Config;
|
|
298
|
-
credentials: SecretStore;
|
|
299
|
-
readOnly: boolean;
|
|
300
|
-
blocking: string[];
|
|
301
|
-
}): Promise<void> {
|
|
302
|
-
const ref = input.config.auth.token_ref;
|
|
303
|
-
if (await input.credentials.has(ref)) return;
|
|
304
|
-
|
|
305
|
-
if (input.readOnly) {
|
|
306
|
-
input.blocking.push(`the endpoint bearer token — nothing at "${ref}"`);
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const { created } = await ensureProfileToken(input.credentials, ref);
|
|
311
|
-
if (created) print(ok(`minted an endpoint token at "${ref}" — read it with lanes link token show`));
|
|
312
|
-
}
|
|
313
288
|
|
|
@@ -22,14 +22,14 @@ import { heading, ok, print, style, warn } from '#cli/output.ts';
|
|
|
22
22
|
* `tools/list` when it connects and keeps it: this endpoint is stateless, so
|
|
23
23
|
* there is no stream on which to send `notifications/tools/list_changed`, and
|
|
24
24
|
* `buildMcpServer` no longer pretends otherwise. A first deploy necessarily
|
|
25
|
-
* publishes a profile whose only connection is `
|
|
25
|
+
* publishes a profile whose only connection is `lanes_setup.lan1` — the accounts come
|
|
26
26
|
* after — so a connector registered in that window captures a two-tool surface
|
|
27
27
|
* and holds it. The endpoint is right, every reload lands, and the client shows
|
|
28
28
|
* two tools until someone removes and re-adds it.
|
|
29
29
|
*
|
|
30
30
|
* Unconditional, and that is the correction that matters. This was gated on
|
|
31
31
|
* `prepared.warnings.length`, which is zero in precisely the case it describes:
|
|
32
|
-
* a fresh profile declares only `
|
|
32
|
+
* a fresh profile declares only `lanes_setup.lan1`, and it is a local provider with
|
|
33
33
|
* no credential, so `prepareSecrets` has nothing to warn about. The advice
|
|
34
34
|
* appeared only on a later re-deploy, by which point the connector is usually
|
|
35
35
|
* registered and the ordering is no longer available to get right.
|
|
@@ -39,7 +39,10 @@ export function registerLine(profile: string, target: string): string {
|
|
|
39
39
|
` Connect your accounts first, then register with:\n` +
|
|
40
40
|
` lanes link outputs --profile ${profile} --workspace ${target}\n` +
|
|
41
41
|
' A client keeps the tool list it fetched when it connected, so one registered\n' +
|
|
42
|
-
' before the accounts holds a surface without them until it is re-added
|
|
42
|
+
' before the accounts holds a surface without them until it is re-added.\n' +
|
|
43
|
+
' One registered before this deploy holds the list from before it, so a version\n' +
|
|
44
|
+
' that renamed a provider or an id leaves it calling names that are gone:\n' +
|
|
45
|
+
' lanes link mcp add',
|
|
43
46
|
);
|
|
44
47
|
}
|
|
45
48
|
|
package/src/dispatch/context.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface BuildContextOptions {
|
|
|
131
131
|
readonly authorize?: ((request: Request) => Promise<Request>) | undefined;
|
|
132
132
|
/** `oauth_apps` entries this profile declares. See `resolveSecretRefs`. */
|
|
133
133
|
readonly ownClients?: readonly string[] | undefined;
|
|
134
|
+
/** Every profile the caller may reach. See `ProviderContext.profiles`. */
|
|
135
|
+
readonly profiles: readonly string[];
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
export function buildProviderContext(options: BuildContextOptions): ProviderContext {
|
|
@@ -146,6 +148,7 @@ export function buildProviderContext(options: BuildContextOptions): ProviderCont
|
|
|
146
148
|
|
|
147
149
|
return {
|
|
148
150
|
connection: info,
|
|
151
|
+
profiles: options.profiles,
|
|
149
152
|
state: createScopedStore(options.state, namespace),
|
|
150
153
|
storage: scopeBlobStore(options.storage, namespace),
|
|
151
154
|
credentials: scopeSecrets(
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -271,6 +271,11 @@ export class Dispatcher {
|
|
|
271
271
|
// provider that could be brokered but is not reads its client from the
|
|
272
272
|
// store, so it has to be able to.
|
|
273
273
|
ownClients: this.#deps.oauthApps,
|
|
274
|
+
// The *caller's* set, not the workspace's. `undefined` means
|
|
275
|
+
// unrestricted — the stdio pipe — and the profile in play is the only
|
|
276
|
+
// honest answer a dispatcher can give for it without knowing what the
|
|
277
|
+
// endpoint is serving.
|
|
278
|
+
profiles: request.principal.profiles ?? [request.principal.profile],
|
|
274
279
|
...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
|
|
275
280
|
});
|
|
276
281
|
|
|
@@ -20,6 +20,38 @@ export function connectionRefOf(connection: ConnectionConfig): string {
|
|
|
20
20
|
return `${connection.provider}.${connection.id}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* What to call a connection nobody has named.
|
|
25
|
+
*
|
|
26
|
+
* `label` is the operator's own word for a row and is usually unset: `connect`
|
|
27
|
+
* offers a default and does not write one that only repeats a line above it, so
|
|
28
|
+
* most rows are an id, a provider and an account and nothing else. Every reader
|
|
29
|
+
* then has to decide what to show, and each of them picked the id — which is the
|
|
30
|
+
* one field that says nothing. It is opaque on purpose (`nextConnectionId`), so
|
|
31
|
+
* `con8` tells a reader strictly less about a row than any other line of it.
|
|
32
|
+
*
|
|
33
|
+
* The provider is what somebody is asking about, and the account is what tells
|
|
34
|
+
* two rows of the same provider apart, so the name is both: `Gmail (ada)`. The
|
|
35
|
+
* local part only — every address a person holds at one domain repeats it, and
|
|
36
|
+
* the account itself is on the line beside this everywhere this is shown.
|
|
37
|
+
*
|
|
38
|
+
* A connection with no account behind it is its provider and nothing else. That
|
|
39
|
+
* is the owner layer, whose rows carry the proper noun in `account` already, so
|
|
40
|
+
* composing the two would read `Memory (Memory)`.
|
|
41
|
+
*/
|
|
42
|
+
export function defaultConnectionLabel(
|
|
43
|
+
providerName: string,
|
|
44
|
+
account: string | null | undefined,
|
|
45
|
+
): string {
|
|
46
|
+
if (!account || account === providerName) return providerName;
|
|
47
|
+
|
|
48
|
+
// Split on `@` and nothing else. A handle, an IBAN or a workspace name has no
|
|
49
|
+
// "first part" that is safe to guess at: `Lanes HQ` cut at the space is
|
|
50
|
+
// `Lanes`, which is a different workspace's name as often as not.
|
|
51
|
+
const local = account.split('@')[0];
|
|
52
|
+
return `${providerName} (${local || account})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
/**
|
|
24
56
|
* One account this profile reaches, with the rules that govern it.
|
|
25
57
|
*
|
package/src/profile/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export {
|
|
|
46
46
|
assertGrantsResolve,
|
|
47
47
|
assertNoRenamedProviders,
|
|
48
48
|
connectionRefOf,
|
|
49
|
+
defaultConnectionLabel,
|
|
49
50
|
selectConnections,
|
|
50
51
|
soleGrantFor,
|
|
51
52
|
vaultRef,
|
|
@@ -121,6 +122,14 @@ export {
|
|
|
121
122
|
type ResolvedTarget,
|
|
122
123
|
} from './registry.ts';
|
|
123
124
|
export { recordTarget, removeTarget } from './deployments.ts';
|
|
125
|
+
export {
|
|
126
|
+
anyIssuedToken,
|
|
127
|
+
membersResolver,
|
|
128
|
+
nextTokenId,
|
|
129
|
+
readEndpointTokens,
|
|
130
|
+
tokenRef,
|
|
131
|
+
type EndpointToken,
|
|
132
|
+
} from './tokens.ts';
|
|
124
133
|
export {
|
|
125
134
|
isRemoteWorkspace,
|
|
126
135
|
readWorkspaceFile,
|
package/src/profile/schema.ts
CHANGED
|
@@ -59,11 +59,22 @@ import { knowledgeTargetSchema } from './knowledge.ts';
|
|
|
59
59
|
* declaration moved inside it. No file's *shape* changed: `grants:`,
|
|
60
60
|
* `members:` and `connections.yaml` are untouched, and only the paths moved.
|
|
61
61
|
*
|
|
62
|
+
* **5 made the endpoint token a person's rather than a profile's** (ADR-068).
|
|
63
|
+
* `auth.token_ref` defaulted to the constant `profile/token` for every profile
|
|
64
|
+
* out of a credential store that is one per workspace, so the "profile's token"
|
|
65
|
+
* was the workspace's wearing a per-profile name — which is why removing one
|
|
66
|
+
* profile once deleted the token its siblings were served by. It moves to
|
|
67
|
+
* `tokens:` in `connections.yaml`, one row per issued token, each naming the
|
|
68
|
+
* Lanes subject it was issued to. What that buys is the thing a bearer token
|
|
69
|
+
* could not do: it resolves through `members:` like an OAuth token does, so a
|
|
70
|
+
* credential says who you are and the profiles follow from that rather than
|
|
71
|
+
* from which profile happened to mint it.
|
|
72
|
+
*
|
|
62
73
|
* A hard cut each time, and only the newest is read here. `./legacy.ts`
|
|
63
74
|
* understands the older shapes and only the migration uses it — a runtime that
|
|
64
75
|
* loaded either would be the two-sources-of-truth problem again, one level up.
|
|
65
76
|
*/
|
|
66
|
-
export const SUPPORTED_CONTRACT =
|
|
77
|
+
export const SUPPORTED_CONTRACT = 5;
|
|
67
78
|
|
|
68
79
|
/**
|
|
69
80
|
* There is no `database:` block any more.
|
|
@@ -447,6 +458,29 @@ export const oauthAppSchema = z.object({
|
|
|
447
458
|
client_secret_ref: credentialRef,
|
|
448
459
|
});
|
|
449
460
|
|
|
461
|
+
/**
|
|
462
|
+
* One static endpoint token, and the person it was issued to.
|
|
463
|
+
*
|
|
464
|
+
* **`subject` is the whole point** (ADR-068). A bearer token used to resolve to
|
|
465
|
+
* the primary profile's owner and reach every profile in the workspace, which
|
|
466
|
+
* made it the one credential here that answered "what may I open" without ever
|
|
467
|
+
* answering "who are you". It names a Lanes subject now, and the profiles it
|
|
468
|
+
* reaches are the ones whose `members:` list that subject — the same resolution
|
|
469
|
+
* an OAuth token has gone through since ADR-060.
|
|
470
|
+
*
|
|
471
|
+
* The value is never here. `ref` points into the workspace's credential store,
|
|
472
|
+
* which is what keeps `findSecrets` from having anything to find in this file.
|
|
473
|
+
*/
|
|
474
|
+
export const endpointTokenSchema = z.object({
|
|
475
|
+
id: identifier,
|
|
476
|
+
subject: subjectRef,
|
|
477
|
+
ref: credentialRef,
|
|
478
|
+
/** What it is for, in a word — `ci`, `runner`. Free text, shown in listings. */
|
|
479
|
+
label: z.string().min(1).optional(),
|
|
480
|
+
/** ISO 8601, written when the row is created. Reported, never compared. */
|
|
481
|
+
issued_at: z.string().min(1).optional(),
|
|
482
|
+
});
|
|
483
|
+
|
|
450
484
|
|
|
451
485
|
/**
|
|
452
486
|
* One profile: who it is, what it reaches, and what it may do.
|
|
@@ -473,7 +507,6 @@ export const configSchema = z.object({
|
|
|
473
507
|
auth: z
|
|
474
508
|
.object({
|
|
475
509
|
mode: z.literal('bearer').default('bearer'),
|
|
476
|
-
token_ref: credentialRef.default('profile/token'),
|
|
477
510
|
/**
|
|
478
511
|
* Additive. `mode` above still describes what the endpoint accepts on the
|
|
479
512
|
* wire — a bearer token — and this describes where a *remote* client can
|
|
@@ -490,7 +523,7 @@ export const configSchema = z.object({
|
|
|
490
523
|
*/
|
|
491
524
|
allowed_origins: z.array(browserOrigin).optional(),
|
|
492
525
|
})
|
|
493
|
-
.default({ mode: 'bearer'
|
|
526
|
+
.default({ mode: 'bearer' }),
|
|
494
527
|
|
|
495
528
|
limits: z
|
|
496
529
|
.object({
|
|
@@ -575,12 +608,23 @@ export const connectionsFileSchema = z.object({
|
|
|
575
608
|
contract: z.number().int().positive(),
|
|
576
609
|
connections: z.array(connectionSchema).default([]),
|
|
577
610
|
oauth_apps: z.record(identifier, oauthAppSchema).default({}),
|
|
611
|
+
/**
|
|
612
|
+
* The static tokens this workspace has issued (ADR-068).
|
|
613
|
+
*
|
|
614
|
+
* Here rather than in a file of their own because this is already where the
|
|
615
|
+
* workspace keeps what is not a profile's and not an account's — `oauth_apps`
|
|
616
|
+
* is the precedent, and it is credential-referencing in the same way. A
|
|
617
|
+
* workspace that has issued none has no key, which is what lets an untouched
|
|
618
|
+
* file parse.
|
|
619
|
+
*/
|
|
620
|
+
tokens: z.array(endpointTokenSchema).default([]),
|
|
578
621
|
});
|
|
579
622
|
|
|
580
623
|
export type ConnectionsFile = z.infer<typeof connectionsFileSchema>;
|
|
581
624
|
|
|
582
625
|
export type Config = z.infer<typeof configSchema>;
|
|
583
626
|
export type ConnectionConfig = z.infer<typeof connectionSchema>;
|
|
627
|
+
export type EndpointToken = z.infer<typeof endpointTokenSchema>;
|
|
584
628
|
export type GrantConfig = z.infer<typeof grantSchema>;
|
|
585
629
|
export type MemberConfig = z.infer<typeof memberSchema>;
|
|
586
630
|
export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { loadWorkspaceProfiles, readConnections } from './workspace.ts';
|
|
2
|
+
import type { EndpointToken } from './schema.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The endpoint's static tokens, which belong to the workspace (ADR-068).
|
|
6
|
+
*
|
|
7
|
+
* **Why this is not a field on a profile any more.** `auth.token_ref` defaulted
|
|
8
|
+
* to the constant `profile/token` for every profile, out of a credential store
|
|
9
|
+
* that has been one per workspace since ADR-057 — so naming a profile to find
|
|
10
|
+
* the endpoint's token asked a question with one answer, and every command whose
|
|
11
|
+
* subject is the endpoint had to ask it. `profile/removal.ts` records the
|
|
12
|
+
* sharper consequence: removing one profile deleted the token its siblings were
|
|
13
|
+
* being served by, and the deployed revision then refused every request.
|
|
14
|
+
*
|
|
15
|
+
* **A row names a person.** That is the part that changes behaviour rather than
|
|
16
|
+
* merely moving a file. A bearer token used to resolve to `ownerPrincipal` of
|
|
17
|
+
* whichever profile was primary, with `profiles: undefined` — "all of them" —
|
|
18
|
+
* so it was the one credential on this endpoint that answered *what may I open*
|
|
19
|
+
* without ever answering *who are you*. A row carries a Lanes subject, so the
|
|
20
|
+
* token resolves through the profile's `members:` exactly as an OAuth token has
|
|
21
|
+
* since ADR-060, and `mayReach` needs no special case for it.
|
|
22
|
+
*
|
|
23
|
+
* The value is never in the file. A row holds a `ref` into the workspace's
|
|
24
|
+
* credential store, which is what keeps `findSecrets` with nothing to find.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Where a row's value lives in the workspace credential store. */
|
|
28
|
+
export function tokenRef(id: string): string {
|
|
29
|
+
return `tokens/${id}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The next free row id, `tok1` upward.
|
|
34
|
+
*
|
|
35
|
+
* Same shape as `nextConnectionId` and opaque for the same reason: the id ends
|
|
36
|
+
* up in a listing and in a credential path, and an id derived from the label or
|
|
37
|
+
* the subject would either collide or leak. `label` is the field for saying what
|
|
38
|
+
* a token is for.
|
|
39
|
+
*/
|
|
40
|
+
export function nextTokenId(taken: readonly string[]): string {
|
|
41
|
+
let highest = 0;
|
|
42
|
+
for (const id of taken) {
|
|
43
|
+
const match = /^tok([0-9]+)$/.exec(id);
|
|
44
|
+
if (match) highest = Math.max(highest, Number(match[1]));
|
|
45
|
+
}
|
|
46
|
+
return `tok${highest + 1}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every token this workspace has issued.
|
|
51
|
+
*
|
|
52
|
+
* Read through `readConnections` rather than the filesystem, so a deployed
|
|
53
|
+
* revision whose root is a bucket URL loads them (ADR-049). A workspace that has
|
|
54
|
+
* issued none returns empty, which is not an error: it is what a fresh install
|
|
55
|
+
* looks like, and what a workspace looks like after `token revoke` removes the
|
|
56
|
+
* last row.
|
|
57
|
+
*/
|
|
58
|
+
export async function readEndpointTokens(
|
|
59
|
+
workspaceRoot: string,
|
|
60
|
+
): Promise<readonly EndpointToken[]> {
|
|
61
|
+
return (await readConnections(workspaceRoot)).tokens;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type { EndpointToken };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Which profiles in this workspace list a subject as a member.
|
|
68
|
+
*
|
|
69
|
+
* The rule is ADR-060's and is stated once: a caller reaches a profile if that
|
|
70
|
+
* profile's `members:` names them. `server/endpoint.ts` answers the same
|
|
71
|
+
* question from its live runtime map when an OAuth code is minted; this answers
|
|
72
|
+
* it from disk, which is what a static token needs because it has no mint to be
|
|
73
|
+
* resolved at. Both are bounded by what the endpoint is actually serving —
|
|
74
|
+
* `visibility.ts` iterates the served profiles, so a name returned here that is
|
|
75
|
+
* not being served is filtered out rather than reachable.
|
|
76
|
+
*
|
|
77
|
+
* **Cached for the same window the token values are.** Without it a deployed
|
|
78
|
+
* revision read every profile's YAML out of a bucket on every request. The
|
|
79
|
+
* window is why `profile members remove` takes effect in seconds rather than on
|
|
80
|
+
* the next rotation, which is the property worth keeping.
|
|
81
|
+
*/
|
|
82
|
+
export function membersResolver(
|
|
83
|
+
workspaceRoot: string,
|
|
84
|
+
options: { readonly ttlMs?: number; readonly now?: () => number } = {},
|
|
85
|
+
): (subject: string) => Promise<readonly string[]> {
|
|
86
|
+
const ttl = options.ttlMs ?? 5_000;
|
|
87
|
+
const now = options.now ?? Date.now;
|
|
88
|
+
|
|
89
|
+
let cached: ReadonlyMap<string, readonly string[]> | null = null;
|
|
90
|
+
let readAt = 0;
|
|
91
|
+
|
|
92
|
+
return async (subject) => {
|
|
93
|
+
if (cached === null || now() - readAt >= ttl) {
|
|
94
|
+
const { loaded } = await loadWorkspaceProfiles(workspaceRoot);
|
|
95
|
+
const bySubject = new Map<string, string[]>();
|
|
96
|
+
for (const entry of loaded) {
|
|
97
|
+
for (const member of entry.config.members) {
|
|
98
|
+
const known = bySubject.get(member.subject) ?? [];
|
|
99
|
+
known.push(entry.profile);
|
|
100
|
+
bySubject.set(member.subject, known);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cached = bySubject;
|
|
104
|
+
readAt = now();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return cached.get(subject) ?? [];
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Any token this workspace holds, for talking to its own endpoint.
|
|
113
|
+
*
|
|
114
|
+
* `outputs`, `tools`, `doctor` and the reload notification all need *a* valid
|
|
115
|
+
* credential to ask the endpoint something, and none of them cares whose: they
|
|
116
|
+
* are the operator's own commands run against the operator's own workspace.
|
|
117
|
+
* Which row answers is therefore not a choice worth making visible — unlike
|
|
118
|
+
* `token show`, where it is the whole subject of the command and `--id` is
|
|
119
|
+
* required once there is more than one.
|
|
120
|
+
*
|
|
121
|
+
* **Null is an ordinary answer, not a failure.** A workspace that has issued
|
|
122
|
+
* none is the common case after ADR-062: a person's client registers against
|
|
123
|
+
* the bare URL and signs in, so nothing needs a static token until something
|
|
124
|
+
* headless does. Every caller degrades — reporting what it could not ask rather
|
|
125
|
+
* than minting a credential nobody asked for, which is what the old
|
|
126
|
+
* `ensureProfileToken` did on six different commands.
|
|
127
|
+
*/
|
|
128
|
+
export async function anyIssuedToken(
|
|
129
|
+
workspaceRoot: string,
|
|
130
|
+
credentials: { get(ref: string): Promise<string | null> },
|
|
131
|
+
): Promise<{ readonly id: string; readonly value: string } | null> {
|
|
132
|
+
for (const row of await readEndpointTokens(workspaceRoot)) {
|
|
133
|
+
const value = await credentials.get(row.ref);
|
|
134
|
+
if (value) return { id: row.id, value };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
package/src/profile/workspace.ts
CHANGED
|
@@ -178,7 +178,7 @@ export const CONNECTIONS_FILE = 'connections.yaml';
|
|
|
178
178
|
export async function readConnections(workspaceRoot: string): Promise<ConnectionsFile> {
|
|
179
179
|
const path = join(workspaceRoot, CONNECTIONS_FILE);
|
|
180
180
|
const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), CONNECTIONS_FILE);
|
|
181
|
-
if (text === null) return { contract: SUPPORTED_CONTRACT, connections: [], oauth_apps: {} };
|
|
181
|
+
if (text === null) return { contract: SUPPORTED_CONTRACT, connections: [], oauth_apps: {}, tokens: [] };
|
|
182
182
|
|
|
183
183
|
const raw = parseYaml(text);
|
|
184
184
|
|
package/src/providers/harness.ts
CHANGED
|
@@ -36,6 +36,7 @@ export function harnessFor(
|
|
|
36
36
|
manifest: definition.manifest,
|
|
37
37
|
definition,
|
|
38
38
|
connection: { id: connectionId, provider: definition.manifest.id, account: 'Owner' },
|
|
39
|
+
profiles: ['personal'],
|
|
39
40
|
state: createMemoryState(),
|
|
40
41
|
// Seeded deliberately: an owner provider must not be able to read any of
|
|
41
42
|
// this, and the credential-boundary test says so by name.
|
|
@@ -188,3 +188,19 @@ export function planAll(
|
|
|
188
188
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
189
189
|
.map((manifest) => planFor(manifest, context));
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One connection this profile can reach, as the overview renders it.
|
|
194
|
+
*
|
|
195
|
+
* Declared once rather than spelled structurally at both ends: the two literals
|
|
196
|
+
* had to agree about `providerName`, and the display name went missing on one
|
|
197
|
+
* side — which is how an owner row came to read "Memory (lanes_memory (Memory))".
|
|
198
|
+
*/
|
|
199
|
+
export interface ReachableConnection {
|
|
200
|
+
readonly key: string;
|
|
201
|
+
readonly provider?: string;
|
|
202
|
+
/** The manifest's display name, which is what a label composes with. */
|
|
203
|
+
readonly providerName?: string;
|
|
204
|
+
readonly account: string;
|
|
205
|
+
readonly label?: string | undefined;
|
|
206
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { defineLocalProvider, keepKeys, type ProviderDefinition, type ProviderManifest } from '#connectivity';
|
|
3
|
-
import {
|
|
3
|
+
import { defaultConnectionLabel } from '#profile';
|
|
4
|
+
import { planAll, planFor, type ProviderPlan, type ReachableConnection } from './plan.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* `setup` — what is connected, and what connecting something else would take.
|
|
@@ -54,7 +55,11 @@ export interface SetupProviderOptions {
|
|
|
54
55
|
* further than the commands this provider emits — nothing here opens a store.
|
|
55
56
|
*/
|
|
56
57
|
readonly target: string;
|
|
57
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Every profile on disk. **Not what gets rendered:** the caller's own set
|
|
60
|
+
* arrives at handler time in `ProviderContext.profiles`; this only says how
|
|
61
|
+
* many are withheld — "one profile" versus "one of four".
|
|
62
|
+
*/
|
|
58
63
|
readonly profiles?: readonly string[];
|
|
59
64
|
/**
|
|
60
65
|
* `oauth_apps` entries this profile declares.
|
|
@@ -77,7 +82,7 @@ export interface SetupProviderOptions {
|
|
|
77
82
|
* enum from. Computing it separately here is how discovery and enforcement
|
|
78
83
|
* drift, and a leak in discovery is still a leak.
|
|
79
84
|
*/
|
|
80
|
-
readonly reachable?: () => ReadonlyArray<
|
|
85
|
+
readonly reachable?: () => ReadonlyArray<ReachableConnection>;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
export function createSetupProvider(options: SetupProviderOptions): ProviderDefinition {
|
|
@@ -132,7 +137,13 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
|
|
|
132
137
|
content: [
|
|
133
138
|
{
|
|
134
139
|
type: 'text',
|
|
135
|
-
|
|
140
|
+
// The caller's set, not the workspace's (ADR-068): rendering
|
|
141
|
+
// `options.profiles` named profiles `mayReach` hides from the
|
|
142
|
+
// same caller's `profile` enum.
|
|
143
|
+
text: renderOverview(options, connections, plans, handlerContext.connection.key, {
|
|
144
|
+
reachable: handlerContext.profiles,
|
|
145
|
+
total: options.profiles?.length ?? handlerContext.profiles.length,
|
|
146
|
+
}),
|
|
136
147
|
},
|
|
137
148
|
],
|
|
138
149
|
};
|
|
@@ -185,9 +196,10 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
|
|
|
185
196
|
|
|
186
197
|
function renderOverview(
|
|
187
198
|
options: SetupProviderOptions,
|
|
188
|
-
connections: ReadonlyArray<
|
|
199
|
+
connections: ReadonlyArray<ReachableConnection>,
|
|
189
200
|
plans: readonly ProviderPlan[],
|
|
190
201
|
self: string,
|
|
202
|
+
caller: { reachable: readonly string[]; total: number },
|
|
191
203
|
): string {
|
|
192
204
|
const lines: string[] = [`Profile "${options.profile}".`, ''];
|
|
193
205
|
|
|
@@ -199,11 +211,16 @@ function renderOverview(
|
|
|
199
211
|
// Account first, label in brackets. The other way round for a person
|
|
200
212
|
// reading `status`, and deliberately not here: an agent choosing which
|
|
201
213
|
// connection to call needs the identity, and "Work mail" is not one.
|
|
214
|
+
//
|
|
215
|
+
// `defaultConnectionLabel` when the row carries none, so an unnamed row is
|
|
216
|
+
// called what `connection list` and `/state` call it. The provider's
|
|
217
|
+
// *name*, never its id: `lanes_memory` with account `Memory` would read
|
|
218
|
+
// "lanes_memory (Memory)".
|
|
219
|
+
const provider = connection.providerName ?? connection.key.split('.')[0] ?? connection.key;
|
|
220
|
+
const named = connection.label ?? defaultConnectionLabel(provider, connection.account);
|
|
202
221
|
lines.push(
|
|
203
222
|
` ${connection.key} — ${connection.account}` +
|
|
204
|
-
(
|
|
205
|
-
? ` (${connection.label})`
|
|
206
|
-
: ''),
|
|
223
|
+
(named && named !== connection.account ? ` (${named})` : ''),
|
|
207
224
|
);
|
|
208
225
|
}
|
|
209
226
|
}
|
|
@@ -241,11 +258,21 @@ function renderOverview(
|
|
|
241
258
|
);
|
|
242
259
|
}
|
|
243
260
|
|
|
244
|
-
|
|
245
|
-
|
|
261
|
+
// **Which profiles *this caller* can reach** — the question an agent asks
|
|
262
|
+
// first. `initialize.instructions` names them until its budget is tight, then
|
|
263
|
+
// collapses to a count; this is the surface it already points at.
|
|
264
|
+
const others = caller.reachable.filter((name) => name !== options.profile);
|
|
265
|
+
lines.push('', `You can reach ${caller.reachable.length} profile(s) on this endpoint.`);
|
|
266
|
+
if (others.length > 0) {
|
|
267
|
+
lines.push(` Also: ${others.join(', ')}. Pass one as \`profile\` to see what it reaches.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A count, never names: naming them is the leak this used to be. That some
|
|
271
|
+
// exist is not a secret, and an agent told "one of four" asks the owner.
|
|
272
|
+
const hidden = caller.total - caller.reachable.length;
|
|
273
|
+
if (hidden > 0) {
|
|
246
274
|
lines.push(
|
|
247
|
-
|
|
248
|
-
`This endpoint also serves: ${siblings.join(', ')}. Pass that profile to see what it reaches.`,
|
|
275
|
+
` ${hidden} other profile(s) exist here that you are not a member of. Only the owner can change that.`,
|
|
249
276
|
);
|
|
250
277
|
}
|
|
251
278
|
|
package/src/server/container.ts
CHANGED
|
@@ -72,8 +72,9 @@ try {
|
|
|
72
72
|
},
|
|
73
73
|
port,
|
|
74
74
|
host,
|
|
75
|
-
//
|
|
76
|
-
|
|
75
|
+
// Nothing about tokens here any more (ADR-068): a deployed instance neither
|
|
76
|
+
// mints one nor needs one to boot. A client discovers the
|
|
77
|
+
// protected-resource document from the 401 and signs its owner in.
|
|
77
78
|
// Stdout is where Cloud Run collects logs, and a rejected credential on a
|
|
78
79
|
// public URL is the event this exists for.
|
|
79
80
|
log: streamLogger((line) => process.stdout.write(`${line}\n`)),
|
|
@@ -84,7 +85,6 @@ try {
|
|
|
84
85
|
// into an image that will be replaced.
|
|
85
86
|
log(`reconciled ${profile}\n${plan}`);
|
|
86
87
|
},
|
|
87
|
-
tokenMinted() {},
|
|
88
88
|
},
|
|
89
89
|
});
|
|
90
90
|
|