@lanes-sh/link 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
package/src/cli/config-edit.ts
CHANGED
|
@@ -94,6 +94,24 @@ export class ConfigDocument {
|
|
|
94
94
|
this.#expand(path.slice(0, -1));
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Remove a key, leaving everything around it untouched.
|
|
99
|
+
*
|
|
100
|
+
* The counterpart to `setIn`, and it exists because a block a command wrote
|
|
101
|
+
* has to be a block that command can take back. `lanes link knowledge use
|
|
102
|
+
* local` moves memory and skills off a repository, and a `knowledge:` block
|
|
103
|
+
* left behind afterwards would point the profile at the repository it just
|
|
104
|
+
* stopped using — a config that is not merely untidy but wrong.
|
|
105
|
+
*
|
|
106
|
+
* Absent is not an error: removing what is not there is what the caller
|
|
107
|
+
* wanted, and a profile whose targets do not all have the key is the ordinary
|
|
108
|
+
* case rather than a broken one.
|
|
109
|
+
*/
|
|
110
|
+
removeIn(path: readonly (string | number)[]): void {
|
|
111
|
+
if (this.#document.getIn(path as (string | number)[]) === undefined) return;
|
|
112
|
+
this.#document.deleteIn(path as (string | number)[]);
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
/**
|
|
98
116
|
* Append to a sequence, creating it if absent.
|
|
99
117
|
*
|
|
@@ -121,6 +139,22 @@ export class ConfigDocument {
|
|
|
121
139
|
this.#expand(path);
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Drop one item out of a sequence, by position.
|
|
144
|
+
*
|
|
145
|
+
* By index rather than by value because the items this is used on are
|
|
146
|
+
* mappings: matching one by value would mean deciding which fields count as
|
|
147
|
+
* its identity, and the caller has already decided that by finding it. The
|
|
148
|
+
* index is therefore the caller's to compute against the *validated* config,
|
|
149
|
+
* whose order this file preserves.
|
|
150
|
+
*
|
|
151
|
+
* `save` re-validates, so removing an item that something else references
|
|
152
|
+
* fails there rather than landing a config that no longer loads.
|
|
153
|
+
*/
|
|
154
|
+
removeFrom(path: readonly (string | number)[], index: number): void {
|
|
155
|
+
this.#document.deleteIn([...path, index] as (string | number)[]);
|
|
156
|
+
}
|
|
157
|
+
|
|
124
158
|
/**
|
|
125
159
|
* Force a collection we just grew onto multiple lines.
|
|
126
160
|
*
|
|
@@ -176,138 +210,6 @@ export class ConfigDocument {
|
|
|
176
210
|
}
|
|
177
211
|
}
|
|
178
212
|
|
|
179
|
-
/** The reserved provider id the setup surface registers under. */
|
|
180
|
-
const SETUP_PROVIDER_ID = 'setup';
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* What a repair did, split by what a caller does with each half.
|
|
184
|
-
*
|
|
185
|
-
* `connect` reports config edits under `changes` and policy under `granted`,
|
|
186
|
-
* and both are serialised verbatim by `--json` — so an audit asking what a
|
|
187
|
-
* command widened reads only the second, and one blended list of sentences
|
|
188
|
-
* filed the grant as an edit and left prose in a field meant for matching.
|
|
189
|
-
*/
|
|
190
|
-
export interface SetupRepair {
|
|
191
|
-
/** Config edits made, spelled for display. Empty when none were needed. */
|
|
192
|
-
readonly changes: readonly string[];
|
|
193
|
-
/** Allow rules added — patterns, not prose, so a caller can act on them. */
|
|
194
|
-
readonly granted: readonly string[];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Give a profile the `setup` surface if it does not already have it.
|
|
199
|
-
*
|
|
200
|
-
* A profile written before the surface existed has neither the connection row
|
|
201
|
-
* nor the allow rule, and the failure is silent in the worst way:
|
|
202
|
-
* `allowedConnections` returns nothing for a provider with no connection row
|
|
203
|
-
* *before* it consults policy, so `setup_overview` and `setup_provider` are
|
|
204
|
-
* simply absent from `tools/list` with nothing saying why. An agent asked what
|
|
205
|
-
* is connected then has nothing to read and invents a command — which is the
|
|
206
|
-
* bug this exists to close, not a hypothetical.
|
|
207
|
-
*
|
|
208
|
-
* Both halves or neither: a connection row without `setup.*` is as inert as the
|
|
209
|
-
* rule without the row, so adding one alone would look like a fix and change
|
|
210
|
-
* nothing.
|
|
211
|
-
*
|
|
212
|
-
* CLI-side by construction. ADR-007 keeps configuration mutation off the served
|
|
213
|
-
* surface, and a deployed revision holds `objectViewer` on `profiles/`
|
|
214
|
-
* (ADR-023) so it could not write this even if the code let it.
|
|
215
|
-
*/
|
|
216
|
-
export function ensureSetupConnection(document: ConfigDocument): SetupRepair {
|
|
217
|
-
// Raw YAML, so nothing here has been through a schema: this runs over sibling
|
|
218
|
-
// profiles that were never validated, and every field is whatever was typed.
|
|
219
|
-
const config = document.toJSON() as {
|
|
220
|
-
connections?: unknown;
|
|
221
|
-
policy?: { allow?: unknown; deny?: unknown };
|
|
222
|
-
} | null;
|
|
223
|
-
|
|
224
|
-
const rule = `${SETUP_PROVIDER_ID}.*`;
|
|
225
|
-
const covers = (pattern: string): boolean => pattern === '*' || pattern === rule;
|
|
226
|
-
|
|
227
|
-
// Denied on purpose, and a deny beats an allow — so writing the rule would
|
|
228
|
-
// widen nothing while announcing that an agent "can now see what is connected
|
|
229
|
-
// here", which would be false. Deleting the two lines no longer removes the
|
|
230
|
-
// surface, because the next `connect` or `deploy` puts them back; a deny is
|
|
231
|
-
// the way it stays off, so it is the one thing this must not undo.
|
|
232
|
-
//
|
|
233
|
-
// Only a rule covering the whole surface counts. `deny: [setup.provider]` is
|
|
234
|
-
// an operator narrowing it, not switching it off, and that narrowing survives
|
|
235
|
-
// the repair untouched — which is the point of denying one capability.
|
|
236
|
-
if (patternsIn(config?.policy?.deny).some(covers)) return { changes: [], granted: [] };
|
|
237
|
-
|
|
238
|
-
const changes: string[] = [];
|
|
239
|
-
const granted: string[] = [];
|
|
240
|
-
|
|
241
|
-
const connections = Array.isArray(config?.connections) ? config.connections : [];
|
|
242
|
-
const isSetup = (row: unknown): boolean =>
|
|
243
|
-
(row as { provider?: unknown } | null)?.provider === SETUP_PROVIDER_ID;
|
|
244
|
-
|
|
245
|
-
if (!connections.some(isSetup)) {
|
|
246
|
-
// Inline, and `main` for the id, so a repaired profile is spelled exactly
|
|
247
|
-
// like `newProfileTemplate` writes a fresh one. Two spellings of one row is
|
|
248
|
-
// how a template and its repair drift apart.
|
|
249
|
-
document.addTo(
|
|
250
|
-
['connections'],
|
|
251
|
-
{ id: 'main', provider: SETUP_PROVIDER_ID, account: 'Setup' },
|
|
252
|
-
{ inline: true },
|
|
253
|
-
);
|
|
254
|
-
changes.push(`connections += ${SETUP_PROVIDER_ID}.main`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// `*` already covers it. Re-stating the rule under a blanket allow would be
|
|
258
|
-
// noise in the file and a diff the operator did not ask for.
|
|
259
|
-
if (!patternsIn(config?.policy?.allow).some(covers)) {
|
|
260
|
-
document.addTo(['policy', 'allow'], rule, { inline: true });
|
|
261
|
-
granted.push(rule);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return { changes, granted };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Whether a repair did anything, without a caller adding up two lists. */
|
|
268
|
-
export function repaired(repair: SetupRepair): boolean {
|
|
269
|
-
return repair.changes.length > 0 || repair.granted.length > 0;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** The repair as display lines, in the order the two halves are applied. */
|
|
273
|
-
export function repairLines(repair: SetupRepair): string[] {
|
|
274
|
-
return [...repair.changes, ...repair.granted.map((rule) => `policy.allow += ${rule}`)];
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* The patterns a raw policy list puts *in force*, in either spelling.
|
|
279
|
-
*
|
|
280
|
-
* `policyRuleSchema` takes a bare pattern or `{ capability, expires_at }` and
|
|
281
|
-
* both parse to the same thing, so reading only the string form would re-add a
|
|
282
|
-
* rule the operator had already written with an expiry. Anything that is
|
|
283
|
-
* neither is dropped rather than guessed at: this reads unvalidated YAML, and a
|
|
284
|
-
* malformed rule is for `validateConfig` to report, not for this to interpret.
|
|
285
|
-
*
|
|
286
|
-
* **Expiry is part of the reading.** `evaluate` holds a rule to
|
|
287
|
-
* `expiresAt === undefined || expiresAt > now` (`#policy`), so a lapsed rule
|
|
288
|
-
* grants and denies nothing — and reading the capability alone got both
|
|
289
|
-
* directions wrong. A lapsed *allow* read as live, so the repair wrote the row,
|
|
290
|
-
* skipped the rule, and announced success: the inert half-state this exists to
|
|
291
|
-
* prevent. A lapsed *deny* blocked the repair for good and printed nothing,
|
|
292
|
-
* because having nothing to add is how "already had it" looks.
|
|
293
|
-
*
|
|
294
|
-
* An unparseable date reads as lapsed, which is the safe direction — it adds a
|
|
295
|
-
* working rule rather than trusting a broken one, and the `save` that follows
|
|
296
|
-
* hands the malformed value to `validateConfig`, whose job it is to complain.
|
|
297
|
-
*/
|
|
298
|
-
function patternsIn(rules: unknown, now = Date.now()): string[] {
|
|
299
|
-
if (!Array.isArray(rules)) return [];
|
|
300
|
-
|
|
301
|
-
return rules
|
|
302
|
-
.filter((rule) => {
|
|
303
|
-
const expiry = (rule as { expires_at?: unknown } | null)?.expires_at;
|
|
304
|
-
return typeof expiry !== 'string' || Date.parse(expiry) > now;
|
|
305
|
-
})
|
|
306
|
-
.map((rule) =>
|
|
307
|
-
typeof rule === 'string' ? rule : (rule as { capability?: unknown } | null)?.capability,
|
|
308
|
-
)
|
|
309
|
-
.filter((pattern): pattern is string => typeof pattern === 'string');
|
|
310
|
-
}
|
|
311
213
|
|
|
312
214
|
/**
|
|
313
215
|
* A fresh profile config.
|
|
@@ -315,7 +217,7 @@ function patternsIn(rules: unknown, now = Date.now()): string[] {
|
|
|
315
217
|
* Written with comments, because this is the file an operator will read first
|
|
316
218
|
* and most of what it needs to say is *why*, not *what*.
|
|
317
219
|
*/
|
|
318
|
-
export function newProfileTemplate(profile: string, port: number): string {
|
|
220
|
+
export function newProfileTemplate(profile: string, port: number, targets: string): string {
|
|
319
221
|
return `# Lanes Link profile: ${profile}
|
|
320
222
|
#
|
|
321
223
|
# This file is the source of truth for what exists. It never contains a
|
|
@@ -328,17 +230,19 @@ contract: 1
|
|
|
328
230
|
|
|
329
231
|
instance:
|
|
330
232
|
profile: ${profile}
|
|
331
|
-
default_target: local
|
|
332
233
|
port: ${port}
|
|
333
234
|
host: 127.0.0.1
|
|
334
235
|
|
|
335
|
-
# Adapter selection is per target
|
|
336
|
-
#
|
|
236
|
+
# Adapter selection is per target, and every command names the one it means:
|
|
237
|
+
#
|
|
238
|
+
# lanes link status --profile ${profile} --target <name>
|
|
239
|
+
#
|
|
240
|
+
# There is no default. A target is chosen on the command line or not at all,
|
|
241
|
+
# so a flag that goes missing fails here rather than quietly running somewhere
|
|
242
|
+
# else (ADR-037). Everything below "targets" is target-independent and declared
|
|
243
|
+
# exactly once.
|
|
337
244
|
targets:
|
|
338
|
-
|
|
339
|
-
credentials: { adapter: file, path: ./data/${profile}/credentials.enc }
|
|
340
|
-
storage: { adapter: filesystem, path: ./data/${profile} }
|
|
341
|
-
|
|
245
|
+
${targets}
|
|
342
246
|
# The bearer token for the endpoint this profile serves.
|
|
343
247
|
#
|
|
344
248
|
# "lanes link start" serves every profile in the workspace from one URL, and this
|
|
@@ -386,12 +290,17 @@ policy:
|
|
|
386
290
|
`;
|
|
387
291
|
}
|
|
388
292
|
|
|
389
|
-
export function newWorkspaceTemplate(
|
|
293
|
+
export function newWorkspaceTemplate(): string {
|
|
390
294
|
return `# Lanes Link workspace
|
|
391
295
|
#
|
|
392
296
|
# A workspace holds one or more profiles, and one endpoint serves all of them:
|
|
393
|
-
# every call names the profile it means. Profiles never share a
|
|
394
|
-
# credential store, so what one holds is invisible to another.
|
|
297
|
+
# every call names the profile it means, with --profile. Profiles never share a
|
|
298
|
+
# database or a credential store, so what one holds is invisible to another.
|
|
299
|
+
#
|
|
300
|
+
# "deploy" adds a "deployments:" list here. It is an index, not configuration —
|
|
301
|
+
# nothing resolves from it. It records where a deployment lives so that losing
|
|
302
|
+
# the target block out of a profile does not lose the service, the bucket, and
|
|
303
|
+
# the credential store along with it. "lanes link sync targets" reads it.
|
|
395
304
|
contract: 1
|
|
396
|
-
|
|
305
|
+
`;
|
|
397
306
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ConfigDocument } from './config-edit.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Giving a profile a reserved provider it is missing, without undoing a choice.
|
|
5
|
+
*
|
|
6
|
+
* Its own file, apart from `ConfigDocument`, because there are now two callers
|
|
7
|
+
* of the same repair and generalising it in place would have pushed
|
|
8
|
+
* `config-edit.ts` past the file-size budget. The split is along the seam that
|
|
9
|
+
* was already there: that file knows how to *edit* YAML safely, and this one
|
|
10
|
+
* knows what a reserved provider needs to be reachable at all.
|
|
11
|
+
*
|
|
12
|
+
* Two providers hold no account and are therefore invisible without a
|
|
13
|
+
* connection row nobody would think to write: `setup`, which describes what is
|
|
14
|
+
* connected, and `identity`, which says who the owner is. Both are repaired the
|
|
15
|
+
* same way and the rules below are subtle enough that a second copy would drift
|
|
16
|
+
* — which is the whole reason this is one function taking a provider id rather
|
|
17
|
+
* than two that look alike.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The reserved provider ids that hold no account, and the label each row carries. */
|
|
21
|
+
const RESERVED_SURFACES = {
|
|
22
|
+
setup: 'Setup',
|
|
23
|
+
identity: 'Identity',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
type ReservedSurface = keyof typeof RESERVED_SURFACES;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a repair did, split by what a caller does with each half.
|
|
30
|
+
*
|
|
31
|
+
* `connect` reports config edits under `changes` and policy under `granted`,
|
|
32
|
+
* and both are serialised verbatim by `--json` — so an audit asking what a
|
|
33
|
+
* command widened reads only the second, and one blended list of sentences
|
|
34
|
+
* filed the grant as an edit and left prose in a field meant for matching.
|
|
35
|
+
*/
|
|
36
|
+
export interface SurfaceRepair {
|
|
37
|
+
/** Config edits made, spelled for display. Empty when none were needed. */
|
|
38
|
+
readonly changes: readonly string[];
|
|
39
|
+
/** Allow rules added — patterns, not prose, so a caller can act on them. */
|
|
40
|
+
readonly granted: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Give a profile one of those surfaces if it does not already have it.
|
|
45
|
+
*
|
|
46
|
+
* A profile that never had the surface has neither the connection row nor the
|
|
47
|
+
* allow rule, and the failure is silent in the worst way: `allowedConnections`
|
|
48
|
+
* returns nothing for a provider with no connection row *before* it consults
|
|
49
|
+
* policy, so the tools are simply absent from `tools/list` with nothing saying
|
|
50
|
+
* why. It is not a hypothetical in either direction — asked what was connected,
|
|
51
|
+
* an agent with no `setup` surface invented a command; and an `identity` block
|
|
52
|
+
* written without the row is a file saying exactly what its owner meant to an
|
|
53
|
+
* agent that cannot see a word of it.
|
|
54
|
+
*
|
|
55
|
+
* Both halves or neither: a connection row without the matching `<provider>.*`
|
|
56
|
+
* rule is as inert as the rule without the row, so adding one alone would look
|
|
57
|
+
* like a fix and change nothing.
|
|
58
|
+
*
|
|
59
|
+
* CLI-side by construction. ADR-007 keeps configuration mutation off the served
|
|
60
|
+
* surface, and a deployed revision holds `objectViewer` on `profiles/`
|
|
61
|
+
* (ADR-023) so it could not write this even if the code let it.
|
|
62
|
+
*/
|
|
63
|
+
export function ensureReservedConnection(
|
|
64
|
+
document: ConfigDocument,
|
|
65
|
+
provider: ReservedSurface,
|
|
66
|
+
): SurfaceRepair {
|
|
67
|
+
// Raw YAML, so nothing here has been through a schema: this runs over sibling
|
|
68
|
+
// profiles that were never validated, and every field is whatever was typed.
|
|
69
|
+
const config = document.toJSON() as {
|
|
70
|
+
connections?: unknown;
|
|
71
|
+
policy?: { allow?: unknown; deny?: unknown };
|
|
72
|
+
} | null;
|
|
73
|
+
|
|
74
|
+
const rule = `${provider}.*`;
|
|
75
|
+
const covers = (pattern: string): boolean => pattern === '*' || pattern === rule;
|
|
76
|
+
|
|
77
|
+
// Denied on purpose, and a deny beats an allow — so writing the rule would
|
|
78
|
+
// widen nothing while announcing that an agent can now read the surface,
|
|
79
|
+
// which would be false. For `setup`, deleting the two lines no longer removes
|
|
80
|
+
// it either, because the next `connect` or `deploy` puts them back; a deny is
|
|
81
|
+
// the way it stays off, so it is the one thing this must not undo. The same
|
|
82
|
+
// holds for `identity`, where the next `identity add` is what would put them
|
|
83
|
+
// back.
|
|
84
|
+
//
|
|
85
|
+
// Only a rule covering the whole surface counts. `deny: [setup.provider]` is
|
|
86
|
+
// an operator narrowing it, not switching it off, and that narrowing survives
|
|
87
|
+
// the repair untouched — which is the point of denying one capability.
|
|
88
|
+
if (patternsIn(config?.policy?.deny).some(covers)) return { changes: [], granted: [] };
|
|
89
|
+
|
|
90
|
+
const changes: string[] = [];
|
|
91
|
+
const granted: string[] = [];
|
|
92
|
+
|
|
93
|
+
const connections = Array.isArray(config?.connections) ? config.connections : [];
|
|
94
|
+
const declared = (row: unknown): boolean =>
|
|
95
|
+
(row as { provider?: unknown } | null)?.provider === provider;
|
|
96
|
+
|
|
97
|
+
if (!connections.some(declared)) {
|
|
98
|
+
// Inline, and `main` for the id, so a repaired profile is spelled exactly
|
|
99
|
+
// like `newProfileTemplate` writes a fresh one. Two spellings of one row is
|
|
100
|
+
// how a template and its repair drift apart.
|
|
101
|
+
document.addTo(
|
|
102
|
+
['connections'],
|
|
103
|
+
{ id: 'main', provider, account: RESERVED_SURFACES[provider] },
|
|
104
|
+
{ inline: true },
|
|
105
|
+
);
|
|
106
|
+
changes.push(`connections += ${provider}.main`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `*` already covers it. Re-stating the rule under a blanket allow would be
|
|
110
|
+
// noise in the file and a diff the operator did not ask for.
|
|
111
|
+
if (!patternsIn(config?.policy?.allow).some(covers)) {
|
|
112
|
+
document.addTo(['policy', 'allow'], rule, { inline: true });
|
|
113
|
+
granted.push(rule);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { changes, granted };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether a repair did anything, without a caller adding up two lists. */
|
|
120
|
+
export function repaired(repair: SurfaceRepair): boolean {
|
|
121
|
+
return repair.changes.length > 0 || repair.granted.length > 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The repair as display lines, in the order the two halves are applied. */
|
|
125
|
+
export function repairLines(repair: SurfaceRepair): string[] {
|
|
126
|
+
return [...repair.changes, ...repair.granted.map((rule) => `policy.allow += ${rule}`)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The patterns a raw policy list puts *in force*, in either spelling.
|
|
131
|
+
*
|
|
132
|
+
* `policyRuleSchema` takes a bare pattern or `{ capability, expires_at }` and
|
|
133
|
+
* both parse to the same thing, so reading only the string form would re-add a
|
|
134
|
+
* rule the operator had already written with an expiry. Anything that is
|
|
135
|
+
* neither is dropped rather than guessed at: this reads unvalidated YAML, and a
|
|
136
|
+
* malformed rule is for `validateConfig` to report, not for this to interpret.
|
|
137
|
+
*
|
|
138
|
+
* **Expiry is part of the reading.** `evaluate` holds a rule to
|
|
139
|
+
* `expiresAt === undefined || expiresAt > now` (`#policy`), so a lapsed rule
|
|
140
|
+
* grants and denies nothing — and reading the capability alone got both
|
|
141
|
+
* directions wrong. A lapsed *allow* read as live, so the repair wrote the row,
|
|
142
|
+
* skipped the rule, and announced success: the inert half-state this exists to
|
|
143
|
+
* prevent. A lapsed *deny* blocked the repair for good and printed nothing,
|
|
144
|
+
* because having nothing to add is how "already had it" looks.
|
|
145
|
+
*
|
|
146
|
+
* An unparseable date reads as lapsed, which is the safe direction — it adds a
|
|
147
|
+
* working rule rather than trusting a broken one, and the `save` that follows
|
|
148
|
+
* hands the malformed value to `validateConfig`, whose job it is to complain.
|
|
149
|
+
*/
|
|
150
|
+
function patternsIn(rules: unknown, now = Date.now()): string[] {
|
|
151
|
+
if (!Array.isArray(rules)) return [];
|
|
152
|
+
|
|
153
|
+
return rules
|
|
154
|
+
.filter((rule) => {
|
|
155
|
+
const expiry = (rule as { expires_at?: unknown } | null)?.expires_at;
|
|
156
|
+
return typeof expiry !== 'string' || Date.parse(expiry) > now;
|
|
157
|
+
})
|
|
158
|
+
.map((rule) =>
|
|
159
|
+
typeof rule === 'string' ? rule : (rule as { capability?: unknown } | null)?.capability,
|
|
160
|
+
)
|
|
161
|
+
.filter((pattern): pattern is string => typeof pattern === 'string');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The `setup` surface, which every profile is expected to have.
|
|
166
|
+
*
|
|
167
|
+
* A named wrapper rather than a call site passing `'setup'`, because three
|
|
168
|
+
* callers say it and reading `ensureReservedConnection(document, 'setup')` at
|
|
169
|
+
* each of them says less than the name did.
|
|
170
|
+
*/
|
|
171
|
+
export function ensureSetupConnection(document: ConfigDocument): SurfaceRepair {
|
|
172
|
+
return ensureReservedConnection(document, 'setup');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The `identity` surface, which only a profile that declares an identity gets.
|
|
177
|
+
*
|
|
178
|
+
* Unlike `setup`, this is *not* repaired by `connect` or `deploy` — a profile
|
|
179
|
+
* with no identity block has nothing for the surface to report, and registering
|
|
180
|
+
* a tool that answers "nothing declared" on every fresh install would spend a
|
|
181
|
+
* paragraph of the instructions budget to say so. `identity add` is the only
|
|
182
|
+
* caller, so the grant arrives exactly when there is something behind it.
|
|
183
|
+
*/
|
|
184
|
+
export function ensureIdentityConnection(document: ConfigDocument): SurfaceRepair {
|
|
185
|
+
return ensureReservedConnection(document, 'identity');
|
|
186
|
+
}
|