@consiliency/agent-board-cli 1.22.5 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +5 -2
- package/dist/cli.js +201 -17
- package/dist/doctor.d.ts +104 -0
- package/dist/doctor.js +360 -0
- package/dist/doctor_scopes.d.ts +128 -0
- package/dist/doctor_scopes.js +757 -0
- package/dist/enroll_runtime.js +5 -13
- package/dist/identity_writer.d.ts +44 -0
- package/dist/identity_writer.js +186 -0
- package/package.json +2 -2
package/dist/enroll_runtime.js
CHANGED
|
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { validateFinalizedFleetHarnessTemplate, markAuthoredCode } from "@consiliency/agent-board-client";
|
|
12
12
|
import { resolveEnrollmentDescriptor, } from "@consiliency/agent-board-client";
|
|
13
|
+
import { writeOwnerOnlyFile } from "./identity_writer.js";
|
|
13
14
|
export class EnrollRuntimeError extends Error {
|
|
14
15
|
code;
|
|
15
16
|
constructor(code, message) {
|
|
@@ -233,19 +234,10 @@ export async function publishFinalizedFleetTemplate(root, template, options = {}
|
|
|
233
234
|
const staged = join(generationRoot, `.${template.receiptDigest}.staged-${process.pid}-${Date.now()}`);
|
|
234
235
|
await fs.mkdir(staged, { mode: 0o700 });
|
|
235
236
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
await handle.writeFile(content, "utf8");
|
|
241
|
-
await handle.sync();
|
|
242
|
-
}
|
|
243
|
-
finally {
|
|
244
|
-
await handle.close();
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
await write("runtime.env", runtimeEnv);
|
|
248
|
-
await write("pmcp.json", pmcp);
|
|
237
|
+
// Straight into the staged directory, not temp+rename: the generation becomes visible
|
|
238
|
+
// only when the whole directory is renamed below.
|
|
239
|
+
await writeOwnerOnlyFile(join(staged, "runtime.env"), runtimeEnv);
|
|
240
|
+
await writeOwnerOnlyFile(join(staged, "pmcp.json"), pmcp);
|
|
249
241
|
await sync(staged);
|
|
250
242
|
await fs.rename(staged, generation);
|
|
251
243
|
await sync(generationRoot);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type EnrollmentOutcome } from "@consiliency/agent-board-client";
|
|
2
|
+
export interface OwnerOnlyWriteOptions {
|
|
3
|
+
syncDirectory?: (path: string) => Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
/** Create `path` exclusively (never following a planted link) at 0600, write, fsync. */
|
|
6
|
+
export declare function writeOwnerOnlyFile(path: string, content: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Write `content` to `path` so a reader sees the old file or the whole new one, never a
|
|
9
|
+
* part: an exclusive 0600 temp beside it, fsync'd, renamed over it, then the directory
|
|
10
|
+
* fsync'd. On any throw before the rename the temp is removed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function writeOwnerOnlyFileAtomic(path: string, content: string, options?: OwnerOnlyWriteOptions): Promise<void>;
|
|
13
|
+
export interface IdentityDeployment {
|
|
14
|
+
boardUrl: string;
|
|
15
|
+
sessionUrl: string;
|
|
16
|
+
publishableKey: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Where each deployment value is read, in precedence order: the enrollment's own names first
|
|
20
|
+
* (so the file records the deployment the enrollment used — pinned against the client's
|
|
21
|
+
* enrollment resolvers in the test), then the identity names. The generic Supabase names the
|
|
22
|
+
* enrollment also accepts for the publishable key are NOT read: a generic key never fills an
|
|
23
|
+
* identity field, here or in the loader.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEPLOYMENT_ENV_NAMES: Readonly<Record<keyof IdentityDeployment, readonly string[]>>;
|
|
26
|
+
/**
|
|
27
|
+
* The deployment half of an identity, read and shape-checked before enrollment commits.
|
|
28
|
+
* Absent → `identity_incomplete`; not the non-secret shape its key requires →
|
|
29
|
+
* `identity_secret_value`. Both name the variable, never the value.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveIdentityDeployment(env: NodeJS.ProcessEnv): IdentityDeployment;
|
|
32
|
+
/**
|
|
33
|
+
* The identity file's values for an enrolled agent. The URLs and publishable key come only
|
|
34
|
+
* from `deployment`; everything else from the outcome. Validated by the client's serializer
|
|
35
|
+
* before it is returned.
|
|
36
|
+
*/
|
|
37
|
+
export declare function identityValuesFromEnrollment(outcome: EnrollmentOutcome, deployment: IdentityDeployment): Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* Write `harness`'s identity file for this host. Every refusal (bad harness, relative root,
|
|
40
|
+
* invalid host, bad value) fires before the first filesystem change. The directory is then
|
|
41
|
+
* put through the reader's own trusted walk, so the writer never produces a file the loader
|
|
42
|
+
* would refuse to open. Returns the path written.
|
|
43
|
+
*/
|
|
44
|
+
export declare function writeIdentityFile(harness: string, values: Readonly<Record<string, string>>, env: NodeJS.ProcessEnv, options?: OwnerOnlyWriteOptions): Promise<string>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// CREDPATH SL-2 — the identity file WRITER. The contract (key set, value shapes, path, trusted
|
|
2
|
+
// walk) is the client's `identity_file.ts`; nothing here restates it. This module only decides
|
|
3
|
+
// which VALUES go in, and writes them the way the reader will accept.
|
|
4
|
+
//
|
|
5
|
+
// Order matters, because the Portal enrollment token is single-use
|
|
6
|
+
// (Consiliency/message-board#521): `resolveIdentityDeployment` runs BEFORE enrollment commits,
|
|
7
|
+
// so a missing or secret-shaped deployment value is refused while the token is still unspent.
|
|
8
|
+
// Only then does enrollment run, and `identityValuesFromEnrollment` + `writeIdentityFile` turn
|
|
9
|
+
// its outcome into the file.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { constants as FS } from "node:fs";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { CredentialResolutionError, ENV_ALIAS_FAMILIES, identityConfigRoot, identityFilePath, serializeIdentityFile, walkTrustedDirectory, } from "@consiliency/agent-board-client";
|
|
15
|
+
async function syncDirectory(path) {
|
|
16
|
+
const handle = await fs.open(path, "r");
|
|
17
|
+
try {
|
|
18
|
+
await handle.sync();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await handle.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Create `path` exclusively (never following a planted link) at 0600, write, fsync. */
|
|
25
|
+
export async function writeOwnerOnlyFile(path, content) {
|
|
26
|
+
const handle = await fs.open(path, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
|
|
27
|
+
try {
|
|
28
|
+
await handle.writeFile(content, "utf8");
|
|
29
|
+
await handle.sync();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Write `content` to `path` so a reader sees the old file or the whole new one, never a
|
|
37
|
+
* part: an exclusive 0600 temp beside it, fsync'd, renamed over it, then the directory
|
|
38
|
+
* fsync'd. On any throw before the rename the temp is removed.
|
|
39
|
+
*/
|
|
40
|
+
export async function writeOwnerOnlyFileAtomic(path, content, options = {}) {
|
|
41
|
+
const sync = options.syncDirectory ?? syncDirectory;
|
|
42
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
43
|
+
try {
|
|
44
|
+
await writeOwnerOnlyFile(temp, content);
|
|
45
|
+
await fs.rename(temp, path);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
await fs.unlink(temp).catch(() => { });
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
await sync(dirname(path));
|
|
52
|
+
}
|
|
53
|
+
/** The identity-name alias family containing `canonical`, from the client's one declaration. */
|
|
54
|
+
function identityFamily(canonical) {
|
|
55
|
+
const family = ENV_ALIAS_FAMILIES.find((members) => members.includes(canonical));
|
|
56
|
+
if (!family)
|
|
57
|
+
throw new Error(`identity_writer: no alias family declares ${canonical}`);
|
|
58
|
+
return family;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where each deployment value is read, in precedence order: the enrollment's own names first
|
|
62
|
+
* (so the file records the deployment the enrollment used — pinned against the client's
|
|
63
|
+
* enrollment resolvers in the test), then the identity names. The generic Supabase names the
|
|
64
|
+
* enrollment also accepts for the publishable key are NOT read: a generic key never fills an
|
|
65
|
+
* identity field, here or in the loader.
|
|
66
|
+
*/
|
|
67
|
+
export const DEPLOYMENT_ENV_NAMES = Object.freeze({
|
|
68
|
+
boardUrl: Object.freeze(["MESSAGE_BOARD_ENROLL_BOARD_URL", ...identityFamily("AGENT_BOARD_URL")]),
|
|
69
|
+
sessionUrl: Object.freeze(["MESSAGE_BOARD_ENROLL_SESSION_URL", "AGENT_BOARD_ENROLL_SESSION_URL", ...identityFamily("AGENT_BOARD_SESSION_URL")]),
|
|
70
|
+
publishableKey: Object.freeze(["MESSAGE_BOARD_ENROLL_PUBLISHABLE_KEY", "AGENT_BOARD_ENROLL_PUBLISHABLE_KEY", ...identityFamily("AGENT_BOARD_PUBLISHABLE_KEY")]),
|
|
71
|
+
});
|
|
72
|
+
const DEPLOYMENT_FILE_KEYS = {
|
|
73
|
+
boardUrl: "AGENT_BOARD_URL",
|
|
74
|
+
sessionUrl: "AGENT_BOARD_SESSION_URL",
|
|
75
|
+
publishableKey: "AGENT_BOARD_PUBLISHABLE_KEY",
|
|
76
|
+
};
|
|
77
|
+
// Well-shaped stand-ins for the values only the enrollment outcome supplies. The preflight
|
|
78
|
+
// substitutes ONE deployment value into this map and lets `serializeIdentityFile` judge it, so
|
|
79
|
+
// the shape rule applied here is the parser's own, never a second copy.
|
|
80
|
+
const PREFLIGHT_STAND_INS = {
|
|
81
|
+
AGENT_BOARD_URL: "https://preflight.invalid",
|
|
82
|
+
AGENT_BOARD_SESSION_URL: "https://preflight.invalid/session",
|
|
83
|
+
AGENT_BOARD_PUBLISHABLE_KEY: "sb_publishable_preflight",
|
|
84
|
+
AGENT_BOARD_AGENT_SLUG: "preflight",
|
|
85
|
+
AGENT_BOARD_EXPECTED_AGENT_HANDLE: "preflight",
|
|
86
|
+
AGENT_BOARD_AGENT_KEY_REFERENCE: "os_keychain:preflight",
|
|
87
|
+
AGENT_BOARD_SIGNING_KEY_ID: "preflight",
|
|
88
|
+
AGENT_BOARD_SIGNING_KEY_VERSION: "1",
|
|
89
|
+
AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT: `sha256:${"A".repeat(43)}`,
|
|
90
|
+
MESSAGE_BOARD_AGENT_AUTH_MODE: "keystore_session",
|
|
91
|
+
};
|
|
92
|
+
function firstPresent(env, names) {
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const value = env[name];
|
|
95
|
+
if (value !== undefined && value.trim() !== "")
|
|
96
|
+
return { name, value: value.trim() };
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The deployment half of an identity, read and shape-checked before enrollment commits.
|
|
102
|
+
* Absent → `identity_incomplete`; not the non-secret shape its key requires →
|
|
103
|
+
* `identity_secret_value`. Both name the variable, never the value.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveIdentityDeployment(env) {
|
|
106
|
+
const deployment = {};
|
|
107
|
+
for (const field of Object.keys(DEPLOYMENT_ENV_NAMES)) {
|
|
108
|
+
const names = DEPLOYMENT_ENV_NAMES[field];
|
|
109
|
+
const found = firstPresent(env, names);
|
|
110
|
+
if (!found) {
|
|
111
|
+
throw new CredentialResolutionError("identity_incomplete", `cannot write an identity file: no ${DEPLOYMENT_FILE_KEYS[field]} for it (set one of: ${names.join(", ")})`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
serializeIdentityFile({ ...PREFLIGHT_STAND_INS, [DEPLOYMENT_FILE_KEYS[field]]: found.value });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code !== "identity_secret_value")
|
|
118
|
+
throw error;
|
|
119
|
+
throw new CredentialResolutionError("identity_secret_value", `${found.name} is not the non-secret shape ${DEPLOYMENT_FILE_KEYS[field]} requires; an identity file carries public values only`);
|
|
120
|
+
}
|
|
121
|
+
deployment[field] = found.value;
|
|
122
|
+
}
|
|
123
|
+
return deployment;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------------------------
|
|
126
|
+
// Outcome → values → file
|
|
127
|
+
// ---------------------------------------------------------------------------------------------
|
|
128
|
+
/** `sha256:` + base64url, over the DECODED public key — exactly what fleet finalize recomputes. */
|
|
129
|
+
function publicKeyFingerprint(agentPublicKey) {
|
|
130
|
+
const publicKey = Buffer.from(agentPublicKey, "base64url");
|
|
131
|
+
// The same refusal finalize makes: a value that is not canonical base64url of 32 bytes would
|
|
132
|
+
// still hash to a plausible fingerprint, of a key that is not the enrolled one. The
|
|
133
|
+
// re-encode comparison is also the charset and padding check.
|
|
134
|
+
if (publicKey.length !== 32 || publicKey.toString("base64url") !== agentPublicKey) {
|
|
135
|
+
throw new CredentialResolutionError("identity_public_key_invalid", "the enrollment outcome's public key is not a 32-byte base64url key");
|
|
136
|
+
}
|
|
137
|
+
return `sha256:${createHash("sha256").update(publicKey).digest("base64url")}`;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The identity file's values for an enrolled agent. The URLs and publishable key come only
|
|
141
|
+
* from `deployment`; everything else from the outcome. Validated by the client's serializer
|
|
142
|
+
* before it is returned.
|
|
143
|
+
*/
|
|
144
|
+
export function identityValuesFromEnrollment(outcome, deployment) {
|
|
145
|
+
const values = {
|
|
146
|
+
AGENT_BOARD_URL: deployment.boardUrl,
|
|
147
|
+
AGENT_BOARD_SESSION_URL: deployment.sessionUrl,
|
|
148
|
+
AGENT_BOARD_PUBLISHABLE_KEY: deployment.publishableKey,
|
|
149
|
+
AGENT_BOARD_AGENT_SLUG: outcome.agentHandle,
|
|
150
|
+
AGENT_BOARD_EXPECTED_AGENT_HANDLE: outcome.agentHandle,
|
|
151
|
+
// The form `fleet_onboarding.ts` checks: `${resolver}:${handle}`.
|
|
152
|
+
AGENT_BOARD_AGENT_KEY_REFERENCE: `${outcome.keyReference.resolver}:${outcome.keyReference.handle}`,
|
|
153
|
+
// `keyId`, the server's key identity /session mints against — NOT `signingKeyId`, the row.
|
|
154
|
+
AGENT_BOARD_SIGNING_KEY_ID: outcome.keyId,
|
|
155
|
+
AGENT_BOARD_SIGNING_KEY_VERSION: String(outcome.keyVersion),
|
|
156
|
+
AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT: publicKeyFingerprint(outcome.agentPublicKey),
|
|
157
|
+
MESSAGE_BOARD_AGENT_AUTH_MODE: "keystore_session",
|
|
158
|
+
};
|
|
159
|
+
serializeIdentityFile(values);
|
|
160
|
+
return values;
|
|
161
|
+
}
|
|
162
|
+
async function mkdirPrivate(path) {
|
|
163
|
+
await fs.mkdir(path, { mode: 0o700 }).catch((error) => {
|
|
164
|
+
if (error.code !== "EEXIST")
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Write `harness`'s identity file for this host. Every refusal (bad harness, relative root,
|
|
170
|
+
* invalid host, bad value) fires before the first filesystem change. The directory is then
|
|
171
|
+
* put through the reader's own trusted walk, so the writer never produces a file the loader
|
|
172
|
+
* would refuse to open. Returns the path written.
|
|
173
|
+
*/
|
|
174
|
+
export async function writeIdentityFile(harness, values, env, options = {}) {
|
|
175
|
+
const path = identityFilePath(harness, env);
|
|
176
|
+
const content = serializeIdentityFile(values);
|
|
177
|
+
const configRoot = identityConfigRoot(env);
|
|
178
|
+
const strictFrom = join(configRoot, "agent-board");
|
|
179
|
+
await fs.mkdir(configRoot, { recursive: true, mode: 0o700 });
|
|
180
|
+
await mkdirPrivate(strictFrom);
|
|
181
|
+
await mkdirPrivate(join(strictFrom, "identities"));
|
|
182
|
+
await mkdirPrivate(dirname(path));
|
|
183
|
+
walkTrustedDirectory(dirname(path), strictFrom);
|
|
184
|
+
await writeOwnerOnlyFileAtomic(path, content, options);
|
|
185
|
+
return path;
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"registry": "https://registry.npmjs.org"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@consiliency/agent-board-client": "1.
|
|
34
|
+
"@consiliency/agent-board-client": "1.39.0",
|
|
35
35
|
"commander": "^14.0.1"
|
|
36
36
|
},
|
|
37
37
|
"license": "Apache-2.0"
|