agentcoor 0.0.0-stage → 0.3.7
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 +104 -2
- package/bin/agentcoor.js +6 -0
- package/package.json +29 -4
- package/src/cli.js +334 -0
- package/src/config.js +67 -0
- package/src/connect.js +351 -0
- package/src/fsx.js +125 -0
- package/src/harness.js +138 -0
- package/src/http.js +61 -0
- package/src/market.js +321 -0
- package/src/release-floor.json +3 -0
- package/src/release.js +184 -0
- package/src/sha.js +24 -0
- package/src/share.js +222 -0
- package/src/signature.js +27 -0
- package/src/toml.js +137 -0
package/src/share.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// `agentcoor share-model` and `agentcoor share ...`: share a local
|
|
2
|
+
// OpenAI-compatible model endpoint through the AgentCoor fleet tunnel
|
|
3
|
+
// (docs/marketplace/MODEL-HOSTING.md, CHECKLIST WP-7).
|
|
4
|
+
//
|
|
5
|
+
// Flow: ensure a provider profile, create a model listing (billing issues the
|
|
6
|
+
// identity fleet and returns its bbf_ token exactly once), write the token to
|
|
7
|
+
// a mode-600 file, write share.json, publish, then install and verify a second
|
|
8
|
+
// service unit (agentcoor-share) that runs `agentcoor-agent -share-model`.
|
|
9
|
+
// The fleet token never appears in argv, the environment or the console.
|
|
10
|
+
import { lstat, unlink } from "node:fs/promises";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { installPaths, writePrivateFile } from "./config.js";
|
|
14
|
+
import { apiJson, errorMessage } from "./http.js";
|
|
15
|
+
import { ensurePrivateDir, atomicWrite, readIfExists } from "./fsx.js";
|
|
16
|
+
import { startService, fetchVerifiedAgent, agentBinaryName, stopPidfileAgent, capture, AGENT_SERVICE } from "./connect.js";
|
|
17
|
+
import { withInstallLock, readAcceptedEpoch, recordAcceptedEpoch } from "./release.js";
|
|
18
|
+
|
|
19
|
+
export const SHARE_SERVICE = Object.freeze({ ...AGENT_SERVICE, label: "com.agentcoor.share", unit: "agentcoor-share", description: "AgentCoor model share", log: "share", flag: "-share-config", extra: ["-share-model"] });
|
|
20
|
+
export const DEFAULT_FLEET_URL = "https://cloud.nionto.com";
|
|
21
|
+
export const FLEET_TOKEN = /^bbf_[A-Za-z0-9_-]{8,256}$/;
|
|
22
|
+
const LISTING_ID = /^lst_[A-Za-z0-9_-]{1,64}$/;
|
|
23
|
+
const FLEET_ID = /^flt_[A-Za-z0-9_-]{1,64}$/;
|
|
24
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
|
|
25
|
+
|
|
26
|
+
export function sharePaths(env = process.env, platform = process.platform) {
|
|
27
|
+
const p = installPaths(env, platform);
|
|
28
|
+
const dir = join(p.base, "share");
|
|
29
|
+
return { ...p, dir, shareConfig: join(dir, "share.json"), tokenFile: join(dir, "fleet.token") };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Same rule as internal/modelshare.AllowedUpstreamHost: loopback or tailnet only. */
|
|
33
|
+
export function allowedUpstreamHost(host) {
|
|
34
|
+
const h = String(host).toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
|
|
35
|
+
if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".ts.net")) return true;
|
|
36
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
37
|
+
if (v4) {
|
|
38
|
+
const [a, b] = v4.slice(1).map(Number);
|
|
39
|
+
if (v4.slice(1).some((x) => Number(x) > 255)) return false;
|
|
40
|
+
return a === 127 || (a === 100 && b >= 64 && b <= 127);
|
|
41
|
+
}
|
|
42
|
+
if (h === "::1") return true;
|
|
43
|
+
return /^fd7a:115c:a1e0:/i.test(h);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeEndpoint(raw) {
|
|
47
|
+
let u;
|
|
48
|
+
try { u = new URL(String(raw || "").trim()); } catch { throw new Error(`invalid --endpoint URL: ${raw}`); }
|
|
49
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("--endpoint must be an http(s) URL such as http://127.0.0.1:8000/v1");
|
|
50
|
+
if (u.username || u.password || u.search || u.hash) throw new Error("--endpoint must not contain credentials, a query or a fragment");
|
|
51
|
+
if (!allowedUpstreamHost(u.hostname)) throw new Error(`--endpoint host ${u.hostname} is not loopback or a tailnet address; the endpoint must stay on this machine or your tailnet`);
|
|
52
|
+
return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeFleetUrl(raw) {
|
|
56
|
+
let u;
|
|
57
|
+
try { u = new URL(String(raw || DEFAULT_FLEET_URL).trim()); } catch { throw new Error(`invalid fleet URL: ${raw}`); }
|
|
58
|
+
const loop = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
|
|
59
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loop)) throw new Error("fleet URL must use https");
|
|
60
|
+
if (u.username || u.password) throw new Error("fleet URL must not contain credentials");
|
|
61
|
+
return u.origin;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function intFlag(v, name, min, max, dflt) {
|
|
65
|
+
if (v === undefined) return dflt;
|
|
66
|
+
const n = Number(v);
|
|
67
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`--${name} must be an integer between ${min} and ${max}`);
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validate share-model flags into a listing spec + local settings. Pure; tested. */
|
|
72
|
+
export function shareSpec(f) {
|
|
73
|
+
if (!f.endpoint || !f.model) throw new Error("usage: agentcoor share-model --endpoint http://127.0.0.1:8000/v1 --model <name> [--context N] [--max-concurrency N]");
|
|
74
|
+
if (!MODEL.test(f.model)) throw new Error("--model contains unexpected characters");
|
|
75
|
+
if (f["price-input"] !== undefined || f["price-output"] !== undefined) {
|
|
76
|
+
throw new Error("model listings are paid at AgentCoor tier prices in v1; --price-input/--price-output are not accepted yet (see the earnings rate in the dashboard)");
|
|
77
|
+
}
|
|
78
|
+
const upstream = normalizeEndpoint(f.endpoint);
|
|
79
|
+
const spec = { model: f.model, context: intFlag(f.context, "context", 1024, 2_000_000, 8192), max_concurrency: intFlag(f["max-concurrency"], "max-concurrency", 1, 16, 1) };
|
|
80
|
+
return { spec, upstream, visibility: f.private ? "private" : "public" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function call(api, origin, key, path, opts) {
|
|
84
|
+
const r = await api(origin, path, key, { timeoutMs: 30_000, ...opts });
|
|
85
|
+
if (r.status === 401) throw new Error("AgentCoor rejected this API key");
|
|
86
|
+
return r;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Write the one-time fleet token before anything else can fail, so it is never lost. */
|
|
90
|
+
export async function saveFleetToken(path, token) {
|
|
91
|
+
if (!FLEET_TOKEN.test(String(token || ""))) throw new Error("billing returned no valid fleet token");
|
|
92
|
+
await ensurePrivateDir(dirname(path));
|
|
93
|
+
await atomicWrite(path, `${token}\n`, { mode: 0o600 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readShareConfig(env = process.env) {
|
|
97
|
+
const text = await readIfExists(sharePaths(env).shareConfig);
|
|
98
|
+
if (text === null) throw new Error("no model is shared from this machine (share.json not found); run `agentcoor share-model` first");
|
|
99
|
+
const cfg = JSON.parse(text);
|
|
100
|
+
if (!LISTING_ID.test(String(cfg.listing_id || ""))) throw new Error("share.json has no valid listing_id");
|
|
101
|
+
return cfg;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Ensure the agent binary exists (installed by `agentcoor connect`, else a verified download). */
|
|
105
|
+
export async function ensureAgentBinary({ origin, p, log, fetcher }) {
|
|
106
|
+
if (await lstat(p.bin).catch(() => null)) return;
|
|
107
|
+
for (const d of [p.base, dirname(p.bin), p.state, p.logs]) await ensurePrivateDir(d);
|
|
108
|
+
await withInstallLock(p.base, async () => {
|
|
109
|
+
const name = agentBinaryName();
|
|
110
|
+
log(`==> Downloading ${name}`);
|
|
111
|
+
const { binary, digest, manifest } = await fetchVerifiedAgent(origin, name, { acceptedEpoch: await readAcceptedEpoch(p.base), ...(fetcher ? { fetcher } : {}) });
|
|
112
|
+
await recordAcceptedEpoch(p.base, manifest.epoch);
|
|
113
|
+
await atomicWrite(p.bin, binary, { mode: 0o755, sha256: digest });
|
|
114
|
+
log(`==> Verified signed release ${manifest.version} and SHA-256 ${digest}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function shareModel({ origin, apiKey, flags, env = process.env, log = () => {}, api = apiJson, start = startService, ensureBinary = ensureAgentBinary }) {
|
|
119
|
+
const { spec, upstream, visibility } = shareSpec(flags);
|
|
120
|
+
const fleetBase = normalizeFleetUrl(flags["fleet-url"] || env.AGENTCOOR_FLEET_URL || DEFAULT_FLEET_URL);
|
|
121
|
+
const p = sharePaths(env);
|
|
122
|
+
const existing = await readIfExists(p.shareConfig);
|
|
123
|
+
if (existing !== null && !flags.replace) throw new Error(`a model is already shared from this machine (${p.shareConfig}); run \`agentcoor share withdraw\` first, or pass --replace`);
|
|
124
|
+
|
|
125
|
+
let r = await call(api, origin, apiKey, "/v1/marketplace/providers", { method: "POST", body: {} });
|
|
126
|
+
if (!r.ok) throw new Error(`provider profile: ${errorMessage(r)}`);
|
|
127
|
+
r = await call(api, origin, apiKey, "/v1/marketplace/listings", { method: "POST", body: { product: "model", visibility, spec } });
|
|
128
|
+
if (!r.ok) throw new Error(`create listing: ${errorMessage(r)}`);
|
|
129
|
+
const listingId = r.data?.listing_id;
|
|
130
|
+
if (!LISTING_ID.test(String(listingId || ""))) throw new Error("billing returned no listing id");
|
|
131
|
+
let fleetId = r.data?.fleet_id, token = r.data?.fleet_token;
|
|
132
|
+
if (token) await saveFleetToken(p.tokenFile, token);
|
|
133
|
+
log(`==> Created listing ${listingId} (${spec.model}, ${visibility})`);
|
|
134
|
+
|
|
135
|
+
r = await call(api, origin, apiKey, `/v1/marketplace/listings/${listingId}`, { method: "PATCH", body: { action: "publish" } });
|
|
136
|
+
if (!r.ok) throw new Error(`publish ${listingId}: ${errorMessage(r)} (withdraw it with: agentcoor share withdraw --listing ${listingId})`);
|
|
137
|
+
if (!token && r.data?.fleet_token) { token = r.data.fleet_token; fleetId = r.data.fleet_id; await saveFleetToken(p.tokenFile, token); }
|
|
138
|
+
token = undefined; // never kept around or printed
|
|
139
|
+
if (!(await lstat(p.tokenFile).catch(() => null))) throw new Error(`billing did not issue a fleet for ${listingId}; withdraw it with: agentcoor share withdraw --listing ${listingId}`);
|
|
140
|
+
fleetId = fleetId || r.data?.listing?.fleet_id;
|
|
141
|
+
if (!FLEET_ID.test(String(fleetId || ""))) throw new Error("billing returned no fleet id");
|
|
142
|
+
log(`==> Published: state ${r.data?.state || "probing"}; fleet ${fleetId}; token saved to ${p.tokenFile} (mode 600)`);
|
|
143
|
+
|
|
144
|
+
const share = { fleet_base: fleetBase, fleet_id: fleetId, listing_id: listingId, token_file: p.tokenFile, upstream, model: spec.model, max_concurrency: spec.max_concurrency, gateway: origin };
|
|
145
|
+
await writePrivateFile(p.shareConfig, JSON.stringify(share, null, 2) + "\n");
|
|
146
|
+
log(`==> Share configuration written to ${p.shareConfig}`);
|
|
147
|
+
|
|
148
|
+
await ensureBinary({ origin, p, log });
|
|
149
|
+
await stopPidfileAgent(join(p.state, "share.pid"), [p.bin]);
|
|
150
|
+
await start({ bin: p.bin, config: p.shareConfig, logs: p.logs, state: p.state, log, env, service: SHARE_SERVICE });
|
|
151
|
+
log("==> Done. Probes run next; the listing becomes active when they pass. Check with: agentcoor share status");
|
|
152
|
+
return share;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Stop and remove the agentcoor-share service (never touches the terminal agent). */
|
|
156
|
+
export async function stopShareService({ env = process.env, platform = process.platform, run = capture } = {}) {
|
|
157
|
+
const home = env.HOME || homedir();
|
|
158
|
+
const p = sharePaths(env, platform);
|
|
159
|
+
if (platform === "darwin") {
|
|
160
|
+
run("launchctl", ["bootout", `gui/${process.getuid()}/${SHARE_SERVICE.label}`]);
|
|
161
|
+
await unlink(join(home, "Library", "LaunchAgents", `${SHARE_SERVICE.label}.plist`)).catch(() => {});
|
|
162
|
+
} else {
|
|
163
|
+
run("systemctl", ["--user", "disable", "--now", `${SHARE_SERVICE.unit}.service`]);
|
|
164
|
+
await unlink(join(env.XDG_CONFIG_HOME || join(home, ".config"), "systemd", "user", `${SHARE_SERVICE.unit}.service`)).catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
await stopPidfileAgent(join(p.state, "share.pid"), [p.bin]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function shareCommand(sub, { origin, apiKey, flags, env = process.env, out = () => {}, api = apiJson, stop = stopShareService }) {
|
|
170
|
+
// Earnings are account-wide (models, jobs and tools alike): no listing or
|
|
171
|
+
// share.json is needed, so a jobs/tools-only provider can read them too.
|
|
172
|
+
if (sub === "earnings") {
|
|
173
|
+
const r = await call(api, origin, apiKey, "/v1/marketplace/providers/me/earnings", {});
|
|
174
|
+
if (r.status === 404) { out(`earnings is not available to API keys yet. See ${origin} (Points tab).\n`); return 0; }
|
|
175
|
+
if (!r.ok) throw new Error(`earnings: ${errorMessage(r)}`);
|
|
176
|
+
out(JSON.stringify(r.data, null, 2) + "\n");
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
const listingId = flags.listing || (await readShareConfig(env)).listing_id;
|
|
180
|
+
if (!LISTING_ID.test(listingId)) throw new Error("--listing must be a lst_ id");
|
|
181
|
+
const path = `/v1/marketplace/listings/${listingId}`;
|
|
182
|
+
switch (sub) {
|
|
183
|
+
case "pause": case "resume": case "withdraw": {
|
|
184
|
+
const r = await call(api, origin, apiKey, path, { method: "PATCH", body: { action: sub } });
|
|
185
|
+
if (!r.ok) throw new Error(`${sub}: ${errorMessage(r)}`);
|
|
186
|
+
out(`${listingId}: ${r.data?.state}\n`);
|
|
187
|
+
if (sub === "withdraw") {
|
|
188
|
+
const p = sharePaths(env);
|
|
189
|
+
const cfg = await readShareConfig(env).catch(() => null);
|
|
190
|
+
// Only the listing this machine serves stops the local share service;
|
|
191
|
+
// withdrawing some other listing by id leaves it running.
|
|
192
|
+
if (!flags.listing || cfg?.listing_id === listingId) {
|
|
193
|
+
await stop({ env });
|
|
194
|
+
for (const f of [p.tokenFile, p.shareConfig]) await unlink(f).catch(() => {});
|
|
195
|
+
out("Stopped the agentcoor-share service and removed the fleet token and share.json.\n");
|
|
196
|
+
}
|
|
197
|
+
if (r.data?.fleet_revoked === false) out("warning: billing could not revoke the fleet yet; it retries on the next withdraw call.\n");
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
case "status": {
|
|
202
|
+
const r = await call(api, origin, apiKey, "/v1/marketplace/providers/me/listings", {});
|
|
203
|
+
if (!r.ok) throw new Error(`status: ${errorMessage(r)}`);
|
|
204
|
+
const l = (r.data?.data || []).find((x) => x.id === listingId);
|
|
205
|
+
if (!l) throw new Error(`listing ${listingId} not found for this account`);
|
|
206
|
+
// Billing's owner view does not carry the fleet; share.json on this machine does.
|
|
207
|
+
const local = await readShareConfig(env).catch(() => null);
|
|
208
|
+
const fleet = l.fleet_id || (local?.listing_id === l.id ? local.fleet_id : "") || "none";
|
|
209
|
+
out(`Listing: ${l.id}\nModel: ${l.spec?.model}\nState: ${l.state}${l.state_reason ? ` (${l.state_reason})` : ""}\nRoutable: ${l.routable ? "yes" : "no"}\nHeartbeat: ${l.heartbeat_age_s == null ? "never" : `${l.heartbeat_age_s}s ago`}\nReliability: ${(l.reliability_bp / 100).toFixed(2)}%\nFleet: ${fleet}\n`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
case "usage": {
|
|
213
|
+
const r = await call(api, origin, apiKey, `${path}/usage`, {});
|
|
214
|
+
if (r.status === 404) { out(`${sub} is not available to API keys yet. See ${origin} (Points tab).\n`); return 0; }
|
|
215
|
+
if (!r.ok) throw new Error(`${sub}: ${errorMessage(r)}`);
|
|
216
|
+
out(JSON.stringify(r.data, null, 2) + "\n");
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
throw new Error("usage: agentcoor share <pause|resume|withdraw|status|usage|earnings> [--listing lst_...]");
|
|
221
|
+
}
|
|
222
|
+
}
|
package/src/signature.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createPublicKey, verify, createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AgentCoor release signing key (Ed25519, public half). release.json (which
|
|
5
|
+
* also lists install.sh by SHA-256 and size) is signed offline with the private half (scripts/sign-release.sh);
|
|
6
|
+
* see release.js for what a verified manifest must also satisfy. Keep in sync
|
|
7
|
+
* with web/public/install.sh, docs/SECURITY.md and the dashboard.
|
|
8
|
+
*/
|
|
9
|
+
export const RELEASE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
10
|
+
MCowBQYDK2VwAyEAC27Sl1eaSoUeW7S30V0+21v/EpsL7Nxl2mVIPJ8Ni3U=
|
|
11
|
+
-----END PUBLIC KEY-----
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
/** sha256 of the DER SubjectPublicKeyInfo (matches `openssl pkey -pubin -outform DER | openssl dgst -sha256`). */
|
|
15
|
+
export function keyFingerprint(pem = RELEASE_PUBLIC_KEY) {
|
|
16
|
+
return createHash("sha256").update(createPublicKey(pem).export({ type: "spki", format: "der" })).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fail closed: throws unless `sig` is a valid Ed25519 signature of the exact bytes `data` by the release key. */
|
|
20
|
+
export function verifyReleaseSignature(data, sig, pem = RELEASE_PUBLIC_KEY, label = "release.json") {
|
|
21
|
+
if (!sig || sig.length === 0) throw new Error(`${label}.sig is missing: refusing to trust an unsigned ${label}`);
|
|
22
|
+
if (sig.length !== 64) throw new Error(`${label}.sig is malformed: refusing to trust ${label}`);
|
|
23
|
+
const key = createPublicKey(pem);
|
|
24
|
+
if (key.asymmetricKeyType !== "ed25519") throw new Error("release key is not Ed25519");
|
|
25
|
+
if (!verify(null, Buffer.from(data), key, Buffer.from(sig))) throw new Error(`${label} signature is invalid: refusing to trust it`);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
package/src/toml.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Minimal, conservative TOML structure scanner. It does not evaluate values; it
|
|
2
|
+
// only finds top-level table headers and refuses input it cannot account for, so
|
|
3
|
+
// we never rewrite a config file we do not understand.
|
|
4
|
+
|
|
5
|
+
const KEY = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
|
|
6
|
+
const DOTTED = String.raw`${KEY}(?:\s*\.\s*${KEY})*`;
|
|
7
|
+
const HEADER = new RegExp(String.raw`^\s*(\[\[?)\s*(${DOTTED})\s*(\]\]?)\s*(?:#.*)?$`);
|
|
8
|
+
const KV = new RegExp(String.raw`^\s*${DOTTED}\s*=`);
|
|
9
|
+
|
|
10
|
+
const unescapeBasic = (body, line) => body.replace(/\\(?:u([0-9A-Fa-f]{4})|U([0-9A-Fa-f]{8})|(.))/g, (m, u4, u8, c) => {
|
|
11
|
+
if (u4 || u8) {
|
|
12
|
+
const cp = parseInt(u4 || u8, 16);
|
|
13
|
+
if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) throw new Error(`invalid unicode escape in key on line ${line}`);
|
|
14
|
+
return String.fromCodePoint(cp);
|
|
15
|
+
}
|
|
16
|
+
const map = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", e: "\x1b", '"': '"', "\\": "\\" };
|
|
17
|
+
if (!(c in map)) throw new Error(`invalid escape in key on line ${line}`);
|
|
18
|
+
return map[c];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Split a TOML dotted key into its canonical components: bare keys as written,
|
|
23
|
+
* "basic" keys unescaped, 'literal' keys verbatim. So [a."b"], [ a . 'b' ] and
|
|
24
|
+
* [a.b] all canonicalize to ["a", "b"], while [a."b.c"] is ["a", "b.c"].
|
|
25
|
+
*/
|
|
26
|
+
export function parseDottedKey(src, line = 0) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
let i = 0;
|
|
29
|
+
const s = String(src);
|
|
30
|
+
const ws = () => { while (i < s.length && (s[i] === " " || s[i] === "\t")) i++; };
|
|
31
|
+
for (;;) {
|
|
32
|
+
ws();
|
|
33
|
+
if (s[i] === '"') {
|
|
34
|
+
let j = i + 1;
|
|
35
|
+
while (j < s.length && s[j] !== '"') j += s[j] === "\\" ? 2 : 1;
|
|
36
|
+
if (j >= s.length) throw new Error(`unterminated quoted key on line ${line}`);
|
|
37
|
+
parts.push(unescapeBasic(s.slice(i + 1, j), line)); i = j + 1;
|
|
38
|
+
} else if (s[i] === "'") {
|
|
39
|
+
const j = s.indexOf("'", i + 1);
|
|
40
|
+
if (j < 0) throw new Error(`unterminated quoted key on line ${line}`);
|
|
41
|
+
parts.push(s.slice(i + 1, j)); i = j + 1;
|
|
42
|
+
} else {
|
|
43
|
+
const m = /^[A-Za-z0-9_-]+/.exec(s.slice(i));
|
|
44
|
+
if (!m) throw new Error(`malformed key on line ${line}`);
|
|
45
|
+
parts.push(m[0]); i += m[0].length;
|
|
46
|
+
}
|
|
47
|
+
ws();
|
|
48
|
+
if (i >= s.length) return parts;
|
|
49
|
+
if (s[i] !== ".") throw new Error(`malformed key on line ${line}`);
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when `path` is the table `name` (a dotted key string) or one of its sub-tables. */
|
|
55
|
+
export function tableOwnedBy(path, name) {
|
|
56
|
+
const want = parseDottedKey(name);
|
|
57
|
+
return path.length >= want.length && want.every((c, k) => path[k] === c);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns [{ line, name, path }] for each top-level table header. Throws on anything
|
|
62
|
+
* that does not look like TOML (unbalanced brackets or strings, stray lines).
|
|
63
|
+
*/
|
|
64
|
+
export function scanToml(text) {
|
|
65
|
+
const lines = String(text).split("\n");
|
|
66
|
+
const headers = [];
|
|
67
|
+
let depth = 0; // [] and {} nesting inside a value
|
|
68
|
+
let ml = null; // inside a multi-line string: '"""' or "'''"
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
const topLevel = depth === 0 && ml === null;
|
|
72
|
+
if (topLevel) {
|
|
73
|
+
const t = line.trim();
|
|
74
|
+
if (t === "" || t.startsWith("#")) continue;
|
|
75
|
+
const h = line.match(HEADER);
|
|
76
|
+
if (h) {
|
|
77
|
+
if ((h[1] === "[[") !== (h[3] === "]]")) throw new Error(`malformed TOML header on line ${i + 1}`);
|
|
78
|
+
headers.push({ line: i, name: h[2].replace(/\s+/g, ""), path: parseDottedKey(h[2], i + 1) });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!KV.test(line)) throw new Error(`malformed TOML on line ${i + 1}`);
|
|
82
|
+
}
|
|
83
|
+
// Scan characters to track strings and bracket depth.
|
|
84
|
+
let j = topLevel ? line.indexOf("=") + 1 : 0;
|
|
85
|
+
while (j < line.length) {
|
|
86
|
+
if (ml) {
|
|
87
|
+
const end = line.indexOf(ml, j);
|
|
88
|
+
if (end < 0) { j = line.length; break; }
|
|
89
|
+
j = end + 3; ml = null; continue;
|
|
90
|
+
}
|
|
91
|
+
const c = line[j];
|
|
92
|
+
if (c === "#") break;
|
|
93
|
+
if (line.startsWith('"""', j) || line.startsWith("'''", j)) { ml = line.slice(j, j + 3); j += 3; continue; }
|
|
94
|
+
if (c === '"') {
|
|
95
|
+
j++;
|
|
96
|
+
while (j < line.length && line[j] !== '"') j += line[j] === "\\" ? 2 : 1;
|
|
97
|
+
if (j >= line.length) throw new Error(`unterminated string on line ${i + 1}`);
|
|
98
|
+
j++; continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === "'") {
|
|
101
|
+
const end = line.indexOf("'", j + 1);
|
|
102
|
+
if (end < 0) throw new Error(`unterminated string on line ${i + 1}`);
|
|
103
|
+
j = end + 1; continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === "[" || c === "{") depth++;
|
|
106
|
+
else if (c === "]" || c === "}") { depth--; if (depth < 0) throw new Error(`unbalanced brackets on line ${i + 1}`); }
|
|
107
|
+
j++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (depth !== 0 || ml !== null) throw new Error("malformed TOML: unterminated array, table or string");
|
|
111
|
+
return headers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove the tables named in `names` (and their sub-tables, e.g. mcp_servers.agentcoor.env)
|
|
116
|
+
* plus the block's own leading comment line, then append `block`. Idempotent: running it twice gives the same text.
|
|
117
|
+
*/
|
|
118
|
+
export function replaceTomlTables(text, names, block) {
|
|
119
|
+
const first = block.replace(/^\n+/, "").split("\n")[0];
|
|
120
|
+
const marker = first.startsWith("#") ? first : null;
|
|
121
|
+
const src = String(text || "");
|
|
122
|
+
const headers = scanToml(src);
|
|
123
|
+
const lines = src.split("\n");
|
|
124
|
+
const drop = new Set();
|
|
125
|
+
headers.forEach((h, k) => {
|
|
126
|
+
if (!names.some((x) => tableOwnedBy(h.path, x))) return;
|
|
127
|
+
const end = k + 1 < headers.length ? headers[k + 1].line : lines.length;
|
|
128
|
+
for (let i = h.line; i < end; i++) drop.add(i);
|
|
129
|
+
});
|
|
130
|
+
const kept = lines.filter((l, i) => !drop.has(i) && (marker === null || l !== marker));
|
|
131
|
+
let out = kept.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "");
|
|
132
|
+
out = (out ? `${out}\n\n` : "") + block.replace(/^\n+/, "").replace(/\s+$/, "") + "\n";
|
|
133
|
+
scanToml(out);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const tomlString = (s) => JSON.stringify(String(s)); // JSON string escapes are valid TOML basic-string escapes
|