@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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONNECTIONS_FILE,
|
|
3
|
+
layout,
|
|
4
|
+
listProfiles,
|
|
5
|
+
readWorkspaceFile,
|
|
6
|
+
workspaceFiles,
|
|
7
|
+
} from '#profile';
|
|
8
|
+
import { parseDocument } from 'yaml';
|
|
9
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
10
|
+
import { openOrCreateConnections } from './config-repair-sweep.ts';
|
|
11
|
+
import { ensureRegistryContract } from './config-repair-sweep.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Contract 4 to contract 5: the endpoint token becomes a person's.
|
|
15
|
+
*
|
|
16
|
+
* Contract 4 left `auth.token_ref` on every profile, defaulting to the constant
|
|
17
|
+
* `profile/token` out of a credential store that has been one per workspace
|
|
18
|
+
* since contract 3. So "the profile's token" was the workspace's wearing a
|
|
19
|
+
* per-profile name, which is why `profile remove` once deleted the token its
|
|
20
|
+
* siblings were being served by, and why every command whose subject is the
|
|
21
|
+
* endpoint had to name a profile in order to find it. ADR-068 moves it to
|
|
22
|
+
* `tokens:` in `connections.yaml`, one row per token, each naming the Lanes
|
|
23
|
+
* subject it was issued to.
|
|
24
|
+
*
|
|
25
|
+
* **The subject is what this migration cannot invent.** A row exists so a
|
|
26
|
+
* bearer token can resolve through `members:` the way an OAuth token does, and
|
|
27
|
+
* a row with the wrong subject is worse than no row: it is a credential that
|
|
28
|
+
* looks issued and reaches nothing, or reaches somebody else's profiles. So the
|
|
29
|
+
* subject comes from the profiles themselves — the owner-role member of a
|
|
30
|
+
* profile that actually holds the token — and when there is no such member this
|
|
31
|
+
* refuses and says which command fixes it.
|
|
32
|
+
*
|
|
33
|
+
* Three steps, ordered so a crash between any two leaves a workspace that still
|
|
34
|
+
* opens and a rerun that still finishes:
|
|
35
|
+
*
|
|
36
|
+
* 1. The row is written into `connections.yaml`, pointing at the ref the
|
|
37
|
+
* value is *already* under. Nothing moves in the credential store, which
|
|
38
|
+
* is what makes this safe to interrupt: the old key and the new row name
|
|
39
|
+
* the same bytes.
|
|
40
|
+
* 2. `auth.token_ref` is removed from each profile.
|
|
41
|
+
* 3. Each profile is stamped `contract: 5`.
|
|
42
|
+
*
|
|
43
|
+
* **The stamp is last**, for the reason contract 4 records: contract 3 shipped
|
|
44
|
+
* with it first, which left profiles claiming the new contract with every byte
|
|
45
|
+
* at the old path and a rerun that read the stamp and found nothing to do.
|
|
46
|
+
*
|
|
47
|
+
* **The ref is not renamed to `tokens/tok1`.** It would read better and it
|
|
48
|
+
* would mean copying a live credential in a store that may be Secret Manager,
|
|
49
|
+
* then deleting the original — two writes and a window where a deployed
|
|
50
|
+
* revision reads neither. A row may name any ref; `profile/token` is a
|
|
51
|
+
* perfectly good one, and the one thing that must not break here is an endpoint
|
|
52
|
+
* that was working before the upgrade.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
export interface Contract5Migration {
|
|
56
|
+
readonly workspaceRoot: string;
|
|
57
|
+
readonly profiles: readonly string[];
|
|
58
|
+
readonly changes: readonly string[];
|
|
59
|
+
/** Written into `tokens:`, as `id → subject`. Empty when there was no token. */
|
|
60
|
+
readonly issued: readonly { readonly id: string; readonly subject: string }[];
|
|
61
|
+
readonly alreadyCurrent: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RawProfile {
|
|
65
|
+
readonly contract?: number;
|
|
66
|
+
readonly auth?: { readonly token_ref?: unknown };
|
|
67
|
+
readonly members?: readonly { readonly subject?: unknown; readonly role?: unknown }[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether this workspace still holds anything at contract 4. */
|
|
71
|
+
export async function needsContract5(workspaceRoot: string): Promise<boolean> {
|
|
72
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
73
|
+
const raw = await readProfile(workspaceRoot, profile);
|
|
74
|
+
if (raw !== null && (raw.contract ?? 0) === 4) return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function migrateToContract5(
|
|
80
|
+
workspaceRoot: string,
|
|
81
|
+
options: { apply: boolean; subject?: string } = { apply: true },
|
|
82
|
+
): Promise<Contract5Migration> {
|
|
83
|
+
const legacy = new Map<string, RawProfile>();
|
|
84
|
+
|
|
85
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
86
|
+
const raw = await readProfile(workspaceRoot, profile);
|
|
87
|
+
if (raw !== null && (raw.contract ?? 0) === 4) legacy.set(profile, raw);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (legacy.size === 0) {
|
|
91
|
+
return { workspaceRoot, profiles: [], changes: [], issued: [], alreadyCurrent: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const profiles = [...legacy.keys()];
|
|
95
|
+
const changes: string[] = [];
|
|
96
|
+
const issued: { id: string; subject: string }[] = [];
|
|
97
|
+
|
|
98
|
+
// Every distinct ref the profiles held, in the order they are declared. Almost
|
|
99
|
+
// always one — the template's constant — but a profile that overrode
|
|
100
|
+
// `token_ref` had a genuinely separate credential, and dropping it would take
|
|
101
|
+
// a working endpoint down.
|
|
102
|
+
const refs = new Map<string, string[]>();
|
|
103
|
+
for (const [profile, raw] of legacy) {
|
|
104
|
+
const ref = typeof raw.auth?.token_ref === 'string' ? raw.auth.token_ref : 'profile/token';
|
|
105
|
+
refs.set(ref, [...(refs.get(ref) ?? []), profile]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!options.apply) {
|
|
109
|
+
for (const [ref, holders] of refs) {
|
|
110
|
+
changes.push(`${CONNECTIONS_FILE}: tokens += a row for "${ref}" (${holders.join(', ')})`);
|
|
111
|
+
}
|
|
112
|
+
for (const profile of profiles) changes.push(`${profile}: auth.token_ref removed`);
|
|
113
|
+
return { workspaceRoot, profiles, changes, issued: [], alreadyCurrent: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// **The one thing this cannot guess.** `--subject` from `update`, else the
|
|
117
|
+
// owner-role member of a profile that held the ref. Refusing beats writing a
|
|
118
|
+
// row nobody can use: a token bound to the wrong subject reaches the wrong
|
|
119
|
+
// profiles, and one bound to nobody reaches none while looking issued.
|
|
120
|
+
const document = await openOrCreateConnections(workspaceRoot);
|
|
121
|
+
// Through `toJSON`, not `getIn`. `getIn` hands back YAML nodes, so reading
|
|
122
|
+
// `row.ref` off one is `undefined` — the idempotence check below silently
|
|
123
|
+
// never matched, and a rerun after an interruption wrote the row twice.
|
|
124
|
+
const existing = asRows((document.toJSON() as { tokens?: unknown } | null)?.tokens);
|
|
125
|
+
let next = existing.length + 1;
|
|
126
|
+
|
|
127
|
+
for (const [ref, holders] of refs) {
|
|
128
|
+
// Already migrated, which is what an interrupted run looks like from here.
|
|
129
|
+
if (existing.some((row) => row.ref === ref)) continue;
|
|
130
|
+
|
|
131
|
+
const subject = options.subject ?? ownerSubject(legacy, holders);
|
|
132
|
+
if (subject === undefined) {
|
|
133
|
+
throw new ContractError(
|
|
134
|
+
`Cannot migrate the endpoint token at "${ref}": a token now names the person it was\n` +
|
|
135
|
+
'issued to (ADR-068), and no profile holding it lists an owner.\n' +
|
|
136
|
+
` Holding it: ${holders.join(', ')}\n` +
|
|
137
|
+
' Sign in and say who you are, then run this again:\n' +
|
|
138
|
+
' lanes auth login\n' +
|
|
139
|
+
` lanes link profile members add --me --profile ${holders[0]} --workspace <name>`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const id = `tok${next++}`;
|
|
144
|
+
document.addTo(['tokens'], { id, subject, ref, label: 'migrated' });
|
|
145
|
+
issued.push({ id, subject });
|
|
146
|
+
changes.push(`${CONNECTIONS_FILE}: tokens += ${id} → ${subject} ("${ref}")`);
|
|
147
|
+
|
|
148
|
+
// **Say so when the row reaches nothing.** A subject that no profile lists
|
|
149
|
+
// is a token the endpoint refuses, and the operator's CI would fail with a
|
|
150
|
+
// 401 that reads as a bad credential. It happens on the path a signed-in
|
|
151
|
+
// operator upgrades a workspace whose `members:` is empty — legitimate, and
|
|
152
|
+
// contract 3 only fills that in for workspaces it migrates itself.
|
|
153
|
+
if (!listedAnywhere(legacy, subject)) {
|
|
154
|
+
changes.push(
|
|
155
|
+
` warning: no profile lists ${subject}, so ${id} reaches nothing until one does — ` +
|
|
156
|
+
'lanes link profile members add --me --profile <name> --workspace <name>',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The stamp, for the reason contract 4 gives where it does the same: nothing
|
|
162
|
+
// reads this field — every contract check is on a profile — which is exactly
|
|
163
|
+
// why it goes stale, and a marker that lies is worse than no marker for
|
|
164
|
+
// whoever writes the next migration.
|
|
165
|
+
document.setIn(['contract'], 5);
|
|
166
|
+
|
|
167
|
+
await document.save();
|
|
168
|
+
|
|
169
|
+
for (const profile of profiles) {
|
|
170
|
+
const config = await ConfigDocument.open(workspaceRoot, profile);
|
|
171
|
+
config.removeIn(['auth', 'token_ref']);
|
|
172
|
+
// Last, and the record that this profile finished.
|
|
173
|
+
config.setIn(['contract'], 5);
|
|
174
|
+
await config.save({ contract: 5 });
|
|
175
|
+
changes.push(`${profile}: auth.token_ref removed, contract 5`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// After the profiles, so the registry never claims a contract they have not
|
|
179
|
+
// reached — `isUnmigrated` reads this field and nothing else.
|
|
180
|
+
await ensureRegistryContract(workspaceRoot, 5);
|
|
181
|
+
|
|
182
|
+
return { workspaceRoot, profiles, changes, issued, alreadyCurrent: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Raised so `doctor` and `update` can print it rather than a stack. */
|
|
186
|
+
export class ContractError extends Error {}
|
|
187
|
+
|
|
188
|
+
/** Whether any profile being migrated lists this subject as a member. */
|
|
189
|
+
function listedAnywhere(legacy: ReadonlyMap<string, RawProfile>, subject: string): boolean {
|
|
190
|
+
for (const raw of legacy.values()) {
|
|
191
|
+
if ((raw.members ?? []).some((member) => member.subject === subject)) return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The subject to bind a ref to: an owner of a profile that held it.
|
|
198
|
+
*
|
|
199
|
+
* `owner` rather than any member, because `owner` is who may edit the member
|
|
200
|
+
* list (ADR-060) and is therefore the one role that cannot have been delegated
|
|
201
|
+
* a narrower reach than the token used to have. Falls back to any member, since
|
|
202
|
+
* a profile with members and no owner still has somebody this belonged to, and
|
|
203
|
+
* a workspace migrated by contract 3 while signed out has neither.
|
|
204
|
+
*/
|
|
205
|
+
function ownerSubject(
|
|
206
|
+
legacy: ReadonlyMap<string, RawProfile>,
|
|
207
|
+
holders: readonly string[],
|
|
208
|
+
): string | undefined {
|
|
209
|
+
for (const role of ['owner', undefined]) {
|
|
210
|
+
for (const profile of holders) {
|
|
211
|
+
for (const member of legacy.get(profile)?.members ?? []) {
|
|
212
|
+
if (typeof member.subject !== 'string') continue;
|
|
213
|
+
if (role === undefined || member.role === role) return member.subject;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function asRows(value: unknown): readonly { ref?: string }[] {
|
|
221
|
+
return Array.isArray(value) ? (value as { ref?: string }[]) : [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function readProfile(root: string, profile: string): Promise<RawProfile | null> {
|
|
225
|
+
const text = await readWorkspaceFile(workspaceFiles(root), layout.profileConfig(profile));
|
|
226
|
+
if (text === null) return null;
|
|
227
|
+
try {
|
|
228
|
+
return parseDocument(text).toJSON() as RawProfile;
|
|
229
|
+
} catch {
|
|
230
|
+
// A file that will not parse is not this migration's problem to report;
|
|
231
|
+
// `check` has a better sentence for it than "needs migrating" would.
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/cli/endpoint-url.ts
CHANGED
|
@@ -42,19 +42,33 @@ export function localUrl(config: Config): string {
|
|
|
42
42
|
*
|
|
43
43
|
* Anonymous would be enough to prove a socket is bound, but the profile list is
|
|
44
44
|
* behind the token, and the profile is the whole point of asking.
|
|
45
|
+
*
|
|
46
|
+
* **The token is optional** since ADR-068, because a healthy workspace may have
|
|
47
|
+
* issued none: a person's client signs in for itself and needs no static
|
|
48
|
+
* credential. Without one this still tells a caller whether anything is
|
|
49
|
+
* answering, which is the half every caller uses; `profiles` comes back empty
|
|
50
|
+
* rather than absent, so a caller cannot mistake "withheld" for "none served".
|
|
45
51
|
*/
|
|
46
|
-
export async function endpointHealth(
|
|
52
|
+
export async function endpointHealth(
|
|
53
|
+
url: string,
|
|
54
|
+
token?: string | undefined,
|
|
55
|
+
): Promise<EndpointHealth | null> {
|
|
47
56
|
try {
|
|
48
57
|
const probe = new URL(url);
|
|
49
58
|
probe.pathname = '/health';
|
|
50
59
|
const response = await fetch(probe, {
|
|
51
|
-
headers: { authorization: `Bearer ${token}` },
|
|
60
|
+
...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }),
|
|
52
61
|
signal: AbortSignal.timeout(700),
|
|
53
62
|
});
|
|
54
63
|
if (!response.ok) return null;
|
|
55
64
|
|
|
56
65
|
const body = (await response.json()) as Partial<EndpointHealth>;
|
|
57
|
-
|
|
66
|
+
if (body.profile) {
|
|
67
|
+
return { profile: body.profile, profiles: body.profiles ?? [body.profile] };
|
|
68
|
+
}
|
|
69
|
+
// Answered, but withheld the list — which is what an unauthenticated probe
|
|
70
|
+
// gets. Reporting the endpoint as absent would be wrong: it is up.
|
|
71
|
+
return token === undefined ? { profile: '', profiles: [] } : null;
|
|
58
72
|
} catch {
|
|
59
73
|
return null;
|
|
60
74
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
policyRule,
|
|
22
22
|
start,
|
|
23
23
|
status,
|
|
24
|
+
tokenIssue,
|
|
25
|
+
tokenList,
|
|
26
|
+
tokenRevoke,
|
|
24
27
|
tokenRotate,
|
|
25
28
|
tokenShow,
|
|
26
29
|
tools,
|
|
@@ -284,16 +287,37 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
284
287
|
throw new Error(`Unknown: ${PROGRAM} policy ${second}`);
|
|
285
288
|
}
|
|
286
289
|
|
|
287
|
-
case 'token':
|
|
290
|
+
case 'token': {
|
|
291
|
+
const row = {
|
|
292
|
+
...global,
|
|
293
|
+
show,
|
|
294
|
+
raw,
|
|
295
|
+
...(typeof flags['id'] === 'string' ? { id: flags['id'] } : {}),
|
|
296
|
+
...(typeof flags['subject'] === 'string' ? { subject: flags['subject'] } : {}),
|
|
297
|
+
me: flags['me'] === true,
|
|
298
|
+
...(typeof flags['label'] === 'string' ? { label: flags['label'] } : {}),
|
|
299
|
+
json: flags['json'] === true,
|
|
300
|
+
};
|
|
288
301
|
switch (second) {
|
|
289
|
-
|
|
302
|
+
// A bare `token` lists rather than showing one. It used to show, because
|
|
303
|
+
// there was one token and nothing to list; there are several now, and a
|
|
304
|
+
// command that printed a credential when asked for an inventory would be
|
|
305
|
+
// the wrong default in the more expensive direction.
|
|
306
|
+
case 'list':
|
|
290
307
|
case undefined:
|
|
291
|
-
return
|
|
308
|
+
return tokenList(row);
|
|
309
|
+
case 'issue':
|
|
310
|
+
return tokenIssue(row);
|
|
311
|
+
case 'show':
|
|
312
|
+
return tokenShow(row);
|
|
292
313
|
case 'rotate':
|
|
293
|
-
return tokenRotate(
|
|
314
|
+
return tokenRotate(row);
|
|
315
|
+
case 'revoke':
|
|
316
|
+
return tokenRevoke(row);
|
|
294
317
|
default:
|
|
295
318
|
throw new Error(`Unknown: ${PROGRAM} token ${second}`);
|
|
296
319
|
}
|
|
320
|
+
}
|
|
297
321
|
|
|
298
322
|
case 'pair':
|
|
299
323
|
return pair({
|
package/src/cli/publish.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SecretStore } from '#secrets';
|
|
2
|
-
import { openTarget, type Config } from '#profile';
|
|
2
|
+
import { anyIssuedToken, openTarget, type Config } from '#profile';
|
|
3
3
|
import { publishWorkspace } from '#deployments/upload.ts';
|
|
4
4
|
import { openSecretStoreFor, type Runtime } from './runtime.ts';
|
|
5
5
|
import { endpointUrl } from './endpoint-url.ts';
|
|
@@ -131,14 +131,23 @@ async function notifyReload(input: {
|
|
|
131
131
|
return { served: false, reason: `could not work out where the endpoint is: ${message(error)}` };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
// Any row the workspace holds (ADR-068). Which one is not a choice worth
|
|
135
|
+
// making here: this is the operator's own command reaching the operator's own
|
|
136
|
+
// endpoint, and `/reload` cares that the caller is authenticated rather than
|
|
137
|
+
// who they are.
|
|
138
|
+
const held = await anyIssuedToken(input.workspaceRoot, input.credentials);
|
|
139
|
+
if (!held) {
|
|
136
140
|
return {
|
|
137
141
|
served: false,
|
|
138
142
|
url,
|
|
139
|
-
|
|
143
|
+
// Not a failure to fix in most cases, which is why it reads as a reason
|
|
144
|
+
// rather than an error. An endpoint serving browser clients needs no
|
|
145
|
+
// static token; what it costs is that a config change is picked up on the
|
|
146
|
+
// next reconcile instead of immediately.
|
|
147
|
+
reason: 'no static token is issued in this workspace, so the endpoint cannot be notified',
|
|
140
148
|
};
|
|
141
149
|
}
|
|
150
|
+
const token = held.value;
|
|
142
151
|
|
|
143
152
|
try {
|
|
144
153
|
const response = await fetch(url, {
|
package/src/cli/runtime/open.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
assertGrantsResolve,
|
|
10
10
|
layout,
|
|
11
11
|
listProfiles,
|
|
12
|
+
membersResolver,
|
|
12
13
|
readConnections,
|
|
13
14
|
selectConnections,
|
|
14
15
|
soleGrantFor,
|
|
@@ -190,7 +191,12 @@ export async function openRuntime(
|
|
|
190
191
|
// Lazy for the same reason `refreshSkills` is: it reads the registry it is
|
|
191
192
|
// registered into. Per call rather than a snapshot, so a policy the runtime
|
|
192
193
|
// was opened with is re-evaluated rather than remembered.
|
|
193
|
-
const reachable = (): ReadonlyArray<{
|
|
194
|
+
const reachable = (): ReadonlyArray<{
|
|
195
|
+
key: string;
|
|
196
|
+
provider: string;
|
|
197
|
+
providerName: string;
|
|
198
|
+
account: string;
|
|
199
|
+
}> =>
|
|
194
200
|
selected
|
|
195
201
|
.filter(({ ref, connection }) =>
|
|
196
202
|
registry
|
|
@@ -204,6 +210,11 @@ export async function openRuntime(
|
|
|
204
210
|
.map(({ ref, connection }) => ({
|
|
205
211
|
key: ref,
|
|
206
212
|
provider: connection.provider,
|
|
213
|
+
// The manifest's display name, not the id. `defaultConnectionLabel`
|
|
214
|
+
// composes "Gmail (ada)" and needs the name — handed the id it produces
|
|
215
|
+
// "gmail (ada)", and for the owner layer, whose `account` is already the
|
|
216
|
+
// proper noun, "lanes_memory (Memory)".
|
|
217
|
+
providerName: registry.manifest(connection.provider)?.name ?? connection.provider,
|
|
207
218
|
account: connection.account,
|
|
208
219
|
...(connection.label ? { label: connection.label } : {}),
|
|
209
220
|
}));
|
|
@@ -329,10 +340,16 @@ export async function openRuntime(
|
|
|
329
340
|
registry,
|
|
330
341
|
refreshSkills,
|
|
331
342
|
dispatcher,
|
|
343
|
+
// The workspace's issued tokens, not a profile's (ADR-068). The rows are
|
|
344
|
+
// re-read on every reload so a `token revoke` lands inside the cache
|
|
345
|
+
// window, and the subject on the matching row is resolved through
|
|
346
|
+
// `members:` — so a bearer token reaches what its holder is a member of
|
|
347
|
+
// rather than everything the workspace holds.
|
|
332
348
|
authenticator: new BearerAuthenticator({
|
|
333
349
|
profile: config.instance.profile,
|
|
334
|
-
|
|
350
|
+
tokens: async () => (await readConnections(root)).tokens,
|
|
335
351
|
credentials,
|
|
352
|
+
profilesFor: membersResolver(root),
|
|
336
353
|
}),
|
|
337
354
|
connectorFor,
|
|
338
355
|
authorizeRequest,
|
|
@@ -159,17 +159,5 @@ export async function openBlobStoreFor(
|
|
|
159
159
|
return area === undefined ? storage() : storage(area);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/** Mint the profile token if it does not exist yet. Returns it either way. */
|
|
163
|
-
export async function ensureProfileToken(
|
|
164
|
-
credentials: SecretStore,
|
|
165
|
-
tokenRef: string,
|
|
166
|
-
): Promise<{ token: string; created: boolean }> {
|
|
167
|
-
const existing = await credentials.get(tokenRef);
|
|
168
|
-
if (existing) return { token: existing, created: false };
|
|
169
|
-
|
|
170
|
-
const token = generateProfileToken();
|
|
171
|
-
await credentials.set(tokenRef, token);
|
|
172
|
-
return { token, created: true };
|
|
173
|
-
}
|
|
174
162
|
|
|
175
163
|
export { ownerPrincipal };
|
package/src/cli/runtime.ts
CHANGED
package/src/cli/selection.ts
CHANGED
|
@@ -145,7 +145,14 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
145
145
|
// ignored, which is the defect this whole file exists for.
|
|
146
146
|
'connect custom': 'workspace',
|
|
147
147
|
setup: 'profile+workspace',
|
|
148
|
-
token
|
|
148
|
+
// A token belongs to the workspace and names the person it was issued to
|
|
149
|
+
// (ADR-068). It used to sit at `auth.token_ref` on a profile, whose default
|
|
150
|
+
// was the same constant for every profile in a store that is one per
|
|
151
|
+
// workspace — so `--profile` was demanded here and could not change the
|
|
152
|
+
// answer. These commands refuse it outright rather than accept and ignore it:
|
|
153
|
+
// somebody passing `--profile work` believes they scoped the credential, and
|
|
154
|
+
// it is the member lists that decide.
|
|
155
|
+
token: 'workspace',
|
|
149
156
|
// One chain per workspace since contract 3, so the workspace is the subject
|
|
150
157
|
// and `--profile` filters the rows rather than choosing which log to read.
|
|
151
158
|
audit: 'workspace',
|
|
@@ -160,7 +167,14 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
160
167
|
auth: 'workspace',
|
|
161
168
|
// Target-scoped: see the note above. `--profile` narrows each to one profile.
|
|
162
169
|
status: 'workspace',
|
|
163
|
-
|
|
170
|
+
// Its subject has always been the endpoint rather than a profile — its own
|
|
171
|
+
// doc comment said so — and one endpoint serves every profile in the
|
|
172
|
+
// workspace. It named a profile only to find the endpoint's token. `--profile`
|
|
173
|
+
// stays accepted and picks whose `instance.port` the local URL names, which
|
|
174
|
+
// is the one thing profiles can still disagree about here.
|
|
175
|
+
outputs: 'workspace',
|
|
176
|
+
// Unlike the two above: this reports what *one profile's* policy resolves to,
|
|
177
|
+
// beside what the endpoint advertises. The profile is the subject.
|
|
164
178
|
tools: 'profile+workspace',
|
|
165
179
|
// It resolves nothing and opens nothing — it hands macOS a URL (ADR-053).
|
|
166
180
|
// `target list` is the precedent for a `'none'` command that still takes a
|
|
@@ -171,8 +185,9 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
171
185
|
desktop: 'none',
|
|
172
186
|
attach: 'profile+workspace',
|
|
173
187
|
// One endpoint serves every profile in the workspace (ADR-009), so naming one
|
|
174
|
-
// described a slice of what it does. `--profile` picks the primary
|
|
175
|
-
//
|
|
188
|
+
// described a slice of what it does. `--profile` picks the primary — which is
|
|
189
|
+
// now only whose host and port it binds, since the token stopped being a
|
|
190
|
+
// profile's (ADR-068) — and `--only` is what narrows what is served.
|
|
176
191
|
start: 'workspace',
|
|
177
192
|
deploy: 'workspace',
|
|
178
193
|
// Both spellings: `sync` alone is `sync targets`, which is the only thing
|
|
@@ -193,14 +208,24 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
193
208
|
// which profile was a question with no answer. `--profile` still narrows, and
|
|
194
209
|
// is how a port is chosen when profiles disagree about one.
|
|
195
210
|
pair: 'workspace',
|
|
196
|
-
'token show': '
|
|
197
|
-
'token rotate': '
|
|
211
|
+
'token show': 'workspace',
|
|
212
|
+
'token rotate': 'workspace',
|
|
213
|
+
'token issue': 'workspace',
|
|
214
|
+
'token list': 'workspace',
|
|
215
|
+
'token revoke': 'workspace',
|
|
198
216
|
'audit tail': 'workspace',
|
|
199
217
|
'audit verify': 'workspace',
|
|
200
218
|
'secrets set': 'workspace',
|
|
201
219
|
'secrets list': 'workspace',
|
|
202
|
-
|
|
203
|
-
|
|
220
|
+
// One registration serves every profile, and each call names one in its
|
|
221
|
+
// `profile` argument — so two `--profile` values produced byte-identical
|
|
222
|
+
// harness commands. What kept the flag required was reading the endpoint's
|
|
223
|
+
// token, which is the workspace's now (ADR-068). `--profile` still picks
|
|
224
|
+
// whose port the local URL names.
|
|
225
|
+
'mcp add': 'workspace',
|
|
226
|
+
// Matches `start`, which it is: the pipe serves every profile in the
|
|
227
|
+
// workspace and `--only` is what narrows that to one.
|
|
228
|
+
'mcp stdio': 'workspace',
|
|
204
229
|
memory: 'profile+workspace',
|
|
205
230
|
tasks: 'profile+workspace',
|
|
206
231
|
assets: 'profile+workspace',
|
|
@@ -230,7 +255,7 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
|
230
255
|
target: ['list', 'use', 'show'],
|
|
231
256
|
policy: ['list', 'allow', 'deny'],
|
|
232
257
|
identity: ['add', 'list', 'remove'],
|
|
233
|
-
token: ['show', 'rotate'],
|
|
258
|
+
token: ['show', 'rotate', 'issue', 'list', 'revoke'],
|
|
234
259
|
audit: ['tail', 'verify'],
|
|
235
260
|
config: ['show'],
|
|
236
261
|
setup: ['plan'],
|
|
@@ -242,7 +267,12 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
|
242
267
|
// No `list`: a bare `entities` is a listing, which is `find` with no
|
|
243
268
|
// criteria. One concept, one word.
|
|
244
269
|
entities: ['find', 'get', 'write', 'link', 'forget', 'reindex'],
|
|
245
|
-
|
|
270
|
+
// `install-instructions` was absent, which is not cosmetic: `dispatchWillRefuse`
|
|
271
|
+
// returns true for a second word it does not know, and both `assertKnownFlags`
|
|
272
|
+
// and `requireSelection` then return early — so `mcp install-instructions
|
|
273
|
+
// --bogus` was accepted and ignored, which is the defect this whole file
|
|
274
|
+
// exists to prevent.
|
|
275
|
+
mcp: ['skill', 'add', 'stdio', 'list', 'install-instructions'],
|
|
246
276
|
secrets: ['push', 'set', 'list'],
|
|
247
277
|
knowledge: ['show', 'use'],
|
|
248
278
|
sync: ['targets', 'workspaces'],
|
package/src/cli/usage.ts
CHANGED
|
@@ -62,10 +62,10 @@ ${style.bold('Profiles')}
|
|
|
62
62
|
${PROGRAM} profile list [--json]
|
|
63
63
|
${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes] [--json]
|
|
64
64
|
[--delete-data | --migrate-to <profile>]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
Say which of --delete-data or --migrate-to for
|
|
66
|
+
its memory, tasks, assets and skills — there is
|
|
67
|
+
no default. Accounts outlive it; disconnect
|
|
68
|
+
removes those, and token revoke the tokens.
|
|
69
69
|
|
|
70
70
|
${style.bold('Workspaces')}
|
|
71
71
|
${PROGRAM} workspace list [--urls] every workspace this one knows
|
|
@@ -90,8 +90,13 @@ ${style.bold('Permissions')}
|
|
|
90
90
|
${PROGRAM} profile members list who may consume this profile, and who could
|
|
91
91
|
${PROGRAM} profile members add <subject>|--me [--role owner|member]
|
|
92
92
|
${PROGRAM} profile members remove <subject>
|
|
93
|
-
${PROGRAM} token
|
|
94
|
-
${PROGRAM} token
|
|
93
|
+
${PROGRAM} token list [--json] static tokens, and what each one reaches
|
|
94
|
+
${PROGRAM} token issue --me|--subject <id> [--label <t>]
|
|
95
|
+
CI only: a token for one person. It reaches
|
|
96
|
+
every profile whose members list them
|
|
97
|
+
${PROGRAM} token show [--id t] [--show|--raw] --raw prints only it, for $(…)
|
|
98
|
+
${PROGRAM} token rotate [--id t] [--show]
|
|
99
|
+
${PROGRAM} token revoke --id <t>
|
|
95
100
|
|
|
96
101
|
${style.bold('Your own context')}
|
|
97
102
|
${PROGRAM} memory list [--tag t] what you have stored
|
|
@@ -146,7 +151,7 @@ ${style.bold('Deploying')}
|
|
|
146
151
|
set up, build, and roll one revision serving
|
|
147
152
|
every profile that declares the target
|
|
148
153
|
${PROGRAM} deploy --workspace <name> --profile a --profile b
|
|
149
|
-
only these; the first
|
|
154
|
+
only these; the first is the primary
|
|
150
155
|
${PROGRAM} deploy --non-interactive take the stored answers, never prompt
|
|
151
156
|
${PROGRAM} deploy --access iam|public who gets past the platform's own door
|
|
152
157
|
${PROGRAM} secrets list credential references in this target
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { migrateToContract4, type Contract4Migration } from './contract4.ts';
|
|
2
|
+
import { migrateToContract5, type Contract5Migration } from './contract5.ts';
|
|
2
3
|
import { migrateToContract3, needsContract3, type Contract3Migration } from './contract3.ts';
|
|
3
4
|
import { readSession } from '#auth/lanes/session.ts';
|
|
4
5
|
import { parseDocument } from 'yaml';
|
|
@@ -196,6 +197,8 @@ export interface ContractMigration {
|
|
|
196
197
|
/** The contract 2 → 3 half, when this workspace needed one. */
|
|
197
198
|
readonly contract3: Contract3Migration | null;
|
|
198
199
|
readonly contract4: Contract4Migration | null;
|
|
200
|
+
/** The contract 4 → 5 half: the endpoint token becomes a person's (ADR-068). */
|
|
201
|
+
readonly contract5: Contract5Migration | null;
|
|
199
202
|
/** Every profile either half rewrote, deduplicated. */
|
|
200
203
|
readonly profiles: readonly string[];
|
|
201
204
|
/** Targets written into the registry by the contract 1 → 2 half. */
|
|
@@ -250,10 +253,10 @@ export async function migrateToCurrentContract(
|
|
|
250
253
|
|
|
251
254
|
const subject = options.subject ?? (await readSession().catch(() => null))?.subject;
|
|
252
255
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
// Contract 3 writes `members:` from it; contract 5 binds the token to it.
|
|
257
|
+
const signed = { apply: options.apply, ...(subject === undefined ? {} : { subject }) };
|
|
258
|
+
|
|
259
|
+
const contract3 = await migrateToContract3(workspaceRoot, signed);
|
|
257
260
|
|
|
258
261
|
// In sequence, not in parallel: contract 4 moves what contract 3 produced, so
|
|
259
262
|
// it has to run against the tree the previous step left. With `apply: false`
|
|
@@ -264,20 +267,23 @@ export async function migrateToCurrentContract(
|
|
|
264
267
|
...(options.target === undefined ? {} : { target: options.target }),
|
|
265
268
|
});
|
|
266
269
|
|
|
270
|
+
// In sequence again, for contract 4's reason.
|
|
271
|
+
const contract5 = await migrateToContract5(workspaceRoot, signed);
|
|
272
|
+
|
|
273
|
+
// Collected, not spelled out per step: a step left out of `alreadyCurrent`
|
|
274
|
+
// reports a migration as finished when it is not.
|
|
275
|
+
const steps = [legacy, contract3, contract4, contract5].filter((s) => s !== null);
|
|
276
|
+
|
|
267
277
|
return {
|
|
268
278
|
workspaceRoot,
|
|
269
279
|
legacy: legacy !== null && !legacy.alreadyCurrent ? legacy : null,
|
|
270
280
|
contract3: contract3.alreadyCurrent ? null : contract3,
|
|
271
281
|
contract4: contract4.alreadyCurrent ? null : contract4,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
],
|
|
282
|
+
contract5: contract5.alreadyCurrent ? null : contract5,
|
|
283
|
+
profiles: [...new Set(steps.flatMap((step) => step.profiles))],
|
|
275
284
|
targets: legacy?.targets ?? [],
|
|
276
|
-
changes:
|
|
277
|
-
alreadyCurrent:
|
|
278
|
-
(legacy === null || legacy.alreadyCurrent) &&
|
|
279
|
-
contract3.alreadyCurrent &&
|
|
280
|
-
contract4.alreadyCurrent,
|
|
285
|
+
changes: steps.flatMap((step) => step.changes),
|
|
286
|
+
alreadyCurrent: steps.every((step) => step.alreadyCurrent),
|
|
281
287
|
};
|
|
282
288
|
}
|
|
283
289
|
|
|
@@ -53,6 +53,23 @@ export interface ConnectionInfo {
|
|
|
53
53
|
*/
|
|
54
54
|
export interface ProviderContext {
|
|
55
55
|
readonly connection: ConnectionInfo;
|
|
56
|
+
/**
|
|
57
|
+
* Every profile this caller may reach, this one included.
|
|
58
|
+
*
|
|
59
|
+
* The narrowest widening of this interface that could carry it, and worth
|
|
60
|
+
* saying why it belongs at all given the rule above. It is not a backend, a
|
|
61
|
+
* credential or a store: it is the same routing fact the client already holds
|
|
62
|
+
* in the `profile` enum on every tool it was shown, which `visibility.ts`
|
|
63
|
+
* filters by `mayReach`. A provider cannot *act* in any of these — dispatch
|
|
64
|
+
* still resolves one profile per call — so this grants no reach.
|
|
65
|
+
*
|
|
66
|
+
* It exists because a surface that describes what a caller can get to has to
|
|
67
|
+
* be given the caller's answer rather than the workspace's.
|
|
68
|
+
* `lanes_setup.overview` was handed every profile on disk and named them all,
|
|
69
|
+
* which told a delegated member about profiles `mayReach` deliberately hides
|
|
70
|
+
* from their enum (ADR-068).
|
|
71
|
+
*/
|
|
72
|
+
readonly profiles: readonly string[];
|
|
56
73
|
readonly state: ScopedStore;
|
|
57
74
|
readonly storage: BlobStore;
|
|
58
75
|
readonly credentials: ScopedSecrets;
|