@themoltnet/agent-daemon 0.55.1 → 0.56.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 +6 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2017 -1214
- package/package.json +7 -7
package/dist/cli.js
CHANGED
|
@@ -30,17 +30,18 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
30
30
|
import { mkdir, open, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
31
31
|
import { pipeline } from "node:stream/promises";
|
|
32
32
|
import { createInterface } from "node:readline/promises";
|
|
33
|
+
import { ModelRuntime, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
35
|
+
import { lock, lockSync } from "proper-lockfile";
|
|
36
|
+
import { isIP } from "node:net";
|
|
33
37
|
import cors from "@fastify/cors";
|
|
34
38
|
import helmet from "@fastify/helmet";
|
|
35
|
-
import { lock, lockSync } from "proper-lockfile";
|
|
36
|
-
import { ModelRuntime, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
37
39
|
import { Transform, Writable } from "node:stream";
|
|
38
40
|
import { writePiConfig } from "@themoltnet/pi-runtime/pi-config";
|
|
39
41
|
import { pathToFileURL } from "node:url";
|
|
40
42
|
import { StringDecoder } from "node:string_decoder";
|
|
41
43
|
import rateLimit from "@fastify/rate-limit";
|
|
42
44
|
import Fastify from "fastify";
|
|
43
|
-
import { isIP } from "node:net";
|
|
44
45
|
import "reflect-metadata";
|
|
45
46
|
import { homedir, platform } from "node:os";
|
|
46
47
|
import { BasicConstraintsExtension, ExtendedKeyUsage, ExtendedKeyUsageExtension, IP, KeyUsageFlags, KeyUsagesExtension, SubjectAlternativeNameExtension, X509CertificateGenerator } from "@peculiar/x509";
|
|
@@ -772,6 +773,7 @@ var CREDENTIAL_SCOPES = {
|
|
|
772
773
|
TaskExecute: "task:execute",
|
|
773
774
|
TaskManage: "task:manage",
|
|
774
775
|
TaskRead: "task:read",
|
|
776
|
+
TaskWrite: "task:write",
|
|
775
777
|
TeamManage: "team:manage",
|
|
776
778
|
TeamRead: "team:read"
|
|
777
779
|
};
|
|
@@ -814,6 +816,7 @@ var MCP_CLIENT_SCOPES = [
|
|
|
814
816
|
CREDENTIAL_SCOPES.TaskExecute,
|
|
815
817
|
CREDENTIAL_SCOPES.TaskManage,
|
|
816
818
|
CREDENTIAL_SCOPES.TaskRead,
|
|
819
|
+
CREDENTIAL_SCOPES.TaskWrite,
|
|
817
820
|
CREDENTIAL_SCOPES.TeamManage,
|
|
818
821
|
CREDENTIAL_SCOPES.TeamRead
|
|
819
822
|
];
|
|
@@ -3458,6 +3461,8 @@ Commands:
|
|
|
3458
3461
|
child processes. Binds 127.0.0.1 only.
|
|
3459
3462
|
server trust
|
|
3460
3463
|
Install the per-user macOS local-HTTPS CA after explicit consent.
|
|
3464
|
+
providers Manage configured endpoints and Pi OAuth subscriptions without
|
|
3465
|
+
starting the Agent Server. See \`agent-daemon providers --help\`.
|
|
3461
3466
|
sync-sessions
|
|
3462
3467
|
Repair durable runtime-session checkpoints from local slot files.
|
|
3463
3468
|
update check
|
|
@@ -3624,6 +3629,24 @@ On macOS, the first interactive run asks to trust a per-user local CA in the
|
|
|
3624
3629
|
login keychain and serves HTTPS. Run \`agent-daemon server trust --remove\` to
|
|
3625
3630
|
remove that exact CA. Linux continues to use the Chromium PNA HTTP path.
|
|
3626
3631
|
`;
|
|
3632
|
+
var PROVIDERS_HELP = `\
|
|
3633
|
+
moltnet-agent providers — manage local model providers.
|
|
3634
|
+
|
|
3635
|
+
Usage:
|
|
3636
|
+
moltnet-agent providers list [--json] [--root <path>]
|
|
3637
|
+
moltnet-agent providers set <id> [--base-url <url>] [--api <pi-api-kind>]
|
|
3638
|
+
[--model <id> ... | --clear-models]
|
|
3639
|
+
[--api-key-stdin | --clear-api-key] [--root <path>]
|
|
3640
|
+
moltnet-agent providers discover <id> [--save] [--json] [--root <path>]
|
|
3641
|
+
moltnet-agent providers remove <id> [--yes] [--root <path>]
|
|
3642
|
+
moltnet-agent providers login <id> [--auth-method <method-id>]
|
|
3643
|
+
[--root <path>]
|
|
3644
|
+
moltnet-agent providers logout <id> [--yes] [--root <path>]
|
|
3645
|
+
|
|
3646
|
+
The default root is ~/.config/moltnet. MOLTNET_AGENT_SERVER_ROOT remains the
|
|
3647
|
+
environment override. API keys are accepted only from redirected stdin; they
|
|
3648
|
+
are stored separately and providers.json contains only a secret reference.
|
|
3649
|
+
`;
|
|
3627
3650
|
//#endregion
|
|
3628
3651
|
//#region src/lib/identity-pin.ts
|
|
3629
3652
|
/** Compare every pinned field without choosing a caller-specific error type. */
|
|
@@ -3913,7 +3936,8 @@ function loadAgentServerEnvConfig() {
|
|
|
3913
3936
|
allowedOrigins: process.env["MOLTNET_AGENT_SERVER_ALLOWED_ORIGINS"] ?? "",
|
|
3914
3937
|
root: process.env["MOLTNET_AGENT_SERVER_ROOT"] ?? "",
|
|
3915
3938
|
apiUrl: process.env["MOLTNET_API_URL"] ?? "",
|
|
3916
|
-
logLevel: process.env["LOG_LEVEL"] ?? ""
|
|
3939
|
+
logLevel: process.env["LOG_LEVEL"] ?? "",
|
|
3940
|
+
activeIdentity: process.env["MOLTNET_ACTIVE_IDENTITY"] ?? ""
|
|
3917
3941
|
};
|
|
3918
3942
|
}
|
|
3919
3943
|
/** Full process environment for spawned Agent Server run children. */
|
|
@@ -7108,1115 +7132,2051 @@ function runPoll(argv, runtimeAdapter) {
|
|
|
7108
7132
|
runtimeAdapter
|
|
7109
7133
|
});
|
|
7110
7134
|
}
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7135
|
+
var NAME_RE = IDENTITY_ALIAS_PATTERN;
|
|
7136
|
+
var PROVIDER_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
7137
|
+
function assertStoreName(kind, value) {
|
|
7138
|
+
try {
|
|
7139
|
+
return assertIdentityAlias(value);
|
|
7140
|
+
} catch {
|
|
7141
|
+
throw new AgentServerStoreError("invalid_name", `${kind} must match ${NAME_RE.source}`);
|
|
7118
7142
|
}
|
|
7119
|
-
};
|
|
7120
|
-
function isLoopbackViolation(error) {
|
|
7121
|
-
return error instanceof LoopbackViolationError;
|
|
7122
7143
|
}
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
function isLoopbackHostname(hostname) {
|
|
7127
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
7144
|
+
function assertProviderId(value) {
|
|
7145
|
+
if (!PROVIDER_ID_RE.test(value)) throw new AgentServerStoreError("invalid_name", `provider id must match ${PROVIDER_ID_RE.source}`);
|
|
7146
|
+
return value;
|
|
7128
7147
|
}
|
|
7148
|
+
var AgentServerStoreError = class extends Error {
|
|
7149
|
+
name = "AgentServerStoreError";
|
|
7150
|
+
constructor(code, message, options) {
|
|
7151
|
+
super(message, options);
|
|
7152
|
+
this.code = code;
|
|
7153
|
+
}
|
|
7154
|
+
};
|
|
7129
7155
|
/**
|
|
7130
|
-
*
|
|
7131
|
-
*
|
|
7132
|
-
*
|
|
7133
|
-
*
|
|
7156
|
+
* `MOLTNET_AGENT_SERVER_ROOT` override, else `~/.config/moltnet`.
|
|
7157
|
+
*
|
|
7158
|
+
* Deliberately does NOT consult `XDG_CONFIG_HOME`. The Go CLI's GetConfigDir
|
|
7159
|
+
* and @moltnet/agent-config's getConfigDir both resolve `~/.config/moltnet`,
|
|
7160
|
+
* so honouring XDG here gave one application two config roots: on a machine
|
|
7161
|
+
* with the variable set, the daemon wrote identities the CLI and SDK could not
|
|
7162
|
+
* read. `MOLTNET_AGENT_SERVER_ROOT` remains the explicit escape hatch for a
|
|
7163
|
+
* genuinely custom location.
|
|
7134
7164
|
*/
|
|
7135
|
-
function
|
|
7136
|
-
|
|
7165
|
+
function resolveAgentServerRoot(input) {
|
|
7166
|
+
const override = input.root?.trim();
|
|
7167
|
+
if (override) return override;
|
|
7168
|
+
return getConfigDir();
|
|
7169
|
+
}
|
|
7170
|
+
function providerEnvName(providerId) {
|
|
7171
|
+
return `MOLTNET_PROVIDER_${assertProviderId(providerId).replaceAll("-", "_").toUpperCase()}_API_KEY`;
|
|
7172
|
+
}
|
|
7173
|
+
function assertProviderEnvName(providerId, value) {
|
|
7174
|
+
const expected = providerEnvName(providerId);
|
|
7175
|
+
if (value !== expected) throw new AgentServerStoreError("invalid_state", `provider envName must be ${expected}`);
|
|
7176
|
+
return value;
|
|
7177
|
+
}
|
|
7178
|
+
function readJson(path) {
|
|
7179
|
+
let raw;
|
|
7137
7180
|
try {
|
|
7138
|
-
|
|
7181
|
+
raw = readFileSync(path, "utf8");
|
|
7139
7182
|
} catch (cause) {
|
|
7140
|
-
|
|
7183
|
+
if (cause.code === "ENOENT") return null;
|
|
7184
|
+
throw new AgentServerStoreError("io_error", `could not read state at ${path}`, { cause });
|
|
7185
|
+
}
|
|
7186
|
+
try {
|
|
7187
|
+
return JSON.parse(raw);
|
|
7188
|
+
} catch (cause) {
|
|
7189
|
+
throw new AgentServerStoreError("invalid_state", `corrupt JSON at ${path}`, { cause });
|
|
7141
7190
|
}
|
|
7142
|
-
if (url.origin !== value || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new LoopbackViolationError("origin_invalid", "Origin is not valid");
|
|
7143
|
-
return url.origin;
|
|
7144
7191
|
}
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7192
|
+
function writeJsonAtomic(path, value) {
|
|
7193
|
+
const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
7194
|
+
try {
|
|
7195
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
7196
|
+
renameSync(temp, path);
|
|
7197
|
+
} catch (cause) {
|
|
7198
|
+
try {
|
|
7199
|
+
rmSync(temp, { force: true });
|
|
7200
|
+
} catch {}
|
|
7201
|
+
throw cause;
|
|
7202
|
+
}
|
|
7148
7203
|
}
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
this.
|
|
7204
|
+
var AgentServerStore = class {
|
|
7205
|
+
root;
|
|
7206
|
+
identitiesDir;
|
|
7207
|
+
runsDir;
|
|
7208
|
+
secretsDir;
|
|
7209
|
+
/** Shared Pi credential dir; `auth.json` inside is pi-managed (lockfiled). */
|
|
7210
|
+
piDir;
|
|
7211
|
+
constructor(root) {
|
|
7212
|
+
this.root = root;
|
|
7213
|
+
this.identitiesDir = join(root, "identities");
|
|
7214
|
+
this.runsDir = join(root, "runs");
|
|
7215
|
+
this.secretsDir = join(root, "secrets");
|
|
7216
|
+
this.piDir = join(root, "pi");
|
|
7158
7217
|
}
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7218
|
+
get piAuthJsonPath() {
|
|
7219
|
+
return join(this.piDir, "auth.json");
|
|
7220
|
+
}
|
|
7221
|
+
/** Create the directory layout (0700) if missing. Idempotent. */
|
|
7222
|
+
ensure() {
|
|
7223
|
+
for (const dir of [
|
|
7224
|
+
this.root,
|
|
7225
|
+
this.identitiesDir,
|
|
7226
|
+
this.runsDir,
|
|
7227
|
+
this.secretsDir
|
|
7228
|
+
]) mkdirSync(dir, {
|
|
7229
|
+
recursive: true,
|
|
7230
|
+
mode: 448
|
|
7231
|
+
});
|
|
7232
|
+
return this;
|
|
7233
|
+
}
|
|
7234
|
+
get statePath() {
|
|
7235
|
+
return join(this.root, "agent-server.json");
|
|
7236
|
+
}
|
|
7237
|
+
readAgentServerState() {
|
|
7238
|
+
const state = readJson(this.statePath);
|
|
7239
|
+
if (!state) return {
|
|
7240
|
+
version: 2,
|
|
7241
|
+
pendingRegistrations: {},
|
|
7242
|
+
activations: {}
|
|
7243
|
+
};
|
|
7244
|
+
if (!isRecord$1(state) || state.version !== 2) throw new AgentServerStoreError("invalid_state", `agent-server.json version ${String(isRecord$1(state) ? state.version : void 0)} is not supported; move agent-server.json aside, run \`moltnet config migrate\`, then add or attach the agents again`);
|
|
7245
|
+
if ("pairedOrigins" in state) throw new AgentServerStoreError("invalid_state", "agent-server.json uses the obsolete pairing format; move agent-server.json aside and configure the agent server again");
|
|
7246
|
+
if (!isRecord$1(state.pendingRegistrations) || !isRecord$1(state.activations)) throw new AgentServerStoreError("invalid_state", "agent-server.json is missing the version 2 activation map; move agent-server.json aside, run `moltnet config migrate`, then add or attach the agents again");
|
|
7247
|
+
for (const [alias, activation] of Object.entries(state.activations)) validateActivation(alias, activation);
|
|
7248
|
+
for (const [alias, registration] of Object.entries(state.pendingRegistrations)) {
|
|
7249
|
+
assertStoreName("agent name", alias);
|
|
7250
|
+
if (!isRecord$1(registration) || typeof registration.apiUrl !== "string" || registration.apiUrl.length === 0 || typeof registration.createdAt !== "string" || registration.createdAt.length === 0) throw new AgentServerStoreError("invalid_state", `pending registration "${alias}" is not valid`);
|
|
7164
7251
|
}
|
|
7252
|
+
return {
|
|
7253
|
+
version: 2,
|
|
7254
|
+
pendingRegistrations: state.pendingRegistrations,
|
|
7255
|
+
activations: state.activations
|
|
7256
|
+
};
|
|
7165
7257
|
}
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7258
|
+
writeAgentServerState(state) {
|
|
7259
|
+
writeJsonAtomic(this.statePath, state);
|
|
7260
|
+
}
|
|
7261
|
+
agentPath(name) {
|
|
7262
|
+
return join(storeChildPath(this.identitiesDir, "identity alias", name), "moltnet.json");
|
|
7263
|
+
}
|
|
7264
|
+
/** Central identity directory, shared with the Go CLI layout. */
|
|
7265
|
+
identityDir(name) {
|
|
7266
|
+
return storeChildPath(this.identitiesDir, "identity alias", name);
|
|
7267
|
+
}
|
|
7268
|
+
get identitySelectorPath() {
|
|
7269
|
+
return join(this.root, "identity-selector.json");
|
|
7270
|
+
}
|
|
7271
|
+
readIdentitySelector() {
|
|
7272
|
+
const selector = readJson(this.identitySelectorPath);
|
|
7273
|
+
if (!selector) return null;
|
|
7274
|
+
if (!isRecord$1(selector) || selector.version !== 1 || selector.default_identity !== void 0 && typeof selector.default_identity !== "string") throw new AgentServerStoreError("invalid_state", "identity-selector.json is not a supported selector document");
|
|
7275
|
+
if (selector.default_identity) assertStoreName("identity alias", selector.default_identity);
|
|
7276
|
+
return selector;
|
|
7277
|
+
}
|
|
7278
|
+
writeIdentitySelector(alias) {
|
|
7279
|
+
writeJsonAtomic(this.identitySelectorPath, {
|
|
7280
|
+
version: 1,
|
|
7281
|
+
default_identity: assertStoreName("identity alias", alias)
|
|
7282
|
+
});
|
|
7283
|
+
}
|
|
7284
|
+
/** Identity aliases available in the shared user-level store. */
|
|
7285
|
+
listIdentityAliases() {
|
|
7286
|
+
return readdirSync(this.identitiesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && IDENTITY_ALIAS_PATTERN.test(entry.name)).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
|
|
7287
|
+
}
|
|
7288
|
+
resolveIdentityAlias(explicit, active) {
|
|
7289
|
+
const alias = explicit?.trim() || active?.trim() || this.readIdentitySelector()?.default_identity;
|
|
7290
|
+
if (!alias) throw new AgentServerStoreError("not_found", "no active identity selected");
|
|
7291
|
+
return assertStoreName("identity alias", alias);
|
|
7292
|
+
}
|
|
7293
|
+
readAgentConfig(alias) {
|
|
7294
|
+
return readJson(this.agentPath(alias));
|
|
7295
|
+
}
|
|
7296
|
+
writeAgentConfig(alias, config) {
|
|
7297
|
+
mkdirSync(this.identityDir(alias), {
|
|
7298
|
+
recursive: true,
|
|
7299
|
+
mode: 448
|
|
7300
|
+
});
|
|
7301
|
+
writeJsonAtomic(this.agentPath(alias), config);
|
|
7302
|
+
if (!this.readIdentitySelector()?.default_identity) this.writeIdentitySelector(alias);
|
|
7303
|
+
}
|
|
7304
|
+
removeAgentConfig(alias) {
|
|
7305
|
+
rmSync(this.agentPath(alias), { force: true });
|
|
7306
|
+
this.clearIdentitySelectorIfDefault(alias);
|
|
7307
|
+
}
|
|
7308
|
+
/** Clears the persisted default when it names `alias`. */
|
|
7309
|
+
clearIdentitySelectorIfDefault(alias) {
|
|
7310
|
+
let selector = null;
|
|
7169
7311
|
try {
|
|
7170
|
-
|
|
7312
|
+
selector = this.readIdentitySelector();
|
|
7171
7313
|
} catch {
|
|
7172
|
-
|
|
7314
|
+
return;
|
|
7173
7315
|
}
|
|
7174
|
-
if (!
|
|
7175
|
-
|
|
7316
|
+
if (!selector || selector.default_identity !== alias) return;
|
|
7317
|
+
writeJsonAtomic(this.identitySelectorPath, { version: 1 });
|
|
7176
7318
|
}
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
function requireOriginHeader(headers) {
|
|
7180
|
-
const origin = headers.origin;
|
|
7181
|
-
if (typeof origin !== "string" || origin.length === 0) throw new LoopbackViolationError("origin_required", "Origin is required");
|
|
7182
|
-
return origin;
|
|
7183
|
-
}
|
|
7184
|
-
//#endregion
|
|
7185
|
-
//#region ../../libs/loopback-companion/src/fastify.ts
|
|
7186
|
-
var CORS_PREFLIGHT_MAX_AGE_SECONDS = 600;
|
|
7187
|
-
var PRIVATE_NETWORK_REQUEST_HEADER = "access-control-request-private-network";
|
|
7188
|
-
var PRIVATE_NETWORK_ALLOW_HEADER = "access-control-allow-private-network";
|
|
7189
|
-
/**
|
|
7190
|
-
* Enforce that the `Host` header identifies loopback. Blocks DNS-rebinding
|
|
7191
|
-
* setups where a public hostname resolves to 127.0.0.1: the browser then
|
|
7192
|
-
* sends that hostname as `Host`, and the request is refused here even
|
|
7193
|
-
* though the socket is loopback.
|
|
7194
|
-
*/
|
|
7195
|
-
function requireLoopbackHost(request) {
|
|
7196
|
-
const host = request.headers.host;
|
|
7197
|
-
if (!host) throw new LoopbackViolationError("host_required", "Host header is required");
|
|
7198
|
-
let hostname;
|
|
7199
|
-
try {
|
|
7200
|
-
hostname = new URL(`http://${host}`).hostname;
|
|
7201
|
-
} catch {
|
|
7202
|
-
throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
7319
|
+
readActivation(alias) {
|
|
7320
|
+
return this.readAgentServerState().activations[assertStoreName("agent name", alias)] ?? null;
|
|
7203
7321
|
}
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
/**
|
|
7207
|
-
* Register the loopback-companion security profile on a Fastify app:
|
|
7208
|
-
*
|
|
7209
|
-
* - loopback `Host` enforcement on every request;
|
|
7210
|
-
* - `cache-control: no-store` on every response;
|
|
7211
|
-
* - strict UTF-8 JSON body parsing (invalid bodies raise a typed violation);
|
|
7212
|
-
* - exact-origin CORS (opaque/`null` origins get no CORS response but are
|
|
7213
|
-
* not rejected here — route-level controls stay mandatory);
|
|
7214
|
-
* - hardened helmet defaults.
|
|
7215
|
-
*
|
|
7216
|
-
*/
|
|
7217
|
-
function registerLoopbackSecurity(app, options) {
|
|
7218
|
-
if (!options.allowedOrigins && !options.isOriginAllowed) throw new Error("registerLoopbackSecurity requires allowedOrigins or isOriginAllowed");
|
|
7219
|
-
const primaryAllowlist = options.allowedOrigins ? new OriginAllowlist(options.allowedOrigins) : null;
|
|
7220
|
-
const selfAllowlist = options.selfOrigins && options.selfOrigins.length > 0 ? new OriginAllowlist(options.selfOrigins) : null;
|
|
7221
|
-
const isOriginAllowed = (origin) => selfAllowlist?.has(origin) === true || primaryAllowlist?.has(origin) === true || options.isOriginAllowed?.(origin) === true;
|
|
7222
|
-
app.addHook("onRequest", (request, _reply, done) => {
|
|
7223
|
-
requireLoopbackHost(request);
|
|
7224
|
-
done();
|
|
7225
|
-
});
|
|
7226
|
-
app.addHook("onSend", async (request, reply, payload) => {
|
|
7227
|
-
reply.header("cache-control", "no-store");
|
|
7228
|
-
if (request.method === "OPTIONS" && request.headers[PRIVATE_NETWORK_REQUEST_HEADER] === "true" && reply.getHeader("access-control-allow-origin") === request.headers.origin) reply.header(PRIVATE_NETWORK_ALLOW_HEADER, "true");
|
|
7229
|
-
return payload;
|
|
7230
|
-
});
|
|
7231
|
-
app.removeContentTypeParser("application/json");
|
|
7232
|
-
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
7233
|
-
try {
|
|
7234
|
-
const json = new TextDecoder("utf-8", { fatal: true }).decode(typeof body === "string" ? Buffer.from(body) : body);
|
|
7235
|
-
done(null, JSON.parse(json));
|
|
7236
|
-
} catch (cause) {
|
|
7237
|
-
done(new LoopbackViolationError("body_not_utf8_json", "Request body must be valid UTF-8 JSON", { cause }), void 0);
|
|
7238
|
-
}
|
|
7239
|
-
});
|
|
7240
|
-
app.register(cors, {
|
|
7241
|
-
allowedHeaders: ["content-type", ...options.allowedHeaders ?? []],
|
|
7242
|
-
maxAge: CORS_PREFLIGHT_MAX_AGE_SECONDS,
|
|
7243
|
-
methods: [...options.methods ?? [
|
|
7244
|
-
"GET",
|
|
7245
|
-
"POST",
|
|
7246
|
-
"OPTIONS"
|
|
7247
|
-
]],
|
|
7248
|
-
origin: (origin, callback) => {
|
|
7249
|
-
if (!origin || origin === "null") {
|
|
7250
|
-
callback(null, false);
|
|
7251
|
-
return;
|
|
7252
|
-
}
|
|
7253
|
-
if (isOriginAllowed(origin)) {
|
|
7254
|
-
callback(null, true);
|
|
7255
|
-
return;
|
|
7256
|
-
}
|
|
7257
|
-
callback(new LoopbackViolationError("origin_not_allowed", "Origin is not allowed"), false);
|
|
7258
|
-
}
|
|
7259
|
-
});
|
|
7260
|
-
app.register(helmet, {
|
|
7261
|
-
contentSecurityPolicy: {
|
|
7262
|
-
useDefaults: false,
|
|
7263
|
-
directives: options.contentSecurityPolicyDirectives ?? {
|
|
7264
|
-
baseUri: ["'none'"],
|
|
7265
|
-
defaultSrc: ["'none'"],
|
|
7266
|
-
formAction: ["'self'"],
|
|
7267
|
-
frameAncestors: ["'none'"],
|
|
7268
|
-
styleSrc: ["'unsafe-inline'"]
|
|
7269
|
-
}
|
|
7270
|
-
},
|
|
7271
|
-
crossOriginEmbedderPolicy: false,
|
|
7272
|
-
crossOriginOpenerPolicy: false,
|
|
7273
|
-
crossOriginResourcePolicy: { policy: "same-origin" },
|
|
7274
|
-
hsts: false,
|
|
7275
|
-
referrerPolicy: { policy: "no-referrer" }
|
|
7276
|
-
});
|
|
7277
|
-
}
|
|
7278
|
-
//#endregion
|
|
7279
|
-
//#region ../../libs/loopback-companion/src/fetch-metadata.ts
|
|
7280
|
-
function headerValue(headers, name) {
|
|
7281
|
-
const value = headers[name];
|
|
7282
|
-
return typeof value === "string" ? value : void 0;
|
|
7283
|
-
}
|
|
7284
|
-
/**
|
|
7285
|
-
* Require that a request is a top-level browser navigation (used by local
|
|
7286
|
-
* approval pages that must be opened as a document, never fetched).
|
|
7287
|
-
*/
|
|
7288
|
-
function assertNavigationRequest(headers) {
|
|
7289
|
-
const site = headerValue(headers, "sec-fetch-site");
|
|
7290
|
-
const mode = headerValue(headers, "sec-fetch-mode");
|
|
7291
|
-
const destination = headerValue(headers, "sec-fetch-dest");
|
|
7292
|
-
if (site !== "cross-site" && site !== "same-origin" && site !== "none" || mode !== "navigate" || destination !== "document") throw new LoopbackViolationError("navigation_required", "Request must be opened as a browser navigation");
|
|
7293
|
-
}
|
|
7294
|
-
/**
|
|
7295
|
-
* Reject an explicit cross-site Fetch-Metadata signal. Only the explicit
|
|
7296
|
-
* `cross-site` value is rejected: Safari may omit Fetch Metadata on
|
|
7297
|
-
* same-origin form submissions, so the absence of the header is not treated
|
|
7298
|
-
* as a violation — callers keep their one-time token as the primary control.
|
|
7299
|
-
*/
|
|
7300
|
-
function rejectExplicitCrossSite(headers) {
|
|
7301
|
-
if (headerValue(headers, "sec-fetch-site") === "cross-site") throw new LoopbackViolationError("cross_site_rejected", "Request must not originate cross-site");
|
|
7302
|
-
}
|
|
7303
|
-
//#endregion
|
|
7304
|
-
//#region src/lib/agent-server/lock.ts
|
|
7305
|
-
var AgentServerLockError = class extends Error {
|
|
7306
|
-
name = "AgentServerLockError";
|
|
7307
|
-
constructor(code, message, options) {
|
|
7308
|
-
super(message, options);
|
|
7309
|
-
this.code = code;
|
|
7322
|
+
hasPendingRegistration(alias) {
|
|
7323
|
+
return Boolean(this.readAgentServerState().pendingRegistrations[assertStoreName("agent name", alias)]);
|
|
7310
7324
|
}
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
try {
|
|
7321
|
-
releaseLock = await lock(root, {
|
|
7322
|
-
lockfilePath: path,
|
|
7323
|
-
realpath: false,
|
|
7324
|
-
retries: 0,
|
|
7325
|
-
...options.staleMs === void 0 ? {} : { stale: options.staleMs },
|
|
7326
|
-
...options.updateMs === void 0 ? {} : { update: options.updateMs },
|
|
7327
|
-
onCompromised: (cause) => {
|
|
7328
|
-
const error = new AgentServerLockError("compromised", `Agent Server lock ${path} was compromised: ${cause.message}`, { cause });
|
|
7329
|
-
if (options.onCompromised) {
|
|
7330
|
-
options.onCompromised(error);
|
|
7331
|
-
return;
|
|
7332
|
-
}
|
|
7333
|
-
throw error;
|
|
7334
|
-
}
|
|
7335
|
-
});
|
|
7336
|
-
} catch (cause) {
|
|
7337
|
-
if (cause.code === "ELOCKED") throw new AgentServerLockError("held", `another moltnet-agent server process already owns ${path}`, { cause });
|
|
7338
|
-
throw new AgentServerLockError("failed", `could not acquire Agent Server lock ${path}: ${cause.message}`, { cause });
|
|
7325
|
+
reserveRegistration(alias, apiUrl) {
|
|
7326
|
+
const name = assertStoreName("agent name", alias);
|
|
7327
|
+
const state = this.readAgentServerState();
|
|
7328
|
+
if (state.activations[name] || state.pendingRegistrations[name]) throw new AgentServerStoreError("already_exists", `agent "${name}" already exists in the agent server store`);
|
|
7329
|
+
state.pendingRegistrations[name] = {
|
|
7330
|
+
apiUrl,
|
|
7331
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7332
|
+
};
|
|
7333
|
+
this.writeAgentServerState(state);
|
|
7339
7334
|
}
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
released = true;
|
|
7346
|
-
await releaseLock();
|
|
7347
|
-
}
|
|
7348
|
-
};
|
|
7349
|
-
}
|
|
7350
|
-
/** Always release after normal completion or startup/runtime failure. */
|
|
7351
|
-
async function withAgentServerLock(root, work, options) {
|
|
7352
|
-
const held = await acquireAgentServerLock(root, options);
|
|
7353
|
-
try {
|
|
7354
|
-
return await work();
|
|
7355
|
-
} finally {
|
|
7356
|
-
await held.release();
|
|
7335
|
+
clearPendingRegistration(alias) {
|
|
7336
|
+
const name = assertStoreName("agent name", alias);
|
|
7337
|
+
const state = this.readAgentServerState();
|
|
7338
|
+
delete state.pendingRegistrations[name];
|
|
7339
|
+
this.writeAgentServerState(state);
|
|
7357
7340
|
}
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
* 1. Console (allowed origin) POSTs `/v1/pairings` → pending pairing bound
|
|
7366
|
-
* to that origin, with a one-time confirmation token.
|
|
7367
|
-
* 2. Console opens `http://127.0.0.1:<port>/pairings/<id>` in a new tab —
|
|
7368
|
-
* a navigation-gated local approval page naming the origin.
|
|
7369
|
-
* 3. One click POSTs the confirmation form (explicit cross-site rejected;
|
|
7370
|
-
* the one-time token is the primary CSRF control).
|
|
7371
|
-
* 4. Console claims `/v1/pairings/<id>/claim` from the same origin and
|
|
7372
|
-
* receives the bearer token exactly once; only its SHA-256 remains in
|
|
7373
|
-
* this supervisor process.
|
|
7374
|
-
*
|
|
7375
|
-
* The token exists for shared-machine cross-user protection and to bind
|
|
7376
|
-
* "this console session is the operator" — browser-vs-browser isolation is
|
|
7377
|
-
* already covered by the loopback-companion origin checks. Grants are
|
|
7378
|
-
* deliberately process-scoped: after the listening socket changes owners, a
|
|
7379
|
-
* token disclosed to an impostor on that port cannot authenticate to a later
|
|
7380
|
-
* supervisor process.
|
|
7381
|
-
*/
|
|
7382
|
-
var PENDING_TTL_MS = 600 * 1e3;
|
|
7383
|
-
var AgentServerPairingError = class extends Error {
|
|
7384
|
-
name = "AgentServerPairingError";
|
|
7385
|
-
constructor(code, message) {
|
|
7386
|
-
super(message);
|
|
7387
|
-
this.code = code;
|
|
7341
|
+
writeActivation(activation) {
|
|
7342
|
+
const alias = assertStoreName("agent name", activation.alias);
|
|
7343
|
+
validateActivation(alias, activation);
|
|
7344
|
+
const state = this.readAgentServerState();
|
|
7345
|
+
state.activations[alias] = activation;
|
|
7346
|
+
if (activation.source === "managed") delete state.pendingRegistrations[alias];
|
|
7347
|
+
this.writeAgentServerState(state);
|
|
7388
7348
|
}
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
7392
|
-
}
|
|
7393
|
-
function safeEqual(a, b) {
|
|
7394
|
-
const left = Buffer.from(a, "utf8");
|
|
7395
|
-
const right = Buffer.from(b, "utf8");
|
|
7396
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
7397
|
-
}
|
|
7398
|
-
var PairingService = class {
|
|
7399
|
-
pending = /* @__PURE__ */ new Map();
|
|
7400
|
-
paired = /* @__PURE__ */ new Map();
|
|
7401
|
-
constructor(options = {}) {
|
|
7402
|
-
this.options = options;
|
|
7349
|
+
listActivations() {
|
|
7350
|
+
return Object.values(this.readAgentServerState().activations).sort((a, b) => a.alias.localeCompare(b.alias));
|
|
7403
7351
|
}
|
|
7404
|
-
|
|
7405
|
-
return this.
|
|
7352
|
+
get providersPath() {
|
|
7353
|
+
return join(this.root, "providers.json");
|
|
7406
7354
|
}
|
|
7407
|
-
|
|
7408
|
-
|
|
7355
|
+
readProviders() {
|
|
7356
|
+
const state = readJson(this.providersPath) ?? {};
|
|
7357
|
+
this.validateProviders(state);
|
|
7358
|
+
return state;
|
|
7409
7359
|
}
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7360
|
+
writeProviders(state) {
|
|
7361
|
+
this.validateProviders(state);
|
|
7362
|
+
writeJsonAtomic(this.providersPath, state);
|
|
7413
7363
|
}
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
confirmToken: this.token(),
|
|
7420
|
-
expiresAt: this.now() + PENDING_TTL_MS,
|
|
7421
|
-
approved: false,
|
|
7422
|
-
bearerToken: null
|
|
7423
|
-
});
|
|
7424
|
-
return {
|
|
7425
|
-
pairingId,
|
|
7426
|
-
approvalPath: `/pairings/${pairingId}`
|
|
7427
|
-
};
|
|
7364
|
+
validateProviders(state) {
|
|
7365
|
+
for (const [id, provider] of Object.entries(state)) {
|
|
7366
|
+
assertProviderId(id);
|
|
7367
|
+
assertProviderEnvName(id, provider.envName);
|
|
7368
|
+
}
|
|
7428
7369
|
}
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7370
|
+
runDir(id) {
|
|
7371
|
+
return storeChildPath(this.runsDir, "run id", id);
|
|
7372
|
+
}
|
|
7373
|
+
resolveRunLogPath(id) {
|
|
7374
|
+
let root;
|
|
7375
|
+
let runDir;
|
|
7376
|
+
try {
|
|
7377
|
+
root = realpathSync(this.runsDir);
|
|
7378
|
+
runDir = realpathSync(this.runDir(id));
|
|
7379
|
+
} catch (cause) {
|
|
7380
|
+
throw new AgentServerStoreError("io_error", "could not resolve run directory", { cause });
|
|
7381
|
+
}
|
|
7382
|
+
if (!isStrictDescendant(root, runDir)) throw new AgentServerStoreError("invalid_state", "run directory escapes its store");
|
|
7383
|
+
const logPath = join(runDir, "daemon.log");
|
|
7384
|
+
try {
|
|
7385
|
+
if (lstatSync(logPath).isSymbolicLink()) throw new AgentServerStoreError("invalid_state", "run log must not be a symbolic link");
|
|
7386
|
+
const resolvedLog = realpathSync(logPath);
|
|
7387
|
+
if (!isStrictDescendant(runDir, resolvedLog)) throw new AgentServerStoreError("invalid_state", "run log escapes its store");
|
|
7388
|
+
} catch (cause) {
|
|
7389
|
+
if (cause instanceof AgentServerStoreError) throw cause;
|
|
7390
|
+
if (cause.code !== "ENOENT") throw new AgentServerStoreError("io_error", "could not resolve run log", { cause });
|
|
7391
|
+
}
|
|
7392
|
+
return logPath;
|
|
7393
|
+
}
|
|
7394
|
+
createRunDir(id) {
|
|
7395
|
+
const dir = this.runDir(id);
|
|
7396
|
+
const piDir = join(dir, "pi");
|
|
7397
|
+
mkdirSync(piDir, {
|
|
7398
|
+
recursive: true,
|
|
7399
|
+
mode: 448
|
|
7400
|
+
});
|
|
7433
7401
|
return {
|
|
7434
|
-
|
|
7435
|
-
|
|
7402
|
+
dir,
|
|
7403
|
+
piDir,
|
|
7404
|
+
logPath: join(dir, "daemon.log")
|
|
7436
7405
|
};
|
|
7437
7406
|
}
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
if (pairing.approved || !safeEqual(pairing.confirmToken, confirmToken)) throw new AgentServerPairingError("pairing_invalid", "Confirmation token is not valid");
|
|
7441
|
-
pairing.approved = true;
|
|
7442
|
-
pairing.bearerToken = this.token();
|
|
7443
|
-
return { origin: pairing.origin };
|
|
7407
|
+
readRun(id) {
|
|
7408
|
+
return readJson(join(this.runDir(id), "run.json"));
|
|
7444
7409
|
}
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
if (pairing.origin !== origin) throw new AgentServerPairingError("pairing_origin_mismatch", "Pairing belongs to a different origin");
|
|
7448
|
-
if (!pairing.approved || !pairing.bearerToken) throw new AgentServerPairingError("pairing_not_approved", "Pairing has not been approved yet");
|
|
7449
|
-
const token = pairing.bearerToken;
|
|
7450
|
-
this.pending.delete(pairingId);
|
|
7451
|
-
this.paired.set(origin, sha256Hex(token));
|
|
7452
|
-
return { token };
|
|
7410
|
+
writeRun(record) {
|
|
7411
|
+
writeJsonAtomic(join(this.runDir(record.id), "run.json"), record);
|
|
7453
7412
|
}
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7413
|
+
listRuns(limit = Number.POSITIVE_INFINITY) {
|
|
7414
|
+
let ids;
|
|
7415
|
+
try {
|
|
7416
|
+
ids = readdirSync(this.runsDir);
|
|
7417
|
+
} catch {
|
|
7418
|
+
return [];
|
|
7419
|
+
}
|
|
7420
|
+
const sortedIds = ids.filter((id) => NAME_RE.test(id)).sort().reverse();
|
|
7421
|
+
const selectedIds = Number.isFinite(limit) ? sortedIds.slice(0, Math.max(0, limit)) : sortedIds;
|
|
7422
|
+
const records = [];
|
|
7423
|
+
for (const id of selectedIds) {
|
|
7424
|
+
const record = this.readRun(id);
|
|
7425
|
+
if (record) records.push(record);
|
|
7426
|
+
}
|
|
7427
|
+
return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
7457
7428
|
}
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
const
|
|
7461
|
-
|
|
7462
|
-
|
|
7429
|
+
/** Remove only completed run directories outside the configured budget. */
|
|
7430
|
+
pruneCompletedRuns(options) {
|
|
7431
|
+
const now = (options.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
7432
|
+
let retainedBytes = 0;
|
|
7433
|
+
let retainedCount = 0;
|
|
7434
|
+
const removed = [];
|
|
7435
|
+
for (const record of this.listRuns()) {
|
|
7436
|
+
if (record.status === "running") continue;
|
|
7437
|
+
const dir = this.runDir(record.id);
|
|
7438
|
+
const bytes = directoryBytes(dir);
|
|
7439
|
+
const endedAt = Date.parse(record.endedAt ?? record.startedAt);
|
|
7440
|
+
const expired = !Number.isFinite(endedAt) || now - endedAt > options.maxAgeMs;
|
|
7441
|
+
const overCount = retainedCount >= options.maxCount;
|
|
7442
|
+
const overBytes = retainedBytes + bytes > options.maxBytes;
|
|
7443
|
+
if (expired || overCount || overBytes) {
|
|
7444
|
+
rmSync(dir, {
|
|
7445
|
+
recursive: true,
|
|
7446
|
+
force: true
|
|
7447
|
+
});
|
|
7448
|
+
removed.push(record.id);
|
|
7449
|
+
continue;
|
|
7450
|
+
}
|
|
7451
|
+
retainedCount += 1;
|
|
7452
|
+
retainedBytes += bytes;
|
|
7453
|
+
}
|
|
7454
|
+
return removed;
|
|
7463
7455
|
}
|
|
7464
7456
|
};
|
|
7465
|
-
function
|
|
7466
|
-
|
|
7457
|
+
function directoryBytes(path) {
|
|
7458
|
+
let info;
|
|
7459
|
+
try {
|
|
7460
|
+
info = lstatSync(path);
|
|
7461
|
+
} catch {
|
|
7462
|
+
return 0;
|
|
7463
|
+
}
|
|
7464
|
+
if (info.isSymbolicLink()) return 0;
|
|
7465
|
+
if (!info.isDirectory()) return info.size;
|
|
7466
|
+
let total = 0;
|
|
7467
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) total += directoryBytes(join(path, entry.name));
|
|
7468
|
+
return total;
|
|
7467
7469
|
}
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
return `<!doctype html>
|
|
7471
|
-
<html lang="en">
|
|
7472
|
-
<head>
|
|
7473
|
-
<meta charset="utf-8" />
|
|
7474
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7475
|
-
<title>MoltNet Agent — approve connection</title>
|
|
7476
|
-
<style>
|
|
7477
|
-
:root { color-scheme: light dark; }
|
|
7478
|
-
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; background: Canvas; color: CanvasText; }
|
|
7479
|
-
main { max-width: 26rem; padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 12px; }
|
|
7480
|
-
h1 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
|
7481
|
-
code { font-size: 0.95em; word-break: break-all; }
|
|
7482
|
-
button { margin-top: 1.25rem; font: inherit; padding: 0.6rem 1.4rem; border-radius: 8px; border: 1px solid color-mix(in srgb, CanvasText 30%, transparent); cursor: pointer; }
|
|
7483
|
-
p.small { font-size: 0.85rem; opacity: 0.75; }
|
|
7484
|
-
</style>
|
|
7485
|
-
</head>
|
|
7486
|
-
<body>
|
|
7487
|
-
<script>
|
|
7488
|
-
// The Console must remain this popup's opener until it finishes navigating
|
|
7489
|
-
// from about:blank. Safari rejects that cross-origin navigation otherwise.
|
|
7490
|
-
// Once this trusted local approval document has loaded, it needs no opener.
|
|
7491
|
-
window.opener = null;
|
|
7492
|
-
<\/script>
|
|
7493
|
-
<main>
|
|
7494
|
-
<h1>Allow this site to manage local MoltNet agents?</h1>
|
|
7495
|
-
<p><code>${escapeHtml(input.origin)}</code> asks to configure agents and start or stop local daemon runs on this machine.</p>
|
|
7496
|
-
<p class="small">Approve only if you opened that page yourself. This grant lasts until the local supervisor stops.</p>
|
|
7497
|
-
<form method="post" action="/pairings/${escapeHtml(input.pairingId)}/confirm">
|
|
7498
|
-
<input type="hidden" name="confirmToken" value="${escapeHtml(input.confirmToken)}" />
|
|
7499
|
-
<button type="submit">Approve</button>
|
|
7500
|
-
</form>
|
|
7501
|
-
</main>
|
|
7502
|
-
</body>
|
|
7503
|
-
</html>
|
|
7504
|
-
`;
|
|
7470
|
+
function isRecord$1(value) {
|
|
7471
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7505
7472
|
}
|
|
7506
|
-
function
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
`;
|
|
7473
|
+
function storeChildPath(root, kind, value, suffix = "") {
|
|
7474
|
+
const name = assertStoreName(kind, value);
|
|
7475
|
+
const normalizedRoot = resolve(root);
|
|
7476
|
+
const candidate = resolve(normalizedRoot, `${name}${suffix}`);
|
|
7477
|
+
if (!isStrictDescendant(normalizedRoot, candidate)) throw new AgentServerStoreError("invalid_name", `${kind} escapes its store`);
|
|
7478
|
+
return candidate;
|
|
7479
|
+
}
|
|
7480
|
+
function isStrictDescendant(root, candidate) {
|
|
7481
|
+
const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
7482
|
+
return candidate !== root && candidate.startsWith(rootPrefix);
|
|
7483
|
+
}
|
|
7484
|
+
function validateActivation(alias, value) {
|
|
7485
|
+
const invalid = () => {
|
|
7486
|
+
throw new AgentServerStoreError("invalid_state", `activation "${alias}" is not a valid version 2 activation`);
|
|
7487
|
+
};
|
|
7488
|
+
if (!isRecord$1(value)) invalid();
|
|
7489
|
+
const activation = value;
|
|
7490
|
+
if (activation.alias !== alias) invalid();
|
|
7491
|
+
if (![
|
|
7492
|
+
"subjectId",
|
|
7493
|
+
"publicKey",
|
|
7494
|
+
"fingerprint",
|
|
7495
|
+
"createdAt"
|
|
7496
|
+
].every((field) => typeof activation[field] === "string" && activation[field].length > 0)) invalid();
|
|
7497
|
+
if (activation.source === "managed") {
|
|
7498
|
+
if (typeof activation.apiUrl !== "string" || activation.apiUrl.length === 0 || activation.configPath !== void 0 || activation.configApiUrl !== void 0) invalid();
|
|
7499
|
+
return;
|
|
7500
|
+
}
|
|
7501
|
+
if (activation.source !== "external" || typeof activation.configPath !== "string" || activation.configPath.length === 0 || typeof activation.configApiUrl !== "string" || activation.configApiUrl.length === 0 || activation.apiUrl !== void 0 && typeof activation.apiUrl !== "string") invalid();
|
|
7515
7502
|
}
|
|
7516
7503
|
//#endregion
|
|
7517
|
-
//#region src/lib/
|
|
7504
|
+
//#region src/lib/provider-lock.ts
|
|
7505
|
+
var DEFAULT_LOCK_TIMEOUT_MS = 3e4;
|
|
7506
|
+
var LOCK_WAIT_WARNING_MS = 1e3;
|
|
7507
|
+
var ProviderLockError = class extends Error {
|
|
7508
|
+
name = "ProviderLockError";
|
|
7509
|
+
constructor(code, message, options) {
|
|
7510
|
+
super(message, options);
|
|
7511
|
+
this.code = code;
|
|
7512
|
+
}
|
|
7513
|
+
};
|
|
7518
7514
|
/**
|
|
7519
|
-
*
|
|
7520
|
-
*
|
|
7521
|
-
*
|
|
7522
|
-
* `ModelRuntime.login()` (which owns persistence into the shared
|
|
7523
|
-
* `pi/auth.json` and token rotation thereafter). The browser only ever sees
|
|
7524
|
-
* the provider's authorize URL or device code — never tokens.
|
|
7525
|
-
*
|
|
7526
|
-
* Provider ids come from Pi's model runtime, so Agent Server stays in lockstep
|
|
7527
|
-
* with supported subscription providers. GitHub Copilot is intentionally
|
|
7528
|
-
* excluded: MoltNet does not broker editor-seat subscription credentials.
|
|
7515
|
+
* Serialize provider state shared by the CLI and the long-running Agent Server.
|
|
7516
|
+
* The lock target is a stable directory because the protected JSON files may
|
|
7517
|
+
* not exist yet.
|
|
7529
7518
|
*/
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7519
|
+
async function withProviderMutationLock(root, work, options = {}) {
|
|
7520
|
+
return withNamedProviderLock(root, "providers", work, options);
|
|
7521
|
+
}
|
|
7522
|
+
/** Serialize Pi credential operations for one provider across processes. */
|
|
7523
|
+
async function withProviderOAuthLock(root, providerId, work, options = {}) {
|
|
7524
|
+
return withNamedProviderLock(root, `oauth-${encodeURIComponent(providerId)}`, work, options);
|
|
7525
|
+
}
|
|
7526
|
+
async function withNamedProviderLock(root, name, work, options) {
|
|
7527
|
+
const locksDir = join(root, "locks");
|
|
7528
|
+
mkdirSync(locksDir, {
|
|
7529
|
+
recursive: true,
|
|
7530
|
+
mode: 448
|
|
7531
|
+
});
|
|
7532
|
+
let compromised;
|
|
7533
|
+
const startedAt = Date.now();
|
|
7534
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
7535
|
+
const lockfilePath = join(locksDir, `${name}.lock`);
|
|
7536
|
+
let warned = false;
|
|
7537
|
+
let release;
|
|
7538
|
+
for (;;) {
|
|
7539
|
+
if (options.signal?.aborted) throw new ProviderLockError("lock_aborted", `provider lock acquisition was cancelled for "${name}"`, { cause: options.signal.reason });
|
|
7540
|
+
const elapsedMs = Date.now() - startedAt;
|
|
7541
|
+
if (elapsedMs >= timeoutMs) throw new ProviderLockError("lock_timeout", `timed out waiting for provider lock "${name}"`);
|
|
7542
|
+
if (!warned && elapsedMs >= LOCK_WAIT_WARNING_MS) {
|
|
7543
|
+
warned = true;
|
|
7544
|
+
options.logger?.warn({
|
|
7545
|
+
code: "provider_lock_contended",
|
|
7546
|
+
elapsedMs,
|
|
7547
|
+
lockName: name
|
|
7548
|
+
}, "Provider operation is waiting for another process");
|
|
7549
|
+
}
|
|
7550
|
+
try {
|
|
7551
|
+
release = await lock(locksDir, {
|
|
7552
|
+
lockfilePath,
|
|
7553
|
+
onCompromised: (error) => {
|
|
7554
|
+
compromised = error;
|
|
7555
|
+
},
|
|
7556
|
+
realpath: false,
|
|
7557
|
+
retries: 0
|
|
7558
|
+
});
|
|
7559
|
+
break;
|
|
7560
|
+
} catch (error) {
|
|
7561
|
+
if (error.code !== "ELOCKED") throw error;
|
|
7562
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
7563
|
+
try {
|
|
7564
|
+
await setTimeout$1(Math.min(100, remainingMs), void 0, { ...options.signal ? { signal: options.signal } : {} });
|
|
7565
|
+
} catch (cause) {
|
|
7566
|
+
throw new ProviderLockError("lock_aborted", `provider lock acquisition was cancelled for "${name}"`, { cause });
|
|
7567
|
+
}
|
|
7568
|
+
}
|
|
7569
|
+
}
|
|
7570
|
+
let outcome;
|
|
7571
|
+
try {
|
|
7572
|
+
outcome = {
|
|
7573
|
+
ok: true,
|
|
7574
|
+
value: await work()
|
|
7575
|
+
};
|
|
7576
|
+
} catch (error) {
|
|
7577
|
+
outcome = {
|
|
7578
|
+
ok: false,
|
|
7579
|
+
error
|
|
7580
|
+
};
|
|
7581
|
+
}
|
|
7582
|
+
try {
|
|
7583
|
+
await release();
|
|
7584
|
+
} catch (error) {
|
|
7585
|
+
if (!compromised && outcome.ok) outcome = {
|
|
7586
|
+
ok: false,
|
|
7587
|
+
error
|
|
7588
|
+
};
|
|
7589
|
+
}
|
|
7590
|
+
if (!outcome.ok) throw outcome.error;
|
|
7591
|
+
if (compromised) throw compromised;
|
|
7592
|
+
return outcome.value;
|
|
7593
|
+
}
|
|
7594
|
+
//#endregion
|
|
7595
|
+
//#region src/lib/oauth-provider.ts
|
|
7596
|
+
var EXCLUDED_OAUTH_PROVIDERS = new Set(["github-copilot"]);
|
|
7597
|
+
function isOAuthProviderEligible(provider) {
|
|
7598
|
+
return provider.auth.oauth !== void 0 && isOAuthProviderIdEligible(provider.id);
|
|
7599
|
+
}
|
|
7600
|
+
function isOAuthProviderIdEligible(providerId) {
|
|
7601
|
+
return !EXCLUDED_OAUTH_PROVIDERS.has(providerId);
|
|
7602
|
+
}
|
|
7603
|
+
var silentLogger$2 = { warn: () => void 0 };
|
|
7604
|
+
var OAuthProviderError = class extends Error {
|
|
7605
|
+
name = "OAuthProviderError";
|
|
7536
7606
|
constructor(code, message) {
|
|
7537
7607
|
super(message);
|
|
7538
7608
|
this.code = code;
|
|
7539
7609
|
}
|
|
7540
7610
|
};
|
|
7541
|
-
var
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
var ProviderLoginService = class ProviderLoginService {
|
|
7547
|
-
logins = /* @__PURE__ */ new Map();
|
|
7548
|
-
logger;
|
|
7549
|
-
constructor(options) {
|
|
7550
|
-
this.options = options;
|
|
7551
|
-
this.logger = options.logger ?? silentLogger;
|
|
7611
|
+
var OAuthProviderService = class OAuthProviderService {
|
|
7612
|
+
constructor(authPath, runtime, logger) {
|
|
7613
|
+
this.authPath = authPath;
|
|
7614
|
+
this.runtime = runtime;
|
|
7615
|
+
this.logger = logger;
|
|
7552
7616
|
}
|
|
7553
7617
|
static async create(options) {
|
|
7554
|
-
const
|
|
7618
|
+
const runtime = options.modelRuntime ?? await ModelRuntime.create({
|
|
7555
7619
|
authPath: options.authPath,
|
|
7556
7620
|
refreshOnCreate: false
|
|
7557
7621
|
});
|
|
7558
|
-
return new
|
|
7559
|
-
|
|
7560
|
-
|
|
7622
|
+
return new OAuthProviderService(options.authPath, runtime, options.logger ?? silentLogger$2);
|
|
7623
|
+
}
|
|
7624
|
+
list() {
|
|
7625
|
+
const providers = this.oauthProviders();
|
|
7626
|
+
const connected = this.connectedProviderIds(providers);
|
|
7627
|
+
return providers.map((provider) => ({
|
|
7628
|
+
id: provider.id,
|
|
7629
|
+
name: provider.name,
|
|
7630
|
+
connected: connected.has(provider.id)
|
|
7631
|
+
}));
|
|
7632
|
+
}
|
|
7633
|
+
async login(providerId, interaction, options = {}) {
|
|
7634
|
+
this.assertProvider(providerId);
|
|
7635
|
+
await withProviderOAuthLock(dirname(this.authPath), providerId, async () => {
|
|
7636
|
+
try {
|
|
7637
|
+
await this.runtime.login(providerId, "oauth", interaction);
|
|
7638
|
+
} finally {
|
|
7639
|
+
await options.onSettledUnderLock?.();
|
|
7640
|
+
}
|
|
7641
|
+
}, {
|
|
7642
|
+
signal: options.signal,
|
|
7643
|
+
logger: this.logger
|
|
7561
7644
|
});
|
|
7562
7645
|
}
|
|
7563
|
-
|
|
7564
|
-
|
|
7646
|
+
async logout(providerId, signal) {
|
|
7647
|
+
this.assertProvider(providerId);
|
|
7648
|
+
await withProviderOAuthLock(dirname(this.authPath), providerId, () => this.runtime.logout(providerId), {
|
|
7649
|
+
signal,
|
|
7650
|
+
logger: this.logger
|
|
7651
|
+
});
|
|
7565
7652
|
}
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
id: provider.id,
|
|
7569
|
-
name: provider.name
|
|
7570
|
-
}))).filter((provider) => !EXCLUDED_SUBSCRIPTION_PROVIDERS.has(provider.id));
|
|
7653
|
+
assertProvider(providerId) {
|
|
7654
|
+
if (!this.oauthProviders().some((provider) => provider.id === providerId)) throw new OAuthProviderError("provider_unknown", `"${providerId}" is not a known OAuth provider`);
|
|
7571
7655
|
}
|
|
7572
|
-
|
|
7573
|
-
|
|
7656
|
+
oauthProviders() {
|
|
7657
|
+
return this.runtime.getProviders().filter(isOAuthProviderEligible).map(({ id, name }) => ({
|
|
7658
|
+
id,
|
|
7659
|
+
name
|
|
7660
|
+
}));
|
|
7661
|
+
}
|
|
7662
|
+
connectedProviderIds(providers) {
|
|
7574
7663
|
try {
|
|
7575
|
-
|
|
7664
|
+
const content = readFileSync(this.authPath, "utf8").replace(/^\uFEFF/u, "");
|
|
7665
|
+
const parsed = JSON.parse(content);
|
|
7666
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Invalid auth.json: expected an object");
|
|
7667
|
+
return new Set(Object.keys(parsed));
|
|
7576
7668
|
} catch (error) {
|
|
7577
|
-
this.logger.warn({
|
|
7669
|
+
if (error.code !== "ENOENT") for (const provider of providers) this.logger.warn({
|
|
7578
7670
|
event: "agent-server.subscription_auth_read_failed",
|
|
7579
|
-
providerId,
|
|
7580
|
-
...
|
|
7671
|
+
providerId: provider.id,
|
|
7672
|
+
...safeOAuthError(error)
|
|
7581
7673
|
}, "Could not read subscription authentication state");
|
|
7582
|
-
return
|
|
7674
|
+
return /* @__PURE__ */ new Set();
|
|
7583
7675
|
}
|
|
7584
7676
|
}
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7677
|
+
};
|
|
7678
|
+
function safeOAuthError(error) {
|
|
7679
|
+
const result = { errorType: error instanceof Error ? error.name : typeof error };
|
|
7680
|
+
const code = error?.code;
|
|
7681
|
+
if (typeof code === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(code)) result["applicationCode"] = code;
|
|
7682
|
+
return result;
|
|
7683
|
+
}
|
|
7684
|
+
var AgentServerModelDiscoveryError = class extends Error {
|
|
7685
|
+
name = "AgentServerModelDiscoveryError";
|
|
7686
|
+
constructor(code, message, statusCode, options) {
|
|
7687
|
+
super(message, options);
|
|
7688
|
+
this.code = code;
|
|
7689
|
+
this.statusCode = statusCode;
|
|
7588
7690
|
}
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7691
|
+
};
|
|
7692
|
+
var ModelDiscoveryCollector = class {
|
|
7693
|
+
models = /* @__PURE__ */ new Set();
|
|
7694
|
+
addOpenAiResponse(value) {
|
|
7695
|
+
if (!isRecord(value) || !Array.isArray(value["data"])) return;
|
|
7696
|
+
for (const candidate of value["data"]) {
|
|
7697
|
+
if (!isRecord(candidate)) continue;
|
|
7698
|
+
const id = candidate["id"];
|
|
7699
|
+
if (typeof id === "string" && id.length > 0) this.models.add(id);
|
|
7700
|
+
}
|
|
7595
7701
|
}
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
for (const
|
|
7599
|
-
if (
|
|
7600
|
-
|
|
7702
|
+
addOllamaResponse(value) {
|
|
7703
|
+
if (!isRecord(value) || !Array.isArray(value["models"])) return;
|
|
7704
|
+
for (const candidate of value["models"]) {
|
|
7705
|
+
if (!isRecord(candidate)) continue;
|
|
7706
|
+
const name = candidate["name"];
|
|
7707
|
+
if (typeof name === "string" && name.length > 0) this.models.add(name);
|
|
7601
7708
|
}
|
|
7602
7709
|
}
|
|
7603
|
-
|
|
7710
|
+
get size() {
|
|
7711
|
+
return this.models.size;
|
|
7712
|
+
}
|
|
7713
|
+
result(providerId, failures) {
|
|
7714
|
+
if (this.models.size === 0) throw discoveryFailure(providerId, failures);
|
|
7715
|
+
return {
|
|
7716
|
+
models: [...this.models].sort().slice(0, 500),
|
|
7717
|
+
discoveredCount: this.models.size
|
|
7718
|
+
};
|
|
7719
|
+
}
|
|
7720
|
+
};
|
|
7721
|
+
function parseProviderBaseUrl(value, providerId) {
|
|
7722
|
+
let parsed;
|
|
7723
|
+
try {
|
|
7724
|
+
parsed = new URL(value);
|
|
7725
|
+
} catch (cause) {
|
|
7726
|
+
throw new AgentServerModelDiscoveryError("invalid_provider", `provider "${providerId}" has an invalid base URL`, 400, { cause });
|
|
7727
|
+
}
|
|
7728
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new AgentServerModelDiscoveryError("invalid_provider", `provider "${providerId}" base URL must be HTTP(S) without credentials, query, or fragment`, 400);
|
|
7729
|
+
if (isNonLoopbackPrivateAddress(parsed.hostname)) throw new AgentServerModelDiscoveryError("invalid_provider", `provider "${providerId}" base URL must not target a private network address`, 400);
|
|
7730
|
+
return parsed;
|
|
7731
|
+
}
|
|
7732
|
+
function isNonLoopbackPrivateAddress(hostname) {
|
|
7733
|
+
if (isIP(hostname) !== 4) return false;
|
|
7734
|
+
const [first, second] = hostname.split(".").map(Number);
|
|
7735
|
+
if (first === 127) return false;
|
|
7736
|
+
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
|
|
7737
|
+
}
|
|
7738
|
+
function discoveryFailure(providerId, failures) {
|
|
7739
|
+
if (failures.some((failure) => failure.kind === "http" && (failure.status === 401 || failure.status === 403))) return new AgentServerModelDiscoveryError("discovery_unauthorized", `provider "${providerId}" rejected model discovery; check its API key`, 502);
|
|
7740
|
+
if (failures.some((failure) => failure.kind === "network")) return new AgentServerModelDiscoveryError("discovery_unavailable", `provider "${providerId}" could not be reached for model discovery`, 502);
|
|
7741
|
+
if (failures.some((failure) => failure.kind === "invalid_response")) return new AgentServerModelDiscoveryError("discovery_invalid_response", `provider "${providerId}" returned an invalid model response`, 502);
|
|
7742
|
+
return new AgentServerModelDiscoveryError("discovery_failed", `no models discovered for provider "${providerId}"`, 502);
|
|
7743
|
+
}
|
|
7744
|
+
function isRecord(value) {
|
|
7745
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7746
|
+
}
|
|
7747
|
+
//#endregion
|
|
7748
|
+
//#region src/lib/safe-error-context.ts
|
|
7749
|
+
function safeErrorContext(error) {
|
|
7750
|
+
const context = { errorType: error instanceof Error ? error.name : typeof error };
|
|
7751
|
+
const applicationCode = safeErrorToken(error?.code);
|
|
7752
|
+
if (applicationCode) context["applicationCode"] = applicationCode;
|
|
7753
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
7754
|
+
if (cause instanceof Error) {
|
|
7755
|
+
context["causeType"] = cause.name;
|
|
7756
|
+
const causeMessage = safeLogMessage(cause.message);
|
|
7757
|
+
if (causeMessage) context["causeMessage"] = causeMessage;
|
|
7758
|
+
}
|
|
7759
|
+
const fsCode = safeErrorToken(cause?.code);
|
|
7760
|
+
const syscall = safeErrorToken(cause?.syscall);
|
|
7761
|
+
if (fsCode) context["fsCode"] = fsCode;
|
|
7762
|
+
if (syscall) context["syscall"] = syscall;
|
|
7763
|
+
const causeStatus = cause?.statusCode;
|
|
7764
|
+
if (typeof causeStatus === "number") context["causeStatusCode"] = causeStatus;
|
|
7765
|
+
return context;
|
|
7766
|
+
}
|
|
7767
|
+
function safeLogMessage(value) {
|
|
7768
|
+
const normalized = value.replace(/[\r\n\t]/gu, " ").trim();
|
|
7769
|
+
return normalized ? normalized.slice(0, 500) : void 0;
|
|
7770
|
+
}
|
|
7771
|
+
function safeErrorToken(value) {
|
|
7772
|
+
return typeof value === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(value) ? value : void 0;
|
|
7773
|
+
}
|
|
7774
|
+
//#endregion
|
|
7775
|
+
//#region src/lib/provider-configuration.ts
|
|
7776
|
+
var DEFAULT_PROVIDER_API = "openai-completions";
|
|
7777
|
+
var ProviderConfigurationError = class extends Error {
|
|
7778
|
+
name = "ProviderConfigurationError";
|
|
7779
|
+
constructor(code, message, statusCode, options) {
|
|
7780
|
+
super(message, options);
|
|
7781
|
+
this.code = code;
|
|
7782
|
+
this.statusCode = statusCode;
|
|
7783
|
+
}
|
|
7784
|
+
};
|
|
7785
|
+
var silentLogger$1 = {
|
|
7786
|
+
info: () => void 0,
|
|
7787
|
+
warn: () => void 0
|
|
7788
|
+
};
|
|
7789
|
+
var ProviderConfigurationService = class {
|
|
7790
|
+
fetchImpl;
|
|
7791
|
+
logger;
|
|
7792
|
+
constructor(options) {
|
|
7793
|
+
this.options = options;
|
|
7794
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
7795
|
+
this.logger = options.logger ?? silentLogger$1;
|
|
7796
|
+
}
|
|
7797
|
+
list() {
|
|
7798
|
+
return Object.fromEntries(Object.entries(this.options.store.readProviders()).map(([id, provider]) => [id, providerView(provider)]));
|
|
7799
|
+
}
|
|
7800
|
+
async set(providerIdInput, input, options = {}) {
|
|
7801
|
+
const providerId = assertProviderId(providerIdInput);
|
|
7802
|
+
if (input.apiKey !== void 0 && input.clearApiKey) throw new ProviderConfigurationError("invalid_provider", "apiKey and clearApiKey cannot be used together", 400);
|
|
7803
|
+
if (input.apiKey !== void 0 && input.apiKey.length === 0) throw new ProviderConfigurationError("invalid_provider", "provider API key must not be empty", 400);
|
|
7804
|
+
return withProviderMutationLock(this.options.store.root, async () => {
|
|
7805
|
+
const providers = this.options.store.readProviders();
|
|
7806
|
+
const previous = providers[providerId];
|
|
7807
|
+
const baseUrl = input.baseUrl ?? previous?.baseUrl;
|
|
7808
|
+
if (!baseUrl) throw new ProviderConfigurationError("invalid_provider", `base URL is required when creating provider "${providerId}"`, 400);
|
|
7809
|
+
parseProviderBaseUrl(baseUrl, providerId);
|
|
7810
|
+
const entry = {
|
|
7811
|
+
api: input.api ?? previous?.api ?? DEFAULT_PROVIDER_API,
|
|
7812
|
+
baseUrl,
|
|
7813
|
+
envName: assertProviderEnvName(providerId, input.envName ?? previous?.envName ?? providerEnvName(providerId)),
|
|
7814
|
+
models: [...input.models ?? previous?.models ?? []],
|
|
7815
|
+
...!input.clearApiKey && previous?.apiKeyRef ? { apiKeyRef: previous.apiKeyRef } : {}
|
|
7816
|
+
};
|
|
7817
|
+
const key = `pi-provider/${providerId}`;
|
|
7818
|
+
let previousSecret;
|
|
7819
|
+
const previousKey = managedSecretKey(previous?.apiKeyRef);
|
|
7820
|
+
if ((input.apiKey !== void 0 || input.clearApiKey) && previous?.apiKeyRef) try {
|
|
7821
|
+
previousSecret = await this.options.secretProviders.resolve(parseSecretReferenceString(previous.apiKeyRef));
|
|
7822
|
+
} catch (error) {
|
|
7823
|
+
if (input.clearApiKey) throw new ProviderConfigurationError("provider_secret_unavailable", `provider "${providerId}" API key could not be prepared for removal`, 400, { cause: error });
|
|
7824
|
+
}
|
|
7825
|
+
if (input.apiKey !== void 0) {
|
|
7826
|
+
await this.options.secrets.write(key, input.apiKey);
|
|
7827
|
+
entry.apiKeyRef = formatSecretReferenceString({
|
|
7828
|
+
provider: FILE_SECRET_PROVIDER,
|
|
7829
|
+
key
|
|
7830
|
+
});
|
|
7831
|
+
}
|
|
7832
|
+
if (input.clearApiKey && previousKey) await this.options.secrets.delete(previousKey);
|
|
7833
|
+
try {
|
|
7834
|
+
providers[providerId] = entry;
|
|
7835
|
+
this.options.store.writeProviders(providers);
|
|
7836
|
+
} catch (error) {
|
|
7837
|
+
try {
|
|
7838
|
+
if (input.apiKey !== void 0) if (previousKey === key && previousSecret !== void 0) await this.options.secrets.write(key, previousSecret);
|
|
7839
|
+
else await this.options.secrets.delete(key);
|
|
7840
|
+
else if (input.clearApiKey && previousKey && previousSecret !== void 0) await this.options.secrets.write(previousKey, previousSecret);
|
|
7841
|
+
} catch (rollbackError) {
|
|
7842
|
+
throw new AggregateError([error, rollbackError], "provider update failed and secret rollback was unsuccessful");
|
|
7843
|
+
}
|
|
7844
|
+
throw error;
|
|
7845
|
+
}
|
|
7846
|
+
return providerView(entry);
|
|
7847
|
+
}, {
|
|
7848
|
+
signal: options.signal,
|
|
7849
|
+
logger: this.logger
|
|
7850
|
+
});
|
|
7851
|
+
}
|
|
7852
|
+
async remove(providerIdInput, options = {}) {
|
|
7853
|
+
const providerId = assertProviderId(providerIdInput);
|
|
7854
|
+
await withProviderMutationLock(this.options.store.root, async () => {
|
|
7855
|
+
const providers = this.options.store.readProviders();
|
|
7856
|
+
const provider = providers[providerId];
|
|
7857
|
+
if (!provider) throw new ProviderConfigurationError("provider_not_found", `Provider ${providerId} was not found`, 404);
|
|
7858
|
+
const key = managedSecretKey(provider.apiKeyRef);
|
|
7859
|
+
let previousSecret;
|
|
7860
|
+
if (provider.apiKeyRef) try {
|
|
7861
|
+
previousSecret = await this.options.secretProviders.resolve(parseSecretReferenceString(provider.apiKeyRef));
|
|
7862
|
+
} catch (error) {
|
|
7863
|
+
throw new ProviderConfigurationError("provider_secret_unavailable", `provider "${providerId}" API key could not be prepared for removal`, 400, { cause: error });
|
|
7864
|
+
}
|
|
7865
|
+
if (key) await this.options.secrets.delete(key);
|
|
7866
|
+
delete providers[providerId];
|
|
7867
|
+
try {
|
|
7868
|
+
this.options.store.writeProviders(providers);
|
|
7869
|
+
} catch (error) {
|
|
7870
|
+
if (key && previousSecret !== void 0) try {
|
|
7871
|
+
await this.options.secrets.write(key, previousSecret);
|
|
7872
|
+
} catch (rollbackError) {
|
|
7873
|
+
throw new AggregateError([error, rollbackError], "provider removal failed and secret rollback was unsuccessful");
|
|
7874
|
+
}
|
|
7875
|
+
throw error;
|
|
7876
|
+
}
|
|
7877
|
+
}, {
|
|
7878
|
+
signal: options.signal,
|
|
7879
|
+
logger: this.logger
|
|
7880
|
+
});
|
|
7881
|
+
}
|
|
7882
|
+
async discover(providerIdInput, options = {}) {
|
|
7883
|
+
const providerId = assertProviderId(providerIdInput);
|
|
7884
|
+
const provider = this.options.store.readProviders()[providerId];
|
|
7885
|
+
if (!provider) throw new ProviderConfigurationError("provider_not_found", `provider "${providerId}" was not found`, 404);
|
|
7886
|
+
const parsed = parseProviderBaseUrl(provider.baseUrl, providerId);
|
|
7887
|
+
const baseUrl = parsed.href.replace(/\/$/u, "");
|
|
7888
|
+
const apiKey = await this.resolveApiKey(providerId, provider);
|
|
7889
|
+
const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
7890
|
+
const failures = [];
|
|
7891
|
+
const collector = new ModelDiscoveryCollector();
|
|
7892
|
+
collector.addOpenAiResponse(await this.requestDiscoveryEndpoint({
|
|
7893
|
+
endpoint: "openai_models",
|
|
7894
|
+
failures,
|
|
7895
|
+
headers,
|
|
7896
|
+
providerId,
|
|
7897
|
+
signal: options.signal,
|
|
7898
|
+
url: `${baseUrl}/models`
|
|
7899
|
+
}));
|
|
7900
|
+
if (isOllamaProvider(providerId, parsed)) collector.addOllamaResponse(await this.requestDiscoveryEndpoint({
|
|
7901
|
+
endpoint: "ollama_tags",
|
|
7902
|
+
failures,
|
|
7903
|
+
headers,
|
|
7904
|
+
providerId,
|
|
7905
|
+
signal: options.signal,
|
|
7906
|
+
url: `${parsed.origin}/api/tags`
|
|
7907
|
+
}));
|
|
7908
|
+
const result = collector.result(providerId, failures);
|
|
7909
|
+
if (result.discoveredCount > result.models.length) this.logger.warn({
|
|
7910
|
+
code: "agent_server_provider_discovery_truncated",
|
|
7911
|
+
discoveredCount: result.discoveredCount,
|
|
7912
|
+
providerId,
|
|
7913
|
+
returnedCount: 500
|
|
7914
|
+
}, "Provider model discovery result was truncated");
|
|
7915
|
+
if (options.save) await this.set(providerId, { models: result.models }, options);
|
|
7916
|
+
this.logger.info({
|
|
7917
|
+
code: "agent_server_provider_discovery_completed",
|
|
7918
|
+
modelCount: result.models.length,
|
|
7919
|
+
providerId
|
|
7920
|
+
}, "Provider model discovery completed");
|
|
7921
|
+
return { models: result.models };
|
|
7922
|
+
}
|
|
7923
|
+
async resolveApiKey(providerId, provider) {
|
|
7924
|
+
if (!provider.apiKeyRef) return void 0;
|
|
7604
7925
|
try {
|
|
7605
|
-
|
|
7606
|
-
return true;
|
|
7926
|
+
return await this.options.secretProviders.resolve(parseSecretReferenceString(provider.apiKeyRef));
|
|
7607
7927
|
} catch (error) {
|
|
7608
|
-
this.logger.
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
providerId
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
return false;
|
|
7928
|
+
this.logger.warn({
|
|
7929
|
+
...safeErrorContext(error),
|
|
7930
|
+
code: "agent_server_provider_secret_unavailable",
|
|
7931
|
+
providerId
|
|
7932
|
+
}, "Provider API key could not be resolved for model discovery");
|
|
7933
|
+
throw new ProviderConfigurationError("provider_secret_unavailable", `provider "${providerId}" API key could not be resolved`, 400, { cause: error });
|
|
7615
7934
|
}
|
|
7616
7935
|
}
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
status(providerId) {
|
|
7632
|
-
this.sweep();
|
|
7633
|
-
const login = this.logins.get(providerId);
|
|
7634
|
-
if (!login) throw new AgentServerSubscriptionError("login_not_found", `no login in progress for "${providerId}"`);
|
|
7635
|
-
return snapshot(login);
|
|
7636
|
-
}
|
|
7637
|
-
/**
|
|
7638
|
-
* Start (or return the in-flight) login for a provider. Resolves once the
|
|
7639
|
-
* flow has surfaced an authorize URL / device code, completed, or the
|
|
7640
|
-
* start window elapsed — whichever comes first.
|
|
7641
|
-
*/
|
|
7642
|
-
async start(providerId) {
|
|
7643
|
-
this.sweep();
|
|
7644
|
-
if (!this.providers().some((provider) => provider.id === providerId)) throw new AgentServerSubscriptionError("provider_unknown", `"${providerId}" is not a known subscription provider`);
|
|
7645
|
-
const existing = this.logins.get(providerId);
|
|
7646
|
-
if (existing && existing.status === "pending") return snapshot(existing);
|
|
7647
|
-
let infoArrived = () => void 0;
|
|
7648
|
-
const infoPromise = new Promise((resolvePromise) => {
|
|
7649
|
-
infoArrived = () => resolvePromise();
|
|
7650
|
-
});
|
|
7651
|
-
const abort = new AbortController();
|
|
7652
|
-
const login = {
|
|
7653
|
-
providerId,
|
|
7654
|
-
status: "pending",
|
|
7655
|
-
operationId: randomUUID(),
|
|
7656
|
-
startedAt: this.now(),
|
|
7657
|
-
infoArrived,
|
|
7658
|
-
abort,
|
|
7659
|
-
invalidated: false,
|
|
7660
|
-
previousCredential: readStoredCredential(providerId, this.options.authPath)
|
|
7661
|
-
};
|
|
7662
|
-
this.logins.set(providerId, login);
|
|
7663
|
-
this.logger.info({
|
|
7664
|
-
event: "agent-server.subscription_login_transition",
|
|
7665
|
-
operationId: login.operationId,
|
|
7666
|
-
providerId,
|
|
7667
|
-
transition: "started"
|
|
7668
|
-
}, "Subscription login started");
|
|
7669
|
-
const callbacks = createLoginCallbacks(login, this.logger);
|
|
7670
|
-
(this.options.runLogin ?? ((id, loginCallbacks) => this.runtime().login(id, "oauth", toAuthInteraction(loginCallbacks)).then(() => void 0)))(providerId, callbacks).then(() => {
|
|
7671
|
-
if (login.invalidated) {
|
|
7672
|
-
this.restoreCredential(login);
|
|
7673
|
-
return;
|
|
7674
|
-
}
|
|
7675
|
-
if (!this.options.runLogin && !this.connected(providerId)) {
|
|
7676
|
-
login.status = "failed";
|
|
7677
|
-
login.error = "Subscription sign-in completed, but credentials were not persisted. Start again to retry.";
|
|
7678
|
-
this.logger.error({
|
|
7679
|
-
event: "agent-server.subscription_login_transition",
|
|
7680
|
-
operationId: login.operationId,
|
|
7681
|
-
providerId,
|
|
7682
|
-
transition: "persistence_failed"
|
|
7683
|
-
}, "Subscription login credentials were not persisted");
|
|
7684
|
-
} else {
|
|
7685
|
-
login.status = "completed";
|
|
7936
|
+
async requestDiscoveryEndpoint(input) {
|
|
7937
|
+
const startedAt = Date.now();
|
|
7938
|
+
const timeout = AbortSignal.timeout(this.options.requestTimeoutMs ?? 1e4);
|
|
7939
|
+
let response;
|
|
7940
|
+
try {
|
|
7941
|
+
response = await this.fetchImpl(input.url, {
|
|
7942
|
+
headers: input.headers,
|
|
7943
|
+
redirect: "error",
|
|
7944
|
+
signal: input.signal ? AbortSignal.any([input.signal, timeout]) : timeout
|
|
7945
|
+
});
|
|
7946
|
+
} catch (error) {
|
|
7947
|
+
const elapsedMs = Date.now() - startedAt;
|
|
7948
|
+
if (input.signal?.aborted) {
|
|
7949
|
+
const abortSource = providerAbortSource(input.signal.reason);
|
|
7686
7950
|
this.logger.info({
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
}, (error) => {
|
|
7695
|
-
if (login.invalidated) {
|
|
7696
|
-
this.restoreCredential(login);
|
|
7697
|
-
return;
|
|
7951
|
+
abortSource,
|
|
7952
|
+
code: "agent_server_provider_discovery_cancelled",
|
|
7953
|
+
elapsedMs,
|
|
7954
|
+
endpoint: input.endpoint,
|
|
7955
|
+
providerId: input.providerId
|
|
7956
|
+
}, "Provider model discovery was cancelled");
|
|
7957
|
+
throw new ProviderConfigurationError("operation_aborted", `provider "${input.providerId}" discovery was cancelled`, 408, { cause: error });
|
|
7698
7958
|
}
|
|
7699
|
-
|
|
7700
|
-
|
|
7959
|
+
const errorType = error instanceof Error ? error.name : typeof error;
|
|
7960
|
+
input.failures.push({
|
|
7961
|
+
kind: "network",
|
|
7962
|
+
errorType
|
|
7963
|
+
});
|
|
7701
7964
|
this.logger.warn({
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7965
|
+
abortSource: timeout.aborted ? "timeout" : void 0,
|
|
7966
|
+
code: timeout.aborted ? "agent_server_provider_discovery_timeout" : "agent_server_provider_discovery_request_failed",
|
|
7967
|
+
elapsedMs,
|
|
7968
|
+
endpoint: input.endpoint,
|
|
7969
|
+
errorType,
|
|
7970
|
+
providerId: input.providerId
|
|
7971
|
+
}, timeout.aborted ? "Provider model discovery request timed out" : "Provider model discovery request failed");
|
|
7972
|
+
return null;
|
|
7973
|
+
}
|
|
7974
|
+
if (!response.ok) {
|
|
7975
|
+
input.failures.push({
|
|
7976
|
+
kind: "http",
|
|
7977
|
+
status: response.status
|
|
7978
|
+
});
|
|
7979
|
+
const context = {
|
|
7980
|
+
code: "agent_server_provider_discovery_upstream_error",
|
|
7981
|
+
elapsedMs: Date.now() - startedAt,
|
|
7982
|
+
endpoint: input.endpoint,
|
|
7983
|
+
providerId: input.providerId,
|
|
7984
|
+
statusCode: response.status
|
|
7985
|
+
};
|
|
7986
|
+
if (response.status >= 500 || response.status === 401 || response.status === 403) this.logger.warn(context, "Provider model discovery was rejected");
|
|
7987
|
+
else this.logger.info(context, "Provider model discovery endpoint unavailable");
|
|
7988
|
+
return null;
|
|
7989
|
+
}
|
|
7990
|
+
try {
|
|
7991
|
+
return await response.json();
|
|
7992
|
+
} catch {
|
|
7993
|
+
input.failures.push({ kind: "invalid_response" });
|
|
7714
7994
|
this.logger.warn({
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
}, "
|
|
7995
|
+
code: "agent_server_provider_discovery_invalid_json",
|
|
7996
|
+
elapsedMs: Date.now() - startedAt,
|
|
7997
|
+
endpoint: input.endpoint,
|
|
7998
|
+
providerId: input.providerId
|
|
7999
|
+
}, "Provider model discovery returned invalid JSON");
|
|
8000
|
+
return null;
|
|
7720
8001
|
}
|
|
7721
|
-
return snapshot(login);
|
|
7722
8002
|
}
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
8003
|
+
};
|
|
8004
|
+
function providerView(provider) {
|
|
8005
|
+
return {
|
|
8006
|
+
api: provider.api,
|
|
8007
|
+
baseUrl: provider.baseUrl,
|
|
8008
|
+
envName: provider.envName,
|
|
8009
|
+
models: [...provider.models],
|
|
8010
|
+
hasApiKey: Boolean(provider.apiKeyRef)
|
|
8011
|
+
};
|
|
8012
|
+
}
|
|
8013
|
+
function isOllamaProvider(providerId, baseUrl) {
|
|
8014
|
+
return providerId === "ollama" || providerId.startsWith("ollama-") || baseUrl.hostname === "ollama.com" || baseUrl.port === "11434";
|
|
8015
|
+
}
|
|
8016
|
+
function managedSecretKey(reference) {
|
|
8017
|
+
if (!reference) return void 0;
|
|
8018
|
+
try {
|
|
8019
|
+
const parsed = parseSecretReferenceString(reference);
|
|
8020
|
+
return parsed.provider === FILE_SECRET_PROVIDER ? parsed.key : void 0;
|
|
8021
|
+
} catch {
|
|
8022
|
+
return;
|
|
7733
8023
|
}
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
8024
|
+
}
|
|
8025
|
+
function providerAbortSource(reason) {
|
|
8026
|
+
if (typeof reason === "object" && reason !== null && "source" in reason && reason.source === "shutdown") return "shutdown";
|
|
8027
|
+
return "caller";
|
|
8028
|
+
}
|
|
8029
|
+
//#endregion
|
|
8030
|
+
//#region src/cli/providers.ts
|
|
8031
|
+
async function runProviders(argv, dependencies = {}) {
|
|
8032
|
+
if (isHelpFlag(argv) || argv.length === 0) {
|
|
8033
|
+
(dependencies.stdout ?? console.log)(PROVIDERS_HELP);
|
|
8034
|
+
return argv.length === 0 ? 1 : 0;
|
|
8035
|
+
}
|
|
8036
|
+
let context;
|
|
8037
|
+
try {
|
|
8038
|
+
const [command, ...args] = argv;
|
|
8039
|
+
const parsed = parseProviderArgsSafely(command, args);
|
|
8040
|
+
context = await createContext(parsed.root, dependencies, parsed.command !== "list");
|
|
8041
|
+
switch (parsed.command) {
|
|
8042
|
+
case "list": return listProviders(context, parsed.json);
|
|
8043
|
+
case "set": return await setProvider(context, parsed);
|
|
8044
|
+
case "discover": return await discoverProvider(context, parsed);
|
|
8045
|
+
case "remove": return await removeProvider(context, parsed);
|
|
8046
|
+
case "login": return await loginProvider(context, parsed);
|
|
8047
|
+
case "logout": return await logoutProvider(context, parsed);
|
|
8048
|
+
default: throw new ProviderCliError("invalid_arguments", `Unknown providers command "${String(command)}"`);
|
|
8049
|
+
}
|
|
8050
|
+
} catch (error) {
|
|
8051
|
+
(context?.stderr ?? dependencies.stderr ?? console.error)(publicCliError(error, context?.signal));
|
|
8052
|
+
return 1;
|
|
8053
|
+
} finally {
|
|
8054
|
+
context?.close();
|
|
8055
|
+
}
|
|
8056
|
+
}
|
|
8057
|
+
var ProviderCliError = class extends Error {
|
|
8058
|
+
name = "ProviderCliError";
|
|
8059
|
+
constructor(code, message) {
|
|
8060
|
+
super(message);
|
|
8061
|
+
this.code = code;
|
|
7738
8062
|
}
|
|
7739
8063
|
};
|
|
7740
|
-
function
|
|
8064
|
+
function parseProviderArgsSafely(command, args) {
|
|
8065
|
+
try {
|
|
8066
|
+
return parseProviderArgs(command, args);
|
|
8067
|
+
} catch (error) {
|
|
8068
|
+
if (error instanceof ProviderCliError) throw error;
|
|
8069
|
+
if (error instanceof Error && error.name === "ParseArgsError") throw new ProviderCliError("invalid_arguments", error.message);
|
|
8070
|
+
throw error;
|
|
8071
|
+
}
|
|
8072
|
+
}
|
|
8073
|
+
function parseProviderArgs(command, args) {
|
|
8074
|
+
const common = { root: { type: "string" } };
|
|
8075
|
+
switch (command) {
|
|
8076
|
+
case "list": {
|
|
8077
|
+
const { values, positionals } = parseArgs({
|
|
8078
|
+
args,
|
|
8079
|
+
options: {
|
|
8080
|
+
...common,
|
|
8081
|
+
json: { type: "boolean" }
|
|
8082
|
+
},
|
|
8083
|
+
allowPositionals: true,
|
|
8084
|
+
strict: true
|
|
8085
|
+
});
|
|
8086
|
+
requirePositionals(positionals, 0, "providers list");
|
|
8087
|
+
return {
|
|
8088
|
+
command,
|
|
8089
|
+
root: values.root,
|
|
8090
|
+
json: values.json ?? false
|
|
8091
|
+
};
|
|
8092
|
+
}
|
|
8093
|
+
case "set": {
|
|
8094
|
+
const { values, positionals } = parseArgs({
|
|
8095
|
+
args,
|
|
8096
|
+
options: {
|
|
8097
|
+
...common,
|
|
8098
|
+
"base-url": { type: "string" },
|
|
8099
|
+
api: { type: "string" },
|
|
8100
|
+
model: {
|
|
8101
|
+
type: "string",
|
|
8102
|
+
multiple: true
|
|
8103
|
+
},
|
|
8104
|
+
"clear-models": { type: "boolean" },
|
|
8105
|
+
"api-key-stdin": { type: "boolean" },
|
|
8106
|
+
"clear-api-key": { type: "boolean" }
|
|
8107
|
+
},
|
|
8108
|
+
allowPositionals: true,
|
|
8109
|
+
strict: true
|
|
8110
|
+
});
|
|
8111
|
+
requirePositionals(positionals, 1, "providers set <id>");
|
|
8112
|
+
if (values.model && values["clear-models"]) throw new ProviderCliError("invalid_arguments", "--model and --clear-models cannot be used together");
|
|
8113
|
+
if (values["api-key-stdin"] && values["clear-api-key"]) throw new ProviderCliError("invalid_arguments", "--api-key-stdin and --clear-api-key cannot be used together");
|
|
8114
|
+
return {
|
|
8115
|
+
command,
|
|
8116
|
+
root: values.root,
|
|
8117
|
+
providerId: positionals[0],
|
|
8118
|
+
baseUrl: values["base-url"],
|
|
8119
|
+
api: values.api,
|
|
8120
|
+
models: values["clear-models"] ? [] : values.model,
|
|
8121
|
+
apiKeyStdin: values["api-key-stdin"] ?? false,
|
|
8122
|
+
clearApiKey: values["clear-api-key"] ?? false
|
|
8123
|
+
};
|
|
8124
|
+
}
|
|
8125
|
+
case "discover": {
|
|
8126
|
+
const { values, positionals } = parseArgs({
|
|
8127
|
+
args,
|
|
8128
|
+
options: {
|
|
8129
|
+
...common,
|
|
8130
|
+
save: { type: "boolean" },
|
|
8131
|
+
json: { type: "boolean" }
|
|
8132
|
+
},
|
|
8133
|
+
allowPositionals: true,
|
|
8134
|
+
strict: true
|
|
8135
|
+
});
|
|
8136
|
+
requirePositionals(positionals, 1, "providers discover <id>");
|
|
8137
|
+
return {
|
|
8138
|
+
command,
|
|
8139
|
+
root: values.root,
|
|
8140
|
+
providerId: positionals[0],
|
|
8141
|
+
save: values.save ?? false,
|
|
8142
|
+
json: values.json ?? false
|
|
8143
|
+
};
|
|
8144
|
+
}
|
|
8145
|
+
case "remove":
|
|
8146
|
+
case "logout": {
|
|
8147
|
+
const { values, positionals } = parseArgs({
|
|
8148
|
+
args,
|
|
8149
|
+
options: {
|
|
8150
|
+
...common,
|
|
8151
|
+
yes: { type: "boolean" }
|
|
8152
|
+
},
|
|
8153
|
+
allowPositionals: true,
|
|
8154
|
+
strict: true
|
|
8155
|
+
});
|
|
8156
|
+
requirePositionals(positionals, 1, `providers ${command} <id>`);
|
|
8157
|
+
return {
|
|
8158
|
+
command,
|
|
8159
|
+
root: values.root,
|
|
8160
|
+
providerId: positionals[0],
|
|
8161
|
+
yes: values.yes ?? false
|
|
8162
|
+
};
|
|
8163
|
+
}
|
|
8164
|
+
case "login": {
|
|
8165
|
+
const { values, positionals } = parseArgs({
|
|
8166
|
+
args,
|
|
8167
|
+
options: {
|
|
8168
|
+
...common,
|
|
8169
|
+
"auth-method": { type: "string" }
|
|
8170
|
+
},
|
|
8171
|
+
allowPositionals: true,
|
|
8172
|
+
strict: true
|
|
8173
|
+
});
|
|
8174
|
+
requirePositionals(positionals, 1, "providers login <id>");
|
|
8175
|
+
return {
|
|
8176
|
+
command,
|
|
8177
|
+
root: values.root,
|
|
8178
|
+
providerId: positionals[0],
|
|
8179
|
+
authMethod: values["auth-method"]
|
|
8180
|
+
};
|
|
8181
|
+
}
|
|
8182
|
+
default: throw new ProviderCliError("invalid_arguments", `Unknown providers command "${String(command)}"`);
|
|
8183
|
+
}
|
|
8184
|
+
}
|
|
8185
|
+
async function createContext(rootFlag, dependencies, installInterruptHandler) {
|
|
8186
|
+
const store = new AgentServerStore(resolveAgentServerRoot({ root: rootFlag ?? dependencies.envRoot ?? loadAgentServerEnvConfig().root })).ensure();
|
|
8187
|
+
const secrets = new FileSecretProvider({
|
|
8188
|
+
root: store.secretsDir,
|
|
8189
|
+
writable: true
|
|
8190
|
+
});
|
|
8191
|
+
const secretProviders = createNodeSecretProviderRegistry().register(secrets);
|
|
8192
|
+
const stderr = dependencies.stderr ?? console.error;
|
|
8193
|
+
const logger = createCliLogger(stderr);
|
|
8194
|
+
const configuration = dependencies.configuration ?? new ProviderConfigurationService({
|
|
8195
|
+
store,
|
|
8196
|
+
secrets,
|
|
8197
|
+
secretProviders,
|
|
8198
|
+
logger
|
|
8199
|
+
});
|
|
8200
|
+
const oauth = dependencies.oauth ?? await OAuthProviderService.create({
|
|
8201
|
+
authPath: store.piAuthJsonPath,
|
|
8202
|
+
logger
|
|
8203
|
+
});
|
|
8204
|
+
const controller = new AbortController();
|
|
8205
|
+
const onInterrupt = () => controller.abort(/* @__PURE__ */ new Error("Interrupted"));
|
|
8206
|
+
if (installInterruptHandler) process.once("SIGINT", onInterrupt);
|
|
8207
|
+
const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
|
|
8208
|
+
const question = dependencies.question ?? defaultQuestion;
|
|
8209
|
+
const stdinIsTTY = dependencies.stdinIsTTY ?? dependencies.interactive ?? Boolean(process.stdin.isTTY);
|
|
8210
|
+
const stdoutIsTTY = dependencies.stdoutIsTTY ?? dependencies.interactive ?? Boolean(process.stdout.isTTY);
|
|
7741
8211
|
return {
|
|
7742
|
-
|
|
8212
|
+
configuration,
|
|
8213
|
+
oauth,
|
|
8214
|
+
stdout: dependencies.stdout ?? console.log,
|
|
8215
|
+
stderr,
|
|
8216
|
+
readStdin: () => raceWithSignal(dependencies.readStdin?.() ?? defaultReadStdin(signal), signal),
|
|
8217
|
+
question: (prompt) => question(prompt, signal),
|
|
8218
|
+
openUrl: async (url) => {
|
|
8219
|
+
if (dependencies.openUrl) await dependencies.openUrl(url);
|
|
8220
|
+
else await defaultOpenUrl(url);
|
|
8221
|
+
},
|
|
8222
|
+
interactive: dependencies.interactive ?? (stdinIsTTY && stdoutIsTTY),
|
|
8223
|
+
stdinIsTTY,
|
|
8224
|
+
signal,
|
|
8225
|
+
close: () => {
|
|
8226
|
+
if (installInterruptHandler) process.removeListener("SIGINT", onInterrupt);
|
|
8227
|
+
}
|
|
8228
|
+
};
|
|
8229
|
+
}
|
|
8230
|
+
function listProviders(context, json) {
|
|
8231
|
+
const configuredProviders = context.configuration.list();
|
|
8232
|
+
const oauthProviders = context.oauth.list();
|
|
8233
|
+
if (json) {
|
|
8234
|
+
context.stdout(JSON.stringify({
|
|
8235
|
+
configuredProviders,
|
|
8236
|
+
oauthProviders
|
|
8237
|
+
}));
|
|
8238
|
+
return 0;
|
|
8239
|
+
}
|
|
8240
|
+
const configured = Object.entries(configuredProviders);
|
|
8241
|
+
context.stdout("Configured providers:");
|
|
8242
|
+
if (configured.length === 0) context.stdout(" (none)");
|
|
8243
|
+
for (const [id, provider] of configured) context.stdout(` ${id} ${provider.baseUrl} ${provider.models.length} model(s)${provider.hasApiKey ? " API key configured" : ""}`);
|
|
8244
|
+
context.stdout("OAuth providers:");
|
|
8245
|
+
if (oauthProviders.length === 0) context.stdout(" (none)");
|
|
8246
|
+
for (const provider of oauthProviders) context.stdout(` ${provider.id} ${provider.name} ${provider.connected ? "connected" : "not connected"}`);
|
|
8247
|
+
return 0;
|
|
8248
|
+
}
|
|
8249
|
+
async function setProvider(context, parsed) {
|
|
8250
|
+
let apiKey;
|
|
8251
|
+
if (parsed.apiKeyStdin) {
|
|
8252
|
+
if (context.stdinIsTTY) throw new ProviderCliError("invalid_input", "--api-key-stdin requires redirected stdin; pipe the API key into this command");
|
|
8253
|
+
apiKey = (await context.readStdin()).replace(/[\r\n]+$/u, "");
|
|
8254
|
+
if (!apiKey) throw new ProviderCliError("invalid_input", "No API key was received on stdin");
|
|
8255
|
+
}
|
|
8256
|
+
const provider = await context.configuration.set(parsed.providerId, {
|
|
8257
|
+
...parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {},
|
|
8258
|
+
...parsed.api ? { api: parsed.api } : {},
|
|
8259
|
+
...parsed.models ? { models: parsed.models } : {},
|
|
8260
|
+
...apiKey ? { apiKey } : {},
|
|
8261
|
+
...parsed.clearApiKey ? { clearApiKey: true } : {}
|
|
8262
|
+
}, { signal: context.signal });
|
|
8263
|
+
context.stdout(JSON.stringify({
|
|
8264
|
+
id: parsed.providerId,
|
|
8265
|
+
...provider
|
|
8266
|
+
}));
|
|
8267
|
+
return 0;
|
|
8268
|
+
}
|
|
8269
|
+
async function discoverProvider(context, parsed) {
|
|
8270
|
+
const result = await context.configuration.discover(parsed.providerId, {
|
|
8271
|
+
save: parsed.save,
|
|
8272
|
+
signal: context.signal
|
|
8273
|
+
});
|
|
8274
|
+
if (parsed.json) context.stdout(JSON.stringify(result));
|
|
8275
|
+
else for (const model of result.models) context.stdout(model);
|
|
8276
|
+
return 0;
|
|
8277
|
+
}
|
|
8278
|
+
async function removeProvider(context, parsed) {
|
|
8279
|
+
if (!await confirmDestructive(context, parsed.yes, `Remove configured provider "${parsed.providerId}"? [y/N] `)) {
|
|
8280
|
+
context.stderr("Provider removal cancelled.");
|
|
8281
|
+
return 1;
|
|
8282
|
+
}
|
|
8283
|
+
await context.configuration.remove(parsed.providerId, { signal: context.signal });
|
|
8284
|
+
context.stdout(`Removed configured provider "${parsed.providerId}".`);
|
|
8285
|
+
return 0;
|
|
8286
|
+
}
|
|
8287
|
+
async function loginProvider(context, parsed) {
|
|
8288
|
+
if (!context.interactive) throw new ProviderCliError("non_interactive", "Provider login requires an interactive terminal");
|
|
8289
|
+
await context.oauth.login(parsed.providerId, {
|
|
8290
|
+
signal: context.signal,
|
|
7743
8291
|
notify: (event) => {
|
|
7744
8292
|
switch (event.type) {
|
|
7745
8293
|
case "auth_url":
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
8294
|
+
context.stderr(event.instructions ?? "Authorize in your browser:");
|
|
8295
|
+
context.stderr(event.url);
|
|
8296
|
+
context.openUrl(event.url).catch(() => {
|
|
8297
|
+
context.stderr("Could not open the browser automatically; open the authorization URL above manually.");
|
|
7749
8298
|
});
|
|
7750
8299
|
break;
|
|
7751
8300
|
case "device_code":
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
verificationUri: event.verificationUri
|
|
7755
|
-
});
|
|
7756
|
-
break;
|
|
7757
|
-
case "info":
|
|
7758
|
-
case "progress":
|
|
7759
|
-
callbacks.onProgress?.(event.message);
|
|
8301
|
+
context.stderr(`Open ${event.verificationUri}`);
|
|
8302
|
+
context.stderr(`Enter code: ${event.userCode}`);
|
|
7760
8303
|
break;
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
8304
|
+
case "info":
|
|
8305
|
+
case "progress":
|
|
8306
|
+
context.stderr(event.message);
|
|
8307
|
+
break;
|
|
8308
|
+
}
|
|
8309
|
+
},
|
|
8310
|
+
prompt: async (prompt) => {
|
|
8311
|
+
if (prompt.type === "select") {
|
|
8312
|
+
if (parsed.authMethod) {
|
|
8313
|
+
if (!prompt.options.some((option) => option.id === parsed.authMethod)) throw new ProviderCliError("unsupported_auth_method", `OAuth auth method "${parsed.authMethod}" is not available`);
|
|
8314
|
+
return parsed.authMethod;
|
|
8315
|
+
}
|
|
8316
|
+
context.stderr(prompt.message);
|
|
8317
|
+
for (const option of prompt.options) context.stderr(` ${option.id}: ${option.label}`);
|
|
8318
|
+
}
|
|
8319
|
+
return context.question(`${prompt.message} `);
|
|
7774
8320
|
}
|
|
7775
|
-
};
|
|
8321
|
+
}, { signal: context.signal });
|
|
8322
|
+
context.stdout(`Connected OAuth provider "${parsed.providerId}".`);
|
|
8323
|
+
return 0;
|
|
7776
8324
|
}
|
|
7777
|
-
function
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
});
|
|
7782
|
-
try {
|
|
7783
|
-
writeFileSync(authPath, "{}\n", {
|
|
7784
|
-
flag: "wx",
|
|
7785
|
-
mode: 384
|
|
7786
|
-
});
|
|
7787
|
-
} catch (error) {
|
|
7788
|
-
if (error.code !== "EEXIST") throw error;
|
|
8325
|
+
async function logoutProvider(context, parsed) {
|
|
8326
|
+
if (!await confirmDestructive(context, parsed.yes, `Log out OAuth provider "${parsed.providerId}"? [y/N] `)) {
|
|
8327
|
+
context.stderr("Provider logout cancelled.");
|
|
8328
|
+
return 1;
|
|
7789
8329
|
}
|
|
7790
|
-
|
|
7791
|
-
|
|
8330
|
+
await context.oauth.logout(parsed.providerId, context.signal);
|
|
8331
|
+
context.stdout(`Logged out OAuth provider "${parsed.providerId}".`);
|
|
8332
|
+
return 0;
|
|
8333
|
+
}
|
|
8334
|
+
async function confirmDestructive(context, yes, message) {
|
|
8335
|
+
if (yes) return true;
|
|
8336
|
+
if (!context.interactive) throw new ProviderCliError("non_interactive", "--yes is required outside an interactive terminal");
|
|
8337
|
+
return /^y(es)?$/iu.test((await context.question(message)).trim());
|
|
8338
|
+
}
|
|
8339
|
+
function requirePositionals(positionals, expected, usage) {
|
|
8340
|
+
if (positionals.length !== expected) throw new ProviderCliError("invalid_arguments", `Usage: ${usage}`);
|
|
8341
|
+
}
|
|
8342
|
+
function publicCliError(error, signal) {
|
|
8343
|
+
if (signal?.aborted) return "Provider operation interrupted.";
|
|
8344
|
+
if (error instanceof ProviderCliError || error instanceof ProviderConfigurationError || error instanceof OAuthProviderError || error instanceof ProviderLockError) return error.message;
|
|
8345
|
+
return "Provider operation failed.";
|
|
8346
|
+
}
|
|
8347
|
+
async function defaultReadStdin(signal) {
|
|
8348
|
+
let value = "";
|
|
8349
|
+
process.stdin.setEncoding("utf8");
|
|
8350
|
+
for await (const chunk of process.stdin) {
|
|
8351
|
+
if (signal.aborted) throw new Error("Interrupted");
|
|
8352
|
+
value += chunk;
|
|
8353
|
+
}
|
|
8354
|
+
return value;
|
|
8355
|
+
}
|
|
8356
|
+
function raceWithSignal(work, signal) {
|
|
8357
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("Interrupted"));
|
|
8358
|
+
return new Promise((resolve, reject) => {
|
|
8359
|
+
const onAbort = () => reject(/* @__PURE__ */ new Error("Interrupted"));
|
|
8360
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
8361
|
+
work.then(resolve, reject).finally(() => {
|
|
8362
|
+
signal.removeEventListener("abort", onAbort);
|
|
8363
|
+
});
|
|
8364
|
+
});
|
|
8365
|
+
}
|
|
8366
|
+
async function defaultQuestion(prompt, signal) {
|
|
8367
|
+
const readline = createInterface({
|
|
8368
|
+
input: process.stdin,
|
|
8369
|
+
output: process.stdout
|
|
8370
|
+
});
|
|
7792
8371
|
try {
|
|
7793
|
-
|
|
7794
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Pi credential store is not an object: ${authPath}`);
|
|
7795
|
-
const credentials = parsed;
|
|
7796
|
-
if (credential) credentials[providerId] = credential;
|
|
7797
|
-
else delete credentials[providerId];
|
|
7798
|
-
writeFileSync(temp, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
|
|
7799
|
-
renameSync(temp, authPath);
|
|
8372
|
+
return await readline.question(prompt, { signal });
|
|
7800
8373
|
} finally {
|
|
7801
|
-
|
|
7802
|
-
release();
|
|
8374
|
+
readline.close();
|
|
7803
8375
|
}
|
|
7804
8376
|
}
|
|
7805
|
-
function
|
|
8377
|
+
function browserLaunchCommand(platform, url) {
|
|
8378
|
+
if (platform === "darwin") return {
|
|
8379
|
+
executable: "open",
|
|
8380
|
+
args: [url]
|
|
8381
|
+
};
|
|
8382
|
+
if (platform === "win32") return {
|
|
8383
|
+
executable: "explorer.exe",
|
|
8384
|
+
args: [url]
|
|
8385
|
+
};
|
|
7806
8386
|
return {
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
if (info.instructions) login.instructions = info.instructions;
|
|
7810
|
-
logger.info({
|
|
7811
|
-
event: "agent-server.subscription_login_transition",
|
|
7812
|
-
operationId: login.operationId,
|
|
7813
|
-
providerId: login.providerId,
|
|
7814
|
-
transition: "authorization_ready"
|
|
7815
|
-
}, "Subscription authorization URL ready");
|
|
7816
|
-
login.infoArrived();
|
|
7817
|
-
},
|
|
7818
|
-
onDeviceCode: (info) => {
|
|
7819
|
-
login.userCode = info.userCode;
|
|
7820
|
-
login.verificationUri = info.verificationUri;
|
|
7821
|
-
logger.info({
|
|
7822
|
-
event: "agent-server.subscription_login_transition",
|
|
7823
|
-
operationId: login.operationId,
|
|
7824
|
-
providerId: login.providerId,
|
|
7825
|
-
transition: "device_code_ready"
|
|
7826
|
-
}, "Subscription device code ready");
|
|
7827
|
-
login.infoArrived();
|
|
7828
|
-
},
|
|
7829
|
-
onPrompt: () => Promise.reject(new AgentServerSubscriptionError("login_unsupported_prompt", "This provider flow needs an interactive prompt; run `pi /login` in a terminal instead")),
|
|
7830
|
-
onSelect: (prompt) => Promise.resolve((prompt.options.find((option) => /device/i.test(option.id)) ?? prompt.options[0])?.id),
|
|
7831
|
-
signal: login.abort.signal
|
|
8387
|
+
executable: "xdg-open",
|
|
8388
|
+
args: [url]
|
|
7832
8389
|
};
|
|
7833
8390
|
}
|
|
7834
|
-
function
|
|
7835
|
-
|
|
7836
|
-
return
|
|
8391
|
+
function defaultOpenUrl(url, spawnProcess = spawn) {
|
|
8392
|
+
const { executable, args } = browserLaunchCommand(process.platform, url);
|
|
8393
|
+
return new Promise((resolve, reject) => {
|
|
8394
|
+
const child = spawnProcess(executable, args, {
|
|
8395
|
+
detached: true,
|
|
8396
|
+
stdio: "ignore"
|
|
8397
|
+
});
|
|
8398
|
+
child.once("error", reject);
|
|
8399
|
+
child.once("spawn", () => {
|
|
8400
|
+
child.unref();
|
|
8401
|
+
resolve();
|
|
8402
|
+
});
|
|
8403
|
+
});
|
|
7837
8404
|
}
|
|
7838
|
-
function
|
|
7839
|
-
const
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
8405
|
+
function createCliLogger(stderr) {
|
|
8406
|
+
const write = (level, context, message) => {
|
|
8407
|
+
const safeContext = Object.fromEntries(Object.entries(context).filter(([key, value]) => !/(?:credential|message|secret|token|url)/iu.test(key) && [
|
|
8408
|
+
"string",
|
|
8409
|
+
"number",
|
|
8410
|
+
"boolean"
|
|
8411
|
+
].includes(typeof value)));
|
|
8412
|
+
stderr(JSON.stringify({
|
|
8413
|
+
level,
|
|
8414
|
+
message,
|
|
8415
|
+
...safeContext
|
|
8416
|
+
}));
|
|
8417
|
+
};
|
|
8418
|
+
return {
|
|
8419
|
+
info: (context, message) => write("info", context, message),
|
|
8420
|
+
warn: (context, message) => write("warn", context, message)
|
|
8421
|
+
};
|
|
8422
|
+
}
|
|
8423
|
+
//#endregion
|
|
8424
|
+
//#region ../../libs/loopback-companion/src/errors.ts
|
|
8425
|
+
var LoopbackViolationError = class extends Error {
|
|
8426
|
+
name = "LoopbackViolationError";
|
|
8427
|
+
constructor(kind, message, options) {
|
|
8428
|
+
super(message, options);
|
|
8429
|
+
this.kind = kind;
|
|
8430
|
+
}
|
|
8431
|
+
};
|
|
8432
|
+
function isLoopbackViolation(error) {
|
|
8433
|
+
return error instanceof LoopbackViolationError;
|
|
8434
|
+
}
|
|
8435
|
+
//#endregion
|
|
8436
|
+
//#region ../../libs/loopback-companion/src/origin.ts
|
|
8437
|
+
/** Hostnames accepted as loopback for companion servers and origins. */
|
|
8438
|
+
function isLoopbackHostname(hostname) {
|
|
8439
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
8440
|
+
}
|
|
8441
|
+
/**
|
|
8442
|
+
* Normalize and validate a browser origin. Accepts exact `https:` origins,
|
|
8443
|
+
* or `http:` origins whose host is loopback. Rejects values that carry a
|
|
8444
|
+
* path, trailing slash, credentials, or any other non-origin decoration
|
|
8445
|
+
* (`url.origin !== value` catches all of those).
|
|
8446
|
+
*/
|
|
8447
|
+
function normalizeOrigin(value) {
|
|
8448
|
+
let url;
|
|
8449
|
+
try {
|
|
8450
|
+
url = new URL(value);
|
|
8451
|
+
} catch (cause) {
|
|
8452
|
+
throw new LoopbackViolationError("origin_invalid", "Origin is not valid", { cause });
|
|
8453
|
+
}
|
|
8454
|
+
if (url.origin !== value || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new LoopbackViolationError("origin_invalid", "Origin is not valid");
|
|
8455
|
+
return url.origin;
|
|
8456
|
+
}
|
|
8457
|
+
/** Parse a comma-separated origin list (config format shared by companions). */
|
|
8458
|
+
function parseAllowedOrigins(csv) {
|
|
8459
|
+
return csv.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
8460
|
+
}
|
|
8461
|
+
/**
|
|
8462
|
+
* Exact-origin allowlist. Every configured origin is normalized eagerly so a
|
|
8463
|
+
* misconfigured allowlist fails at startup, not at request time.
|
|
8464
|
+
*/
|
|
8465
|
+
var OriginAllowlist = class {
|
|
8466
|
+
origins;
|
|
8467
|
+
constructor(allowedOrigins) {
|
|
8468
|
+
if (allowedOrigins.length === 0) throw new Error("OriginAllowlist requires at least one origin");
|
|
8469
|
+
this.origins = new Set(allowedOrigins.map((origin) => normalizeOrigin(origin)));
|
|
8470
|
+
}
|
|
8471
|
+
has(origin) {
|
|
8472
|
+
try {
|
|
8473
|
+
return this.origins.has(normalizeOrigin(origin));
|
|
8474
|
+
} catch {
|
|
8475
|
+
return false;
|
|
8476
|
+
}
|
|
8477
|
+
}
|
|
8478
|
+
/** Return the normalized origin or throw `origin_not_allowed`. */
|
|
8479
|
+
assert(value) {
|
|
8480
|
+
let origin;
|
|
8481
|
+
try {
|
|
8482
|
+
origin = normalizeOrigin(value);
|
|
8483
|
+
} catch {
|
|
8484
|
+
throw new LoopbackViolationError("origin_not_allowed", "Origin is not allowed");
|
|
8485
|
+
}
|
|
8486
|
+
if (!this.origins.has(origin)) throw new LoopbackViolationError("origin_not_allowed", "Origin is not allowed");
|
|
8487
|
+
return origin;
|
|
8488
|
+
}
|
|
8489
|
+
};
|
|
8490
|
+
/** Extract and require the `Origin` header value. */
|
|
8491
|
+
function requireOriginHeader(headers) {
|
|
8492
|
+
const origin = headers.origin;
|
|
8493
|
+
if (typeof origin !== "string" || origin.length === 0) throw new LoopbackViolationError("origin_required", "Origin is required");
|
|
8494
|
+
return origin;
|
|
8495
|
+
}
|
|
8496
|
+
//#endregion
|
|
8497
|
+
//#region ../../libs/loopback-companion/src/fastify.ts
|
|
8498
|
+
var CORS_PREFLIGHT_MAX_AGE_SECONDS = 600;
|
|
8499
|
+
var PRIVATE_NETWORK_REQUEST_HEADER = "access-control-request-private-network";
|
|
8500
|
+
var PRIVATE_NETWORK_ALLOW_HEADER = "access-control-allow-private-network";
|
|
8501
|
+
/**
|
|
8502
|
+
* Enforce that the `Host` header identifies loopback. Blocks DNS-rebinding
|
|
8503
|
+
* setups where a public hostname resolves to 127.0.0.1: the browser then
|
|
8504
|
+
* sends that hostname as `Host`, and the request is refused here even
|
|
8505
|
+
* though the socket is loopback.
|
|
8506
|
+
*/
|
|
8507
|
+
function requireLoopbackHost(request) {
|
|
8508
|
+
const host = request.headers.host;
|
|
8509
|
+
if (!host) throw new LoopbackViolationError("host_required", "Host header is required");
|
|
8510
|
+
let hostname;
|
|
8511
|
+
try {
|
|
8512
|
+
hostname = new URL(`http://${host}`).hostname;
|
|
8513
|
+
} catch {
|
|
8514
|
+
throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
8515
|
+
}
|
|
8516
|
+
if (!isLoopbackHostname(hostname === "::1" ? "[::1]" : hostname)) throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
8517
|
+
}
|
|
8518
|
+
/**
|
|
8519
|
+
* Register the loopback-companion security profile on a Fastify app:
|
|
8520
|
+
*
|
|
8521
|
+
* - loopback `Host` enforcement on every request;
|
|
8522
|
+
* - `cache-control: no-store` on every response;
|
|
8523
|
+
* - strict UTF-8 JSON body parsing (invalid bodies raise a typed violation);
|
|
8524
|
+
* - exact-origin CORS (opaque/`null` origins get no CORS response but are
|
|
8525
|
+
* not rejected here — route-level controls stay mandatory);
|
|
8526
|
+
* - hardened helmet defaults.
|
|
8527
|
+
*
|
|
8528
|
+
*/
|
|
8529
|
+
function registerLoopbackSecurity(app, options) {
|
|
8530
|
+
if (!options.allowedOrigins && !options.isOriginAllowed) throw new Error("registerLoopbackSecurity requires allowedOrigins or isOriginAllowed");
|
|
8531
|
+
const primaryAllowlist = options.allowedOrigins ? new OriginAllowlist(options.allowedOrigins) : null;
|
|
8532
|
+
const selfAllowlist = options.selfOrigins && options.selfOrigins.length > 0 ? new OriginAllowlist(options.selfOrigins) : null;
|
|
8533
|
+
const isOriginAllowed = (origin) => selfAllowlist?.has(origin) === true || primaryAllowlist?.has(origin) === true || options.isOriginAllowed?.(origin) === true;
|
|
8534
|
+
app.addHook("onRequest", (request, _reply, done) => {
|
|
8535
|
+
requireLoopbackHost(request);
|
|
8536
|
+
done();
|
|
8537
|
+
});
|
|
8538
|
+
app.addHook("onSend", async (request, reply, payload) => {
|
|
8539
|
+
reply.header("cache-control", "no-store");
|
|
8540
|
+
if (request.method === "OPTIONS" && request.headers[PRIVATE_NETWORK_REQUEST_HEADER] === "true" && reply.getHeader("access-control-allow-origin") === request.headers.origin) reply.header(PRIVATE_NETWORK_ALLOW_HEADER, "true");
|
|
8541
|
+
return payload;
|
|
8542
|
+
});
|
|
8543
|
+
app.removeContentTypeParser("application/json");
|
|
8544
|
+
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
8545
|
+
try {
|
|
8546
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(typeof body === "string" ? Buffer.from(body) : body);
|
|
8547
|
+
done(null, JSON.parse(json));
|
|
8548
|
+
} catch (cause) {
|
|
8549
|
+
done(new LoopbackViolationError("body_not_utf8_json", "Request body must be valid UTF-8 JSON", { cause }), void 0);
|
|
8550
|
+
}
|
|
8551
|
+
});
|
|
8552
|
+
app.register(cors, {
|
|
8553
|
+
allowedHeaders: ["content-type", ...options.allowedHeaders ?? []],
|
|
8554
|
+
maxAge: CORS_PREFLIGHT_MAX_AGE_SECONDS,
|
|
8555
|
+
methods: [...options.methods ?? [
|
|
8556
|
+
"GET",
|
|
8557
|
+
"POST",
|
|
8558
|
+
"OPTIONS"
|
|
8559
|
+
]],
|
|
8560
|
+
origin: (origin, callback) => {
|
|
8561
|
+
if (!origin || origin === "null") {
|
|
8562
|
+
callback(null, false);
|
|
8563
|
+
return;
|
|
8564
|
+
}
|
|
8565
|
+
if (isOriginAllowed(origin)) {
|
|
8566
|
+
callback(null, true);
|
|
8567
|
+
return;
|
|
8568
|
+
}
|
|
8569
|
+
callback(new LoopbackViolationError("origin_not_allowed", "Origin is not allowed"), false);
|
|
8570
|
+
}
|
|
8571
|
+
});
|
|
8572
|
+
app.register(helmet, {
|
|
8573
|
+
contentSecurityPolicy: {
|
|
8574
|
+
useDefaults: false,
|
|
8575
|
+
directives: options.contentSecurityPolicyDirectives ?? {
|
|
8576
|
+
baseUri: ["'none'"],
|
|
8577
|
+
defaultSrc: ["'none'"],
|
|
8578
|
+
formAction: ["'self'"],
|
|
8579
|
+
frameAncestors: ["'none'"],
|
|
8580
|
+
styleSrc: ["'unsafe-inline'"]
|
|
8581
|
+
}
|
|
8582
|
+
},
|
|
8583
|
+
crossOriginEmbedderPolicy: false,
|
|
8584
|
+
crossOriginOpenerPolicy: false,
|
|
8585
|
+
crossOriginResourcePolicy: { policy: "same-origin" },
|
|
8586
|
+
hsts: false,
|
|
8587
|
+
referrerPolicy: { policy: "no-referrer" }
|
|
8588
|
+
});
|
|
7843
8589
|
}
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
...authUrl ? { authUrl } : {},
|
|
7850
|
-
...instructions ? { instructions } : {},
|
|
7851
|
-
...userCode ? { userCode } : {},
|
|
7852
|
-
...verificationUri ? { verificationUri } : {},
|
|
7853
|
-
...error ? { error } : {},
|
|
7854
|
-
...waitingForAuthorization ? { waitingForAuthorization } : {}
|
|
7855
|
-
};
|
|
8590
|
+
//#endregion
|
|
8591
|
+
//#region ../../libs/loopback-companion/src/fetch-metadata.ts
|
|
8592
|
+
function headerValue(headers, name) {
|
|
8593
|
+
const value = headers[name];
|
|
8594
|
+
return typeof value === "string" ? value : void 0;
|
|
7856
8595
|
}
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
8596
|
+
/**
|
|
8597
|
+
* Require that a request is a top-level browser navigation (used by local
|
|
8598
|
+
* approval pages that must be opened as a document, never fetched).
|
|
8599
|
+
*/
|
|
8600
|
+
function assertNavigationRequest(headers) {
|
|
8601
|
+
const site = headerValue(headers, "sec-fetch-site");
|
|
8602
|
+
const mode = headerValue(headers, "sec-fetch-mode");
|
|
8603
|
+
const destination = headerValue(headers, "sec-fetch-dest");
|
|
8604
|
+
if (site !== "cross-site" && site !== "same-origin" && site !== "none" || mode !== "navigate" || destination !== "document") throw new LoopbackViolationError("navigation_required", "Request must be opened as a browser navigation");
|
|
7865
8605
|
}
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
8606
|
+
/**
|
|
8607
|
+
* Reject an explicit cross-site Fetch-Metadata signal. Only the explicit
|
|
8608
|
+
* `cross-site` value is rejected: Safari may omit Fetch Metadata on
|
|
8609
|
+
* same-origin form submissions, so the absence of the header is not treated
|
|
8610
|
+
* as a violation — callers keep their one-time token as the primary control.
|
|
8611
|
+
*/
|
|
8612
|
+
function rejectExplicitCrossSite(headers) {
|
|
8613
|
+
if (headerValue(headers, "sec-fetch-site") === "cross-site") throw new LoopbackViolationError("cross_site_rejected", "Request must not originate cross-site");
|
|
7869
8614
|
}
|
|
7870
|
-
|
|
7871
|
-
|
|
8615
|
+
//#endregion
|
|
8616
|
+
//#region src/lib/agent-server/lock.ts
|
|
8617
|
+
var AgentServerLockError = class extends Error {
|
|
8618
|
+
name = "AgentServerLockError";
|
|
7872
8619
|
constructor(code, message, options) {
|
|
7873
8620
|
super(message, options);
|
|
7874
8621
|
this.code = code;
|
|
7875
8622
|
}
|
|
7876
8623
|
};
|
|
7877
8624
|
/**
|
|
7878
|
-
* `
|
|
7879
|
-
*
|
|
7880
|
-
*
|
|
7881
|
-
* and @moltnet/agent-config's getConfigDir both resolve `~/.config/moltnet`,
|
|
7882
|
-
* so honouring XDG here gave one application two config roots: on a machine
|
|
7883
|
-
* with the variable set, the daemon wrote identities the CLI and SDK could not
|
|
7884
|
-
* read. `MOLTNET_AGENT_SERVER_ROOT` remains the explicit escape hatch for a
|
|
7885
|
-
* genuinely custom location.
|
|
8625
|
+
* Acquire the per-root singleton lock. `proper-lockfile` uses an atomic lock
|
|
8626
|
+
* directory at exactly `<root>/agent-server.lock`, recovers stale owners, and keeps
|
|
8627
|
+
* the mtime fresh while the supervisor is alive.
|
|
7886
8628
|
*/
|
|
7887
|
-
function
|
|
7888
|
-
const
|
|
7889
|
-
|
|
7890
|
-
return getConfigDir();
|
|
7891
|
-
}
|
|
7892
|
-
function providerEnvName(providerId) {
|
|
7893
|
-
return `MOLTNET_PROVIDER_${assertProviderId(providerId).replaceAll("-", "_").toUpperCase()}_API_KEY`;
|
|
7894
|
-
}
|
|
7895
|
-
function assertProviderEnvName(providerId, value) {
|
|
7896
|
-
const expected = providerEnvName(providerId);
|
|
7897
|
-
if (value !== expected) throw new AgentServerStoreError("invalid_state", `provider envName must be ${expected}`);
|
|
7898
|
-
return value;
|
|
7899
|
-
}
|
|
7900
|
-
function readJson(path) {
|
|
7901
|
-
let raw;
|
|
7902
|
-
try {
|
|
7903
|
-
raw = readFileSync(path, "utf8");
|
|
7904
|
-
} catch (cause) {
|
|
7905
|
-
if (cause.code === "ENOENT") return null;
|
|
7906
|
-
throw new AgentServerStoreError("io_error", `could not read state at ${path}`, { cause });
|
|
7907
|
-
}
|
|
8629
|
+
async function acquireAgentServerLock(root, options = {}) {
|
|
8630
|
+
const path = join(root, "agent-server.lock");
|
|
8631
|
+
let releaseLock;
|
|
7908
8632
|
try {
|
|
7909
|
-
|
|
8633
|
+
releaseLock = await lock(root, {
|
|
8634
|
+
lockfilePath: path,
|
|
8635
|
+
realpath: false,
|
|
8636
|
+
retries: 0,
|
|
8637
|
+
...options.staleMs === void 0 ? {} : { stale: options.staleMs },
|
|
8638
|
+
...options.updateMs === void 0 ? {} : { update: options.updateMs },
|
|
8639
|
+
onCompromised: (cause) => {
|
|
8640
|
+
const error = new AgentServerLockError("compromised", `Agent Server lock ${path} was compromised: ${cause.message}`, { cause });
|
|
8641
|
+
if (options.onCompromised) {
|
|
8642
|
+
options.onCompromised(error);
|
|
8643
|
+
return;
|
|
8644
|
+
}
|
|
8645
|
+
throw error;
|
|
8646
|
+
}
|
|
8647
|
+
});
|
|
7910
8648
|
} catch (cause) {
|
|
7911
|
-
throw new
|
|
8649
|
+
if (cause.code === "ELOCKED") throw new AgentServerLockError("held", `another moltnet-agent server process already owns ${path}`, { cause });
|
|
8650
|
+
throw new AgentServerLockError("failed", `could not acquire Agent Server lock ${path}: ${cause.message}`, { cause });
|
|
7912
8651
|
}
|
|
8652
|
+
let released = false;
|
|
8653
|
+
return {
|
|
8654
|
+
path,
|
|
8655
|
+
async release() {
|
|
8656
|
+
if (released) return;
|
|
8657
|
+
released = true;
|
|
8658
|
+
await releaseLock();
|
|
8659
|
+
}
|
|
8660
|
+
};
|
|
7913
8661
|
}
|
|
7914
|
-
|
|
7915
|
-
|
|
8662
|
+
/** Always release after normal completion or startup/runtime failure. */
|
|
8663
|
+
async function withAgentServerLock(root, work, options) {
|
|
8664
|
+
const held = await acquireAgentServerLock(root, options);
|
|
7916
8665
|
try {
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
try {
|
|
7921
|
-
rmSync(temp, { force: true });
|
|
7922
|
-
} catch {}
|
|
7923
|
-
throw cause;
|
|
8666
|
+
return await work();
|
|
8667
|
+
} finally {
|
|
8668
|
+
await held.release();
|
|
7924
8669
|
}
|
|
7925
8670
|
}
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
}
|
|
7956
|
-
get statePath() {
|
|
7957
|
-
return join(this.root, "agent-server.json");
|
|
7958
|
-
}
|
|
7959
|
-
readAgentServerState() {
|
|
7960
|
-
const state = readJson(this.statePath);
|
|
7961
|
-
if (!state) return {
|
|
7962
|
-
version: 2,
|
|
7963
|
-
pendingRegistrations: {},
|
|
7964
|
-
activations: {}
|
|
7965
|
-
};
|
|
7966
|
-
if (!isRecord$1(state) || state.version !== 2) throw new AgentServerStoreError("invalid_state", `agent-server.json version ${String(isRecord$1(state) ? state.version : void 0)} is not supported; move agent-server.json aside, run \`moltnet config migrate\`, then add or attach the agents again`);
|
|
7967
|
-
if ("pairedOrigins" in state) throw new AgentServerStoreError("invalid_state", "agent-server.json uses the obsolete pairing format; move agent-server.json aside and configure the agent server again");
|
|
7968
|
-
if (!isRecord$1(state.pendingRegistrations) || !isRecord$1(state.activations)) throw new AgentServerStoreError("invalid_state", "agent-server.json is missing the version 2 activation map; move agent-server.json aside, run `moltnet config migrate`, then add or attach the agents again");
|
|
7969
|
-
for (const [alias, activation] of Object.entries(state.activations)) validateActivation(alias, activation);
|
|
7970
|
-
for (const [alias, registration] of Object.entries(state.pendingRegistrations)) {
|
|
7971
|
-
assertStoreName("agent name", alias);
|
|
7972
|
-
if (!isRecord$1(registration) || typeof registration.apiUrl !== "string" || registration.apiUrl.length === 0 || typeof registration.createdAt !== "string" || registration.createdAt.length === 0) throw new AgentServerStoreError("invalid_state", `pending registration "${alias}" is not valid`);
|
|
7973
|
-
}
|
|
7974
|
-
return {
|
|
7975
|
-
version: 2,
|
|
7976
|
-
pendingRegistrations: state.pendingRegistrations,
|
|
7977
|
-
activations: state.activations
|
|
7978
|
-
};
|
|
7979
|
-
}
|
|
7980
|
-
writeAgentServerState(state) {
|
|
7981
|
-
writeJsonAtomic(this.statePath, state);
|
|
7982
|
-
}
|
|
7983
|
-
agentPath(name) {
|
|
7984
|
-
return join(storeChildPath(this.identitiesDir, "identity alias", name), "moltnet.json");
|
|
7985
|
-
}
|
|
7986
|
-
/** Central identity directory, shared with the Go CLI layout. */
|
|
7987
|
-
identityDir(name) {
|
|
7988
|
-
return storeChildPath(this.identitiesDir, "identity alias", name);
|
|
7989
|
-
}
|
|
7990
|
-
get identitySelectorPath() {
|
|
7991
|
-
return join(this.root, "identity-selector.json");
|
|
7992
|
-
}
|
|
7993
|
-
readIdentitySelector() {
|
|
7994
|
-
const selector = readJson(this.identitySelectorPath);
|
|
7995
|
-
if (!selector) return null;
|
|
7996
|
-
if (!isRecord$1(selector) || selector.version !== 1 || selector.default_identity !== void 0 && typeof selector.default_identity !== "string") throw new AgentServerStoreError("invalid_state", "identity-selector.json is not a supported selector document");
|
|
7997
|
-
if (selector.default_identity) assertStoreName("identity alias", selector.default_identity);
|
|
7998
|
-
return selector;
|
|
7999
|
-
}
|
|
8000
|
-
writeIdentitySelector(alias) {
|
|
8001
|
-
writeJsonAtomic(this.identitySelectorPath, {
|
|
8002
|
-
version: 1,
|
|
8003
|
-
default_identity: assertStoreName("identity alias", alias)
|
|
8004
|
-
});
|
|
8005
|
-
}
|
|
8006
|
-
resolveIdentityAlias(explicit, active) {
|
|
8007
|
-
const alias = explicit?.trim() || active?.trim() || this.readIdentitySelector()?.default_identity;
|
|
8008
|
-
if (!alias) throw new AgentServerStoreError("not_found", "no active identity selected");
|
|
8009
|
-
return assertStoreName("identity alias", alias);
|
|
8010
|
-
}
|
|
8011
|
-
readAgentConfig(alias) {
|
|
8012
|
-
return readJson(this.agentPath(alias));
|
|
8671
|
+
//#endregion
|
|
8672
|
+
//#region src/lib/agent-server/pairing.ts
|
|
8673
|
+
/**
|
|
8674
|
+
* One-click pairing ceremony for the agent server, mirroring the signer's
|
|
8675
|
+
* session/ceremony pattern (#2062 design):
|
|
8676
|
+
*
|
|
8677
|
+
* 1. Console (allowed origin) POSTs `/v1/pairings` → pending pairing bound
|
|
8678
|
+
* to that origin, with a one-time confirmation token.
|
|
8679
|
+
* 2. Console opens `http://127.0.0.1:<port>/pairings/<id>` in a new tab —
|
|
8680
|
+
* a navigation-gated local approval page naming the origin.
|
|
8681
|
+
* 3. One click POSTs the confirmation form (explicit cross-site rejected;
|
|
8682
|
+
* the one-time token is the primary CSRF control).
|
|
8683
|
+
* 4. Console claims `/v1/pairings/<id>/claim` from the same origin and
|
|
8684
|
+
* receives the bearer token exactly once; only its SHA-256 remains in
|
|
8685
|
+
* this supervisor process.
|
|
8686
|
+
*
|
|
8687
|
+
* The token exists for shared-machine cross-user protection and to bind
|
|
8688
|
+
* "this console session is the operator" — browser-vs-browser isolation is
|
|
8689
|
+
* already covered by the loopback-companion origin checks. Grants are
|
|
8690
|
+
* deliberately process-scoped: after the listening socket changes owners, a
|
|
8691
|
+
* token disclosed to an impostor on that port cannot authenticate to a later
|
|
8692
|
+
* supervisor process.
|
|
8693
|
+
*/
|
|
8694
|
+
var PENDING_TTL_MS = 600 * 1e3;
|
|
8695
|
+
var AgentServerPairingError = class extends Error {
|
|
8696
|
+
name = "AgentServerPairingError";
|
|
8697
|
+
constructor(code, message) {
|
|
8698
|
+
super(message);
|
|
8699
|
+
this.code = code;
|
|
8013
8700
|
}
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8701
|
+
};
|
|
8702
|
+
function sha256Hex(value) {
|
|
8703
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
8704
|
+
}
|
|
8705
|
+
function safeEqual(a, b) {
|
|
8706
|
+
const left = Buffer.from(a, "utf8");
|
|
8707
|
+
const right = Buffer.from(b, "utf8");
|
|
8708
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
8709
|
+
}
|
|
8710
|
+
var PairingService = class {
|
|
8711
|
+
pending = /* @__PURE__ */ new Map();
|
|
8712
|
+
paired = /* @__PURE__ */ new Map();
|
|
8713
|
+
constructor(options = {}) {
|
|
8714
|
+
this.options = options;
|
|
8021
8715
|
}
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
this.clearIdentitySelectorIfDefault(alias);
|
|
8716
|
+
now() {
|
|
8717
|
+
return this.options.now?.() ?? Date.now();
|
|
8025
8718
|
}
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
let selector = null;
|
|
8029
|
-
try {
|
|
8030
|
-
selector = this.readIdentitySelector();
|
|
8031
|
-
} catch {
|
|
8032
|
-
return;
|
|
8033
|
-
}
|
|
8034
|
-
if (!selector || selector.default_identity !== alias) return;
|
|
8035
|
-
writeJsonAtomic(this.identitySelectorPath, { version: 1 });
|
|
8719
|
+
token() {
|
|
8720
|
+
return this.options.randomToken?.() ?? randomBytes(32).toString("base64url");
|
|
8036
8721
|
}
|
|
8037
|
-
|
|
8038
|
-
|
|
8722
|
+
sweep() {
|
|
8723
|
+
const now = this.now();
|
|
8724
|
+
for (const [id, pairing] of this.pending) if (pairing.expiresAt <= now) this.pending.delete(id);
|
|
8039
8725
|
}
|
|
8040
|
-
|
|
8041
|
-
|
|
8726
|
+
start(origin) {
|
|
8727
|
+
this.sweep();
|
|
8728
|
+
const pairingId = randomBytes(12).toString("hex");
|
|
8729
|
+
this.pending.set(pairingId, {
|
|
8730
|
+
origin,
|
|
8731
|
+
confirmToken: this.token(),
|
|
8732
|
+
expiresAt: this.now() + PENDING_TTL_MS,
|
|
8733
|
+
approved: false,
|
|
8734
|
+
bearerToken: null
|
|
8735
|
+
});
|
|
8736
|
+
return {
|
|
8737
|
+
pairingId,
|
|
8738
|
+
approvalPath: `/pairings/${pairingId}`
|
|
8739
|
+
};
|
|
8042
8740
|
}
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
const
|
|
8046
|
-
if (
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8741
|
+
/** Data for the local approval page. */
|
|
8742
|
+
approval(pairingId) {
|
|
8743
|
+
const pairing = this.require(pairingId);
|
|
8744
|
+
if (pairing.approved) throw new AgentServerPairingError("pairing_invalid", "Pairing is already approved");
|
|
8745
|
+
return {
|
|
8746
|
+
origin: pairing.origin,
|
|
8747
|
+
confirmToken: pairing.confirmToken
|
|
8050
8748
|
};
|
|
8051
|
-
this.writeAgentServerState(state);
|
|
8052
8749
|
}
|
|
8053
|
-
|
|
8054
|
-
const
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
this.
|
|
8750
|
+
confirm(pairingId, confirmToken) {
|
|
8751
|
+
const pairing = this.require(pairingId);
|
|
8752
|
+
if (pairing.approved || !safeEqual(pairing.confirmToken, confirmToken)) throw new AgentServerPairingError("pairing_invalid", "Confirmation token is not valid");
|
|
8753
|
+
pairing.approved = true;
|
|
8754
|
+
pairing.bearerToken = this.token();
|
|
8755
|
+
return { origin: pairing.origin };
|
|
8058
8756
|
}
|
|
8059
|
-
|
|
8060
|
-
const
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
this.
|
|
8757
|
+
claim(pairingId, origin) {
|
|
8758
|
+
const pairing = this.require(pairingId);
|
|
8759
|
+
if (pairing.origin !== origin) throw new AgentServerPairingError("pairing_origin_mismatch", "Pairing belongs to a different origin");
|
|
8760
|
+
if (!pairing.approved || !pairing.bearerToken) throw new AgentServerPairingError("pairing_not_approved", "Pairing has not been approved yet");
|
|
8761
|
+
const token = pairing.bearerToken;
|
|
8762
|
+
this.pending.delete(pairingId);
|
|
8763
|
+
this.paired.set(origin, sha256Hex(token));
|
|
8764
|
+
return { token };
|
|
8066
8765
|
}
|
|
8067
|
-
|
|
8068
|
-
|
|
8766
|
+
verify(origin, token) {
|
|
8767
|
+
const tokenHash = this.paired.get(origin);
|
|
8768
|
+
if (!tokenHash || !safeEqual(tokenHash, sha256Hex(token))) throw new AgentServerPairingError("pairing_token_invalid", "Pairing token is not valid for this origin");
|
|
8069
8769
|
}
|
|
8070
|
-
|
|
8071
|
-
|
|
8770
|
+
require(pairingId) {
|
|
8771
|
+
this.sweep();
|
|
8772
|
+
const pairing = this.pending.get(pairingId);
|
|
8773
|
+
if (!pairing) throw new AgentServerPairingError("pairing_not_found", "Pairing was not found or has expired");
|
|
8774
|
+
return pairing;
|
|
8072
8775
|
}
|
|
8073
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8776
|
+
};
|
|
8777
|
+
function escapeHtml(value) {
|
|
8778
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
8779
|
+
}
|
|
8780
|
+
/** Minimal, dependency-free local approval page. */
|
|
8781
|
+
function renderPairingApprovalPage(input) {
|
|
8782
|
+
return `<!doctype html>
|
|
8783
|
+
<html lang="en">
|
|
8784
|
+
<head>
|
|
8785
|
+
<meta charset="utf-8" />
|
|
8786
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8787
|
+
<title>MoltNet Agent — approve connection</title>
|
|
8788
|
+
<style>
|
|
8789
|
+
:root { color-scheme: light dark; }
|
|
8790
|
+
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; background: Canvas; color: CanvasText; }
|
|
8791
|
+
main { max-width: 26rem; padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 12px; }
|
|
8792
|
+
h1 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
|
8793
|
+
code { font-size: 0.95em; word-break: break-all; }
|
|
8794
|
+
button { margin-top: 1.25rem; font: inherit; padding: 0.6rem 1.4rem; border-radius: 8px; border: 1px solid color-mix(in srgb, CanvasText 30%, transparent); cursor: pointer; }
|
|
8795
|
+
p.small { font-size: 0.85rem; opacity: 0.75; }
|
|
8796
|
+
</style>
|
|
8797
|
+
</head>
|
|
8798
|
+
<body>
|
|
8799
|
+
<script>
|
|
8800
|
+
// The Console must remain this popup's opener until it finishes navigating
|
|
8801
|
+
// from about:blank. Safari rejects that cross-origin navigation otherwise.
|
|
8802
|
+
// Once this trusted local approval document has loaded, it needs no opener.
|
|
8803
|
+
window.opener = null;
|
|
8804
|
+
<\/script>
|
|
8805
|
+
<main>
|
|
8806
|
+
<h1>Allow this site to manage local MoltNet agents?</h1>
|
|
8807
|
+
<p><code>${escapeHtml(input.origin)}</code> asks to configure agents and start or stop local daemon runs on this machine.</p>
|
|
8808
|
+
<p class="small">Approve only if you opened that page yourself. This grant lasts until the local supervisor stops.</p>
|
|
8809
|
+
<form method="post" action="/pairings/${escapeHtml(input.pairingId)}/confirm">
|
|
8810
|
+
<input type="hidden" name="confirmToken" value="${escapeHtml(input.confirmToken)}" />
|
|
8811
|
+
<button type="submit">Approve</button>
|
|
8812
|
+
</form>
|
|
8813
|
+
</main>
|
|
8814
|
+
</body>
|
|
8815
|
+
</html>
|
|
8816
|
+
`;
|
|
8817
|
+
}
|
|
8818
|
+
function renderPairingResultPage(input) {
|
|
8819
|
+
return `<!doctype html>
|
|
8820
|
+
<html lang="en">
|
|
8821
|
+
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(input.title)}</title>
|
|
8822
|
+
<style>:root{color-scheme:light dark}body{margin:0;font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;background:Canvas;color:CanvasText}main{max-width:26rem;padding:2rem}</style>
|
|
8823
|
+
</head>
|
|
8824
|
+
<body><main role="status"><h1>${escapeHtml(input.title)}</h1><p>${escapeHtml(input.message)}</p><p>You can close this tab.</p></main></body>
|
|
8825
|
+
</html>
|
|
8826
|
+
`;
|
|
8827
|
+
}
|
|
8828
|
+
//#endregion
|
|
8829
|
+
//#region src/lib/agent-server/provider-login.ts
|
|
8830
|
+
/**
|
|
8831
|
+
* Subscription-provider OAuth brokering for Agent Server (#2061 slice 4).
|
|
8832
|
+
*
|
|
8833
|
+
* The console clicks "Connect"; agent server runs the Pi OAuth flow host-side via
|
|
8834
|
+
* `ModelRuntime.login()` (which owns persistence into the shared
|
|
8835
|
+
* `pi/auth.json` and token rotation thereafter). The browser only ever sees
|
|
8836
|
+
* the provider's authorize URL or device code — never tokens.
|
|
8837
|
+
*
|
|
8838
|
+
* Provider ids come from Pi's model runtime, so Agent Server stays in lockstep
|
|
8839
|
+
* with supported subscription providers. GitHub Copilot is intentionally
|
|
8840
|
+
* excluded: MoltNet does not broker editor-seat subscription credentials.
|
|
8841
|
+
*/
|
|
8842
|
+
var LOGIN_TTL_MS = 600 * 1e3;
|
|
8843
|
+
/** How long `start()` waits for the flow to surface a URL / device code. */
|
|
8844
|
+
var START_INFO_TIMEOUT_MS = 5e3;
|
|
8845
|
+
var AgentServerSubscriptionError = class extends Error {
|
|
8846
|
+
name = "AgentServerSubscriptionError";
|
|
8847
|
+
constructor(code, message) {
|
|
8848
|
+
super(message);
|
|
8849
|
+
this.code = code;
|
|
8077
8850
|
}
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8851
|
+
};
|
|
8852
|
+
var silentLogger = {
|
|
8853
|
+
info: () => void 0,
|
|
8854
|
+
warn: () => void 0,
|
|
8855
|
+
error: () => void 0
|
|
8856
|
+
};
|
|
8857
|
+
var ProviderLoginService = class ProviderLoginService {
|
|
8858
|
+
logins = /* @__PURE__ */ new Map();
|
|
8859
|
+
logger;
|
|
8860
|
+
constructor(options) {
|
|
8861
|
+
this.options = options;
|
|
8862
|
+
this.logger = options.logger ?? silentLogger;
|
|
8081
8863
|
}
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
}
|
|
8864
|
+
static async create(options) {
|
|
8865
|
+
const oauthProviders = await OAuthProviderService.create({
|
|
8866
|
+
authPath: options.authPath,
|
|
8867
|
+
logger: options.logger
|
|
8868
|
+
});
|
|
8869
|
+
return new ProviderLoginService({
|
|
8870
|
+
...options,
|
|
8871
|
+
oauthProviders
|
|
8872
|
+
});
|
|
8087
8873
|
}
|
|
8088
|
-
|
|
8089
|
-
return
|
|
8874
|
+
now() {
|
|
8875
|
+
return this.options.now?.() ?? Date.now();
|
|
8090
8876
|
}
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
runDir = realpathSync(this.runDir(id));
|
|
8097
|
-
} catch (cause) {
|
|
8098
|
-
throw new AgentServerStoreError("io_error", "could not resolve run directory", { cause });
|
|
8099
|
-
}
|
|
8100
|
-
if (!isStrictDescendant(root, runDir)) throw new AgentServerStoreError("invalid_state", "run directory escapes its store");
|
|
8101
|
-
const logPath = join(runDir, "daemon.log");
|
|
8102
|
-
try {
|
|
8103
|
-
if (lstatSync(logPath).isSymbolicLink()) throw new AgentServerStoreError("invalid_state", "run log must not be a symbolic link");
|
|
8104
|
-
const resolvedLog = realpathSync(logPath);
|
|
8105
|
-
if (!isStrictDescendant(runDir, resolvedLog)) throw new AgentServerStoreError("invalid_state", "run log escapes its store");
|
|
8106
|
-
} catch (cause) {
|
|
8107
|
-
if (cause instanceof AgentServerStoreError) throw cause;
|
|
8108
|
-
if (cause.code !== "ENOENT") throw new AgentServerStoreError("io_error", "could not resolve run log", { cause });
|
|
8109
|
-
}
|
|
8110
|
-
return logPath;
|
|
8877
|
+
providers() {
|
|
8878
|
+
return (this.options.listProviders ? this.options.listProviders() : this.options.oauthProviders ? this.options.oauthProviders.list() : this.runtime().getProviders().filter(isOAuthProviderEligible).map((provider) => ({
|
|
8879
|
+
id: provider.id,
|
|
8880
|
+
name: provider.name
|
|
8881
|
+
}))).filter((provider) => isOAuthProviderIdEligible(provider.id));
|
|
8111
8882
|
}
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8883
|
+
connected(providerId) {
|
|
8884
|
+
if (this.options.isConnected) return this.options.isConnected(providerId);
|
|
8885
|
+
if (this.options.oauthProviders) return this.options.oauthProviders.list().find((provider) => provider.id === providerId)?.connected ?? false;
|
|
8886
|
+
try {
|
|
8887
|
+
return readStoredCredential(providerId, this.options.authPath) !== void 0;
|
|
8888
|
+
} catch (error) {
|
|
8889
|
+
this.logger.warn({
|
|
8890
|
+
event: "agent-server.subscription_auth_read_failed",
|
|
8891
|
+
providerId,
|
|
8892
|
+
...safeLoginError(error)
|
|
8893
|
+
}, "Could not read subscription authentication state");
|
|
8894
|
+
return false;
|
|
8895
|
+
}
|
|
8124
8896
|
}
|
|
8125
|
-
|
|
8126
|
-
|
|
8897
|
+
runtime() {
|
|
8898
|
+
if (!this.options.modelRuntime) throw new Error("ProviderLoginService requires a model runtime when production adapters are not overridden");
|
|
8899
|
+
return this.options.modelRuntime;
|
|
8127
8900
|
}
|
|
8128
|
-
|
|
8129
|
-
|
|
8901
|
+
list() {
|
|
8902
|
+
this.sweep();
|
|
8903
|
+
if (this.options.oauthProviders && !this.options.listProviders && !this.options.isConnected) return this.options.oauthProviders.list();
|
|
8904
|
+
return this.providers().map((provider) => ({
|
|
8905
|
+
...provider,
|
|
8906
|
+
connected: this.connected(provider.id)
|
|
8907
|
+
}));
|
|
8130
8908
|
}
|
|
8131
|
-
|
|
8132
|
-
|
|
8133
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
return [];
|
|
8909
|
+
sweep() {
|
|
8910
|
+
const now = this.now();
|
|
8911
|
+
for (const [id, login] of this.logins) if (login.startedAt + LOGIN_TTL_MS <= now) {
|
|
8912
|
+
if (login.status === "pending") this.invalidate(login, "expired");
|
|
8913
|
+
this.logins.delete(id);
|
|
8137
8914
|
}
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8915
|
+
}
|
|
8916
|
+
restoreCredential(login) {
|
|
8917
|
+
try {
|
|
8918
|
+
restoreStoredCredential(this.options.authPath, login.providerId, login.previousCredential);
|
|
8919
|
+
return true;
|
|
8920
|
+
} catch (error) {
|
|
8921
|
+
this.logger.error({
|
|
8922
|
+
event: "agent-server.subscription_login_cleanup_failed",
|
|
8923
|
+
operationId: login.operationId,
|
|
8924
|
+
providerId: login.providerId,
|
|
8925
|
+
...safeLoginError(error)
|
|
8926
|
+
}, "Could not restore subscription credentials after an invalidated login");
|
|
8927
|
+
return false;
|
|
8144
8928
|
}
|
|
8145
|
-
return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
8146
8929
|
}
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8930
|
+
invalidate(login, transition) {
|
|
8931
|
+
if (login.invalidated) return true;
|
|
8932
|
+
login.invalidated = true;
|
|
8933
|
+
login.abort.abort(/* @__PURE__ */ new Error(`subscription login ${transition}`));
|
|
8934
|
+
const restored = this.restoreCredential(login);
|
|
8935
|
+
login.cleanupSucceeded = restored;
|
|
8936
|
+
login.infoArrived();
|
|
8937
|
+
this.logger.info({
|
|
8938
|
+
event: "agent-server.subscription_login_transition",
|
|
8939
|
+
operationId: login.operationId,
|
|
8940
|
+
providerId: login.providerId,
|
|
8941
|
+
transition
|
|
8942
|
+
}, "Subscription login invalidated");
|
|
8943
|
+
return restored;
|
|
8944
|
+
}
|
|
8945
|
+
status(providerId) {
|
|
8946
|
+
this.sweep();
|
|
8947
|
+
const login = this.logins.get(providerId);
|
|
8948
|
+
if (!login) throw new AgentServerSubscriptionError("login_not_found", `no login in progress for "${providerId}"`);
|
|
8949
|
+
return snapshot(login);
|
|
8950
|
+
}
|
|
8951
|
+
/**
|
|
8952
|
+
* Start (or return the in-flight) login for a provider. Resolves once the
|
|
8953
|
+
* flow has surfaced an authorize URL / device code, completed, or the
|
|
8954
|
+
* start window elapsed — whichever comes first.
|
|
8955
|
+
*/
|
|
8956
|
+
async start(providerId) {
|
|
8957
|
+
this.sweep();
|
|
8958
|
+
if (!this.providers().some((provider) => provider.id === providerId)) throw new AgentServerSubscriptionError("provider_unknown", `"${providerId}" is not a known subscription provider`);
|
|
8959
|
+
const existing = this.logins.get(providerId);
|
|
8960
|
+
if (existing && existing.status === "pending") return snapshot(existing);
|
|
8961
|
+
let infoArrived = () => void 0;
|
|
8962
|
+
const infoPromise = new Promise((resolvePromise) => {
|
|
8963
|
+
infoArrived = () => resolvePromise();
|
|
8964
|
+
});
|
|
8965
|
+
const abort = new AbortController();
|
|
8966
|
+
const login = {
|
|
8967
|
+
providerId,
|
|
8968
|
+
status: "pending",
|
|
8969
|
+
operationId: randomUUID(),
|
|
8970
|
+
startedAt: this.now(),
|
|
8971
|
+
infoArrived,
|
|
8972
|
+
abort,
|
|
8973
|
+
invalidated: false,
|
|
8974
|
+
cleanupUnderProviderLock: !this.options.runLogin && Boolean(this.options.oauthProviders),
|
|
8975
|
+
cleanupSucceeded: true,
|
|
8976
|
+
previousCredential: readStoredCredential(providerId, this.options.authPath)
|
|
8977
|
+
};
|
|
8978
|
+
this.logins.set(providerId, login);
|
|
8979
|
+
this.logger.info({
|
|
8980
|
+
event: "agent-server.subscription_login_transition",
|
|
8981
|
+
operationId: login.operationId,
|
|
8982
|
+
providerId,
|
|
8983
|
+
transition: "started"
|
|
8984
|
+
}, "Subscription login started");
|
|
8985
|
+
const callbacks = createLoginCallbacks(login, this.logger);
|
|
8986
|
+
(this.options.runLogin ?? ((id, loginCallbacks) => {
|
|
8987
|
+
if (this.options.oauthProviders) return this.options.oauthProviders.login(id, toAuthInteraction(loginCallbacks), {
|
|
8988
|
+
signal: login.abort.signal,
|
|
8989
|
+
onSettledUnderLock: () => {
|
|
8990
|
+
if (login.invalidated) login.cleanupSucceeded = this.restoreCredential(login);
|
|
8991
|
+
}
|
|
8992
|
+
});
|
|
8993
|
+
return this.runtime().login(id, "oauth", toAuthInteraction(loginCallbacks)).then(() => void 0);
|
|
8994
|
+
}))(providerId, callbacks).then(() => {
|
|
8995
|
+
if (login.invalidated) {
|
|
8996
|
+
if (!login.cleanupUnderProviderLock) this.restoreCredential(login);
|
|
8997
|
+
return;
|
|
8168
8998
|
}
|
|
8169
|
-
|
|
8170
|
-
|
|
8999
|
+
if (!this.options.runLogin && !this.connected(providerId)) {
|
|
9000
|
+
login.status = "failed";
|
|
9001
|
+
login.error = "Subscription sign-in completed, but credentials were not persisted. Start again to retry.";
|
|
9002
|
+
this.logger.error({
|
|
9003
|
+
event: "agent-server.subscription_login_transition",
|
|
9004
|
+
operationId: login.operationId,
|
|
9005
|
+
providerId,
|
|
9006
|
+
transition: "persistence_failed"
|
|
9007
|
+
}, "Subscription login credentials were not persisted");
|
|
9008
|
+
} else {
|
|
9009
|
+
login.status = "completed";
|
|
9010
|
+
this.logger.info({
|
|
9011
|
+
event: "agent-server.subscription_login_transition",
|
|
9012
|
+
operationId: login.operationId,
|
|
9013
|
+
providerId,
|
|
9014
|
+
transition: "completed"
|
|
9015
|
+
}, "Subscription login completed");
|
|
9016
|
+
}
|
|
9017
|
+
login.infoArrived();
|
|
9018
|
+
}, (error) => {
|
|
9019
|
+
if (login.invalidated) {
|
|
9020
|
+
if (!login.cleanupUnderProviderLock) this.restoreCredential(login);
|
|
9021
|
+
return;
|
|
9022
|
+
}
|
|
9023
|
+
login.status = "failed";
|
|
9024
|
+
login.error = publicLoginError(error);
|
|
9025
|
+
this.logger.warn({
|
|
9026
|
+
event: "agent-server.subscription_login_transition",
|
|
9027
|
+
operationId: login.operationId,
|
|
9028
|
+
providerId,
|
|
9029
|
+
transition: "failed",
|
|
9030
|
+
...safeLoginError(error)
|
|
9031
|
+
}, "Subscription login failed");
|
|
9032
|
+
login.infoArrived();
|
|
9033
|
+
});
|
|
9034
|
+
if (!await Promise.race([infoPromise, new Promise((resolvePromise) => {
|
|
9035
|
+
setTimeout(() => resolvePromise(), START_INFO_TIMEOUT_MS).unref?.();
|
|
9036
|
+
})]).then(() => login.authUrl !== void 0 || login.userCode !== void 0 || login.status !== "pending")) {
|
|
9037
|
+
login.waitingForAuthorization = true;
|
|
9038
|
+
this.logger.warn({
|
|
9039
|
+
event: "serve.subscription_login_start_info_timeout",
|
|
9040
|
+
operationId: login.operationId,
|
|
9041
|
+
providerId,
|
|
9042
|
+
timeoutMs: START_INFO_TIMEOUT_MS
|
|
9043
|
+
}, "Subscription login is still waiting for authorization information");
|
|
8171
9044
|
}
|
|
8172
|
-
return
|
|
9045
|
+
return snapshot(login);
|
|
9046
|
+
}
|
|
9047
|
+
/** Abort an in-flight login and forget it. */
|
|
9048
|
+
cancel(providerId) {
|
|
9049
|
+
const login = this.logins.get(providerId);
|
|
9050
|
+
if (!login) throw new AgentServerSubscriptionError("login_not_found", `no login in progress for "${providerId}"`);
|
|
9051
|
+
if (!this.invalidate(login, "cancelled")) throw new AgentServerSubscriptionError("login_cleanup_failed", `could not safely cancel login for "${providerId}"; credential cleanup requires intervention`);
|
|
9052
|
+
this.logins.delete(providerId);
|
|
9053
|
+
return {
|
|
9054
|
+
providerId,
|
|
9055
|
+
status: "cancelled"
|
|
9056
|
+
};
|
|
9057
|
+
}
|
|
9058
|
+
/** Abort every pending flow during supervisor shutdown. */
|
|
9059
|
+
close() {
|
|
9060
|
+
for (const login of this.logins.values()) if (login.status === "pending") this.invalidate(login, "shutdown");
|
|
9061
|
+
this.logins.clear();
|
|
8173
9062
|
}
|
|
8174
9063
|
};
|
|
8175
|
-
function
|
|
8176
|
-
|
|
9064
|
+
function toAuthInteraction(callbacks) {
|
|
9065
|
+
return {
|
|
9066
|
+
...callbacks.signal ? { signal: callbacks.signal } : {},
|
|
9067
|
+
notify: (event) => {
|
|
9068
|
+
switch (event.type) {
|
|
9069
|
+
case "auth_url":
|
|
9070
|
+
callbacks.onAuth({
|
|
9071
|
+
url: event.url,
|
|
9072
|
+
...event.instructions ? { instructions: event.instructions } : {}
|
|
9073
|
+
});
|
|
9074
|
+
break;
|
|
9075
|
+
case "device_code":
|
|
9076
|
+
callbacks.onDeviceCode({
|
|
9077
|
+
userCode: event.userCode,
|
|
9078
|
+
verificationUri: event.verificationUri
|
|
9079
|
+
});
|
|
9080
|
+
break;
|
|
9081
|
+
case "info":
|
|
9082
|
+
case "progress":
|
|
9083
|
+
callbacks.onProgress?.(event.message);
|
|
9084
|
+
break;
|
|
9085
|
+
}
|
|
9086
|
+
},
|
|
9087
|
+
prompt: async (prompt) => {
|
|
9088
|
+
if (prompt.type !== "select") return callbacks.onPrompt({ message: prompt.message });
|
|
9089
|
+
const selected = await callbacks.onSelect({
|
|
9090
|
+
message: prompt.message,
|
|
9091
|
+
options: prompt.options.map(({ id, label }) => ({
|
|
9092
|
+
id,
|
|
9093
|
+
label
|
|
9094
|
+
}))
|
|
9095
|
+
});
|
|
9096
|
+
if (!selected) throw new AgentServerSubscriptionError("login_unsupported_prompt", "This provider flow did not offer a supported sign-in method");
|
|
9097
|
+
return selected;
|
|
9098
|
+
}
|
|
9099
|
+
};
|
|
9100
|
+
}
|
|
9101
|
+
function restoreStoredCredential(authPath, providerId, credential) {
|
|
9102
|
+
mkdirSync(dirname(authPath), {
|
|
9103
|
+
recursive: true,
|
|
9104
|
+
mode: 448
|
|
9105
|
+
});
|
|
8177
9106
|
try {
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
9107
|
+
writeFileSync(authPath, "{}\n", {
|
|
9108
|
+
flag: "wx",
|
|
9109
|
+
mode: 384
|
|
9110
|
+
});
|
|
9111
|
+
} catch (error) {
|
|
9112
|
+
if (error.code !== "EEXIST") throw error;
|
|
9113
|
+
}
|
|
9114
|
+
const release = lockSync(authPath, { realpath: false });
|
|
9115
|
+
const temp = `${authPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
9116
|
+
try {
|
|
9117
|
+
const parsed = JSON.parse(readFileSync(authPath, "utf8"));
|
|
9118
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Pi credential store is not an object: ${authPath}`);
|
|
9119
|
+
const credentials = parsed;
|
|
9120
|
+
if (credential) credentials[providerId] = credential;
|
|
9121
|
+
else delete credentials[providerId];
|
|
9122
|
+
writeFileSync(temp, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
|
|
9123
|
+
renameSync(temp, authPath);
|
|
9124
|
+
} finally {
|
|
9125
|
+
rmSync(temp, { force: true });
|
|
9126
|
+
release();
|
|
8181
9127
|
}
|
|
8182
|
-
if (info.isSymbolicLink()) return 0;
|
|
8183
|
-
if (!info.isDirectory()) return info.size;
|
|
8184
|
-
let total = 0;
|
|
8185
|
-
for (const entry of readdirSync(path, { withFileTypes: true })) total += directoryBytes(join(path, entry.name));
|
|
8186
|
-
return total;
|
|
8187
9128
|
}
|
|
8188
|
-
function
|
|
8189
|
-
return
|
|
9129
|
+
function createLoginCallbacks(login, logger) {
|
|
9130
|
+
return {
|
|
9131
|
+
onAuth: (info) => {
|
|
9132
|
+
login.authUrl = info.url;
|
|
9133
|
+
if (info.instructions) login.instructions = info.instructions;
|
|
9134
|
+
logger.info({
|
|
9135
|
+
event: "agent-server.subscription_login_transition",
|
|
9136
|
+
operationId: login.operationId,
|
|
9137
|
+
providerId: login.providerId,
|
|
9138
|
+
transition: "authorization_ready"
|
|
9139
|
+
}, "Subscription authorization URL ready");
|
|
9140
|
+
login.infoArrived();
|
|
9141
|
+
},
|
|
9142
|
+
onDeviceCode: (info) => {
|
|
9143
|
+
login.userCode = info.userCode;
|
|
9144
|
+
login.verificationUri = info.verificationUri;
|
|
9145
|
+
logger.info({
|
|
9146
|
+
event: "agent-server.subscription_login_transition",
|
|
9147
|
+
operationId: login.operationId,
|
|
9148
|
+
providerId: login.providerId,
|
|
9149
|
+
transition: "device_code_ready"
|
|
9150
|
+
}, "Subscription device code ready");
|
|
9151
|
+
login.infoArrived();
|
|
9152
|
+
},
|
|
9153
|
+
onPrompt: () => Promise.reject(new AgentServerSubscriptionError("login_unsupported_prompt", "This provider flow needs an interactive prompt; run `pi /login` in a terminal instead")),
|
|
9154
|
+
onSelect: (prompt) => Promise.resolve((prompt.options.find((option) => /device/i.test(option.id)) ?? prompt.options[0])?.id),
|
|
9155
|
+
signal: login.abort.signal
|
|
9156
|
+
};
|
|
8190
9157
|
}
|
|
8191
|
-
function
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
const candidate = resolve(normalizedRoot, `${name}${suffix}`);
|
|
8195
|
-
if (!isStrictDescendant(normalizedRoot, candidate)) throw new AgentServerStoreError("invalid_name", `${kind} escapes its store`);
|
|
8196
|
-
return candidate;
|
|
9158
|
+
function publicLoginError(error) {
|
|
9159
|
+
if (error instanceof AgentServerSubscriptionError) return error.message;
|
|
9160
|
+
return "Subscription sign-in failed. Start again to retry.";
|
|
8197
9161
|
}
|
|
8198
|
-
function
|
|
8199
|
-
const
|
|
8200
|
-
|
|
9162
|
+
function safeLoginError(error) {
|
|
9163
|
+
const result = { errorType: error instanceof Error ? error.name : typeof error };
|
|
9164
|
+
const code = error?.code;
|
|
9165
|
+
if (typeof code === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(code)) result["applicationCode"] = code;
|
|
9166
|
+
return result;
|
|
8201
9167
|
}
|
|
8202
|
-
function
|
|
8203
|
-
const
|
|
8204
|
-
|
|
9168
|
+
function snapshot(login) {
|
|
9169
|
+
const { providerId, status, authUrl, instructions, userCode, verificationUri, error, waitingForAuthorization } = login;
|
|
9170
|
+
return {
|
|
9171
|
+
providerId,
|
|
9172
|
+
status,
|
|
9173
|
+
...authUrl ? { authUrl } : {},
|
|
9174
|
+
...instructions ? { instructions } : {},
|
|
9175
|
+
...userCode ? { userCode } : {},
|
|
9176
|
+
...verificationUri ? { verificationUri } : {},
|
|
9177
|
+
...error ? { error } : {},
|
|
9178
|
+
...waitingForAuthorization ? { waitingForAuthorization } : {}
|
|
8205
9179
|
};
|
|
8206
|
-
if (!isRecord$1(value)) invalid();
|
|
8207
|
-
const activation = value;
|
|
8208
|
-
if (activation.alias !== alias) invalid();
|
|
8209
|
-
if (![
|
|
8210
|
-
"subjectId",
|
|
8211
|
-
"publicKey",
|
|
8212
|
-
"fingerprint",
|
|
8213
|
-
"createdAt"
|
|
8214
|
-
].every((field) => typeof activation[field] === "string" && activation[field].length > 0)) invalid();
|
|
8215
|
-
if (activation.source === "managed") {
|
|
8216
|
-
if (typeof activation.apiUrl !== "string" || activation.apiUrl.length === 0 || activation.configPath !== void 0 || activation.configApiUrl !== void 0) invalid();
|
|
8217
|
-
return;
|
|
8218
|
-
}
|
|
8219
|
-
if (activation.source !== "external" || typeof activation.configPath !== "string" || activation.configPath.length === 0 || typeof activation.configApiUrl !== "string" || activation.configApiUrl.length === 0 || activation.apiUrl !== void 0 && typeof activation.apiUrl !== "string") invalid();
|
|
8220
9180
|
}
|
|
8221
9181
|
//#endregion
|
|
8222
9182
|
//#region src/lib/agent-server/identity.ts
|
|
@@ -8238,13 +9198,13 @@ var AgentServerIdentityError = class extends Error {
|
|
|
8238
9198
|
this.code = code;
|
|
8239
9199
|
}
|
|
8240
9200
|
};
|
|
8241
|
-
function reserveAlias(store, alias) {
|
|
9201
|
+
function reserveAlias(store, alias, allowExistingConfig = false) {
|
|
8242
9202
|
let pending = pendingAliases.get(store);
|
|
8243
9203
|
if (!pending) {
|
|
8244
9204
|
pending = /* @__PURE__ */ new Set();
|
|
8245
9205
|
pendingAliases.set(store, pending);
|
|
8246
9206
|
}
|
|
8247
|
-
if (pending.has(alias) || store.hasPendingRegistration(alias) || store.readActivation(alias) || store.readAgentConfig(alias)) throw new AgentServerIdentityError("agent_exists", `agent "${alias}" already exists in the agent server store`);
|
|
9207
|
+
if (pending.has(alias) || store.hasPendingRegistration(alias) || store.readActivation(alias) || !allowExistingConfig && store.readAgentConfig(alias)) throw new AgentServerIdentityError("agent_exists", `agent "${alias}" already exists in the agent server store`);
|
|
8248
9208
|
pending.add(alias);
|
|
8249
9209
|
return () => pending.delete(alias);
|
|
8250
9210
|
}
|
|
@@ -8386,12 +9346,14 @@ async function reconcileManagedRegistration(store, secrets, aliasInput, action,
|
|
|
8386
9346
|
}
|
|
8387
9347
|
async function attachExternalAgent(store, secretProviders, input, connectAgent = connect) {
|
|
8388
9348
|
const alias = assertStoreName("agent name", input.name);
|
|
8389
|
-
|
|
9349
|
+
if (!isAbsolute(input.configDir)) throw new AgentServerIdentityError("config_not_found", "external configDir must be an absolute path");
|
|
9350
|
+
const configPath = join(input.configDir, "moltnet.json");
|
|
9351
|
+
const central = configPath === store.agentPath(alias);
|
|
9352
|
+
const releaseAlias = reserveAlias(store, alias, central);
|
|
8390
9353
|
try {
|
|
8391
|
-
if (!
|
|
8392
|
-
const configPath = join(input.configDir, "moltnet.json");
|
|
8393
|
-
externalAgentLocation(configPath);
|
|
9354
|
+
if (!central) externalAgentLocation(configPath);
|
|
8394
9355
|
const config = await readCurrentConfig(configPath);
|
|
9356
|
+
if (central && !config.agent_key_ref) throw new AgentServerIdentityError("unsupported_credential", `central identity "${alias}" needs a stored agent key before the Agent Server can run it`);
|
|
8395
9357
|
const configApiUrl = requireTrustedConfigApiUrl(config, configPath);
|
|
8396
9358
|
const whoami = await authenticateConfig(config, configPath, requireTrustedApiOverride(input.apiUrl, configApiUrl, configPath), secretProviders, connectAgent, input.signal);
|
|
8397
9359
|
const identity = identityFromConfig(config);
|
|
@@ -8422,7 +9384,7 @@ async function attachExternalAgent(store, secretProviders, input, connectAgent =
|
|
|
8422
9384
|
/** Load and authenticate the current config, then refresh its derived pin. */
|
|
8423
9385
|
async function verifyAgentActivation(store, alias, managedSecretProviders, externalSecretProviders, connectAgent = connect, signal) {
|
|
8424
9386
|
const activation = requireActivation(store, alias);
|
|
8425
|
-
const verified = activation.source === "managed" ? await verifyManagedActivation(store, activation, managedSecretProviders, connectAgent, signal) : await verifyExternalActivation(activation, externalSecretProviders, connectAgent, signal);
|
|
9387
|
+
const verified = activation.source === "managed" ? await verifyManagedActivation(store, activation, managedSecretProviders, connectAgent, signal) : await verifyExternalActivation(store, activation, externalSecretProviders, connectAgent, signal);
|
|
8426
9388
|
assertSubjectMatches(verified.whoami, verified.config, "authenticated whoami", `agent "${activation.alias}" config`);
|
|
8427
9389
|
if (verified.whoami.subjectId !== activation.subjectId) throw new AgentServerIdentityError("verification_failed", `authenticated whoami subject does not match agent "${activation.alias}" pinned activation`);
|
|
8428
9390
|
const identity = identityFromConfig(verified.config);
|
|
@@ -8462,10 +9424,12 @@ async function verifyManagedActivation(store, activation, secretProviders, conne
|
|
|
8462
9424
|
}, configPath, signal)
|
|
8463
9425
|
};
|
|
8464
9426
|
}
|
|
8465
|
-
async function verifyExternalActivation(activation, secretProviders, connectAgent, signal) {
|
|
8466
|
-
|
|
9427
|
+
async function verifyExternalActivation(store, activation, secretProviders, connectAgent, signal) {
|
|
9428
|
+
const central = activation.configPath === store.agentPath(activation.alias);
|
|
9429
|
+
if (!central) externalAgentLocation(activation.configPath);
|
|
8467
9430
|
assertTrustedConfigApiUrl(activation.configApiUrl);
|
|
8468
9431
|
const config = await readCurrentConfig(activation.configPath);
|
|
9432
|
+
if (central && !config.agent_key_ref) throw new AgentServerIdentityError("unsupported_credential", `central identity "${activation.alias}" needs a stored agent key before the Agent Server can run it`);
|
|
8469
9433
|
assertActivatedConfig(config, activation, activation.configPath, requireTrustedConfigApiUrl(config, activation.configPath), activation.configApiUrl);
|
|
8470
9434
|
const effectiveApiUrl = requireTrustedApiOverride(activation.apiUrl, activation.configApiUrl, activation.configPath);
|
|
8471
9435
|
return {
|
|
@@ -8576,7 +9540,7 @@ function assertIdentityMatches(current, expected, currentLabel, expectedLabel) {
|
|
|
8576
9540
|
if (!assessment.ok) throw new AgentServerIdentityError("verification_failed", `${currentLabel} ${assessment.label} does not match ${expectedLabel}`);
|
|
8577
9541
|
}
|
|
8578
9542
|
function verificationError(message, cause) {
|
|
8579
|
-
return new AgentServerIdentityError("verification_failed", message, { cause });
|
|
9543
|
+
return new AgentServerIdentityError("verification_failed", cause instanceof AuthenticationError ? cause.message : message, { cause });
|
|
8580
9544
|
}
|
|
8581
9545
|
/** Non-secret projection preserving the existing `/v1` response shape. */
|
|
8582
9546
|
function publicAgentView(store, activation) {
|
|
@@ -8666,6 +9630,8 @@ function validateRunSpec(spec) {
|
|
|
8666
9630
|
if (!spec.teamId) throw new AgentServerRunError("invalid_spec", "teamId is required");
|
|
8667
9631
|
if (!Array.isArray(spec.profiles) || spec.profiles.length === 0) throw new AgentServerRunError("invalid_spec", "at least one profile is required");
|
|
8668
9632
|
if (!Array.isArray(spec.taskTypes) || spec.taskTypes.length === 0) throw new AgentServerRunError("invalid_spec", "at least one task type is required");
|
|
9633
|
+
const unknownTaskType = spec.taskTypes.find((taskType) => !Object.prototype.hasOwnProperty.call(BUILT_IN_TASK_TYPES, taskType));
|
|
9634
|
+
if (unknownTaskType) throw new AgentServerRunError("invalid_spec", `unknown task type "${unknownTaskType}"`);
|
|
8669
9635
|
}
|
|
8670
9636
|
var RunManager = class {
|
|
8671
9637
|
active = /* @__PURE__ */ new Map();
|
|
@@ -8703,6 +9669,10 @@ var RunManager = class {
|
|
|
8703
9669
|
agentName: activation.alias,
|
|
8704
9670
|
cwd: dirname(piDir),
|
|
8705
9671
|
extraArgs: []
|
|
9672
|
+
} : activation.configPath === this.store.agentPath(activation.alias) ? {
|
|
9673
|
+
agentName: activation.alias,
|
|
9674
|
+
cwd: dirname(piDir),
|
|
9675
|
+
extraArgs: []
|
|
8706
9676
|
} : (() => {
|
|
8707
9677
|
const { agentName, agentRoot } = externalAgentLocation(activation.configPath);
|
|
8708
9678
|
return {
|
|
@@ -9319,69 +10289,6 @@ function writeRegistry(path, entries) {
|
|
|
9319
10289
|
writeFileSync(temp, `${JSON.stringify(entries, null, 2)}\n`, { mode: 384 });
|
|
9320
10290
|
renameSync(temp, path);
|
|
9321
10291
|
}
|
|
9322
|
-
var AgentServerModelDiscoveryError = class extends Error {
|
|
9323
|
-
name = "AgentServerModelDiscoveryError";
|
|
9324
|
-
constructor(code, message, statusCode, options) {
|
|
9325
|
-
super(message, options);
|
|
9326
|
-
this.code = code;
|
|
9327
|
-
this.statusCode = statusCode;
|
|
9328
|
-
}
|
|
9329
|
-
};
|
|
9330
|
-
var ModelDiscoveryCollector = class {
|
|
9331
|
-
models = /* @__PURE__ */ new Set();
|
|
9332
|
-
addOpenAiResponse(value) {
|
|
9333
|
-
if (!isRecord(value) || !Array.isArray(value["data"])) return;
|
|
9334
|
-
for (const candidate of value["data"]) {
|
|
9335
|
-
if (!isRecord(candidate)) continue;
|
|
9336
|
-
const id = candidate["id"];
|
|
9337
|
-
if (typeof id === "string" && id.length > 0) this.models.add(id);
|
|
9338
|
-
}
|
|
9339
|
-
}
|
|
9340
|
-
addOllamaResponse(value) {
|
|
9341
|
-
if (!isRecord(value) || !Array.isArray(value["models"])) return;
|
|
9342
|
-
for (const candidate of value["models"]) {
|
|
9343
|
-
if (!isRecord(candidate)) continue;
|
|
9344
|
-
const name = candidate["name"];
|
|
9345
|
-
if (typeof name === "string" && name.length > 0) this.models.add(name);
|
|
9346
|
-
}
|
|
9347
|
-
}
|
|
9348
|
-
get size() {
|
|
9349
|
-
return this.models.size;
|
|
9350
|
-
}
|
|
9351
|
-
result(providerId, failures) {
|
|
9352
|
-
if (this.models.size === 0) throw discoveryFailure(providerId, failures);
|
|
9353
|
-
return {
|
|
9354
|
-
models: [...this.models].sort().slice(0, 500),
|
|
9355
|
-
discoveredCount: this.models.size
|
|
9356
|
-
};
|
|
9357
|
-
}
|
|
9358
|
-
};
|
|
9359
|
-
function parseProviderBaseUrl(value, providerId) {
|
|
9360
|
-
let parsed;
|
|
9361
|
-
try {
|
|
9362
|
-
parsed = new URL(value);
|
|
9363
|
-
} catch (cause) {
|
|
9364
|
-
throw new AgentServerModelDiscoveryError("invalid_provider", `provider "${providerId}" has an invalid base URL`, 400, { cause });
|
|
9365
|
-
}
|
|
9366
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new AgentServerModelDiscoveryError("invalid_provider", `provider "${providerId}" base URL must be HTTP(S) without credentials, query, or fragment`, 400);
|
|
9367
|
-
if (isNonLoopbackPrivateAddress(parsed.hostname)) throw new AgentServerModelDiscoveryError("invalid_provider", `provider "${providerId}" base URL must not target a private network address`, 400);
|
|
9368
|
-
return parsed;
|
|
9369
|
-
}
|
|
9370
|
-
function isNonLoopbackPrivateAddress(hostname) {
|
|
9371
|
-
if (isIP(hostname) !== 4) return false;
|
|
9372
|
-
const [first, second] = hostname.split(".").map(Number);
|
|
9373
|
-
if (first === 127) return false;
|
|
9374
|
-
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
|
|
9375
|
-
}
|
|
9376
|
-
function discoveryFailure(providerId, failures) {
|
|
9377
|
-
if (failures.some((failure) => failure.kind === "http" && (failure.status === 401 || failure.status === 403))) return new AgentServerModelDiscoveryError("discovery_unauthorized", `provider "${providerId}" rejected model discovery; check its API key`, 502);
|
|
9378
|
-
if (failures.some((failure) => failure.kind === "network")) return new AgentServerModelDiscoveryError("discovery_unavailable", `provider "${providerId}" could not be reached for model discovery`, 502);
|
|
9379
|
-
if (failures.some((failure) => failure.kind === "invalid_response")) return new AgentServerModelDiscoveryError("discovery_invalid_response", `provider "${providerId}" returned an invalid model response`, 502);
|
|
9380
|
-
return new AgentServerModelDiscoveryError("discovery_failed", `no models discovered for provider "${providerId}"`, 502);
|
|
9381
|
-
}
|
|
9382
|
-
function isRecord(value) {
|
|
9383
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9384
|
-
}
|
|
9385
10292
|
//#endregion
|
|
9386
10293
|
//#region src/lib/agent-server/protocol.ts
|
|
9387
10294
|
var DateTime = Type.String({ format: "date-time" });
|
|
@@ -9408,6 +10315,12 @@ var AgentServerAgentSchema = Type.Object({
|
|
|
9408
10315
|
hasAgentKey: Type.Optional(Type.Boolean()),
|
|
9409
10316
|
hasPrivateKey: Type.Optional(Type.Boolean())
|
|
9410
10317
|
}, { $id: "AgentServerAgent" });
|
|
10318
|
+
var AgentServerIdentitySchema = Type.Object({
|
|
10319
|
+
alias: Type.String(),
|
|
10320
|
+
activated: Type.Boolean(),
|
|
10321
|
+
hasAgentKey: Type.Boolean()
|
|
10322
|
+
}, { $id: "AgentServerIdentity" });
|
|
10323
|
+
var AgentServerTaskTypeSchema = Type.Union(REGISTERED_TASK_TYPES.map((taskType) => Type.Literal(taskType)), { $id: "AgentServerTaskType" });
|
|
9411
10324
|
var AgentServerProviderSchema = Type.Object({
|
|
9412
10325
|
api: Type.String(),
|
|
9413
10326
|
baseUrl: Type.String({ format: "uri" }),
|
|
@@ -9457,6 +10370,8 @@ var AgentServerStatusSchema = Type.Object({
|
|
|
9457
10370
|
platform: Type.String(),
|
|
9458
10371
|
subscriptions: Type.Array(schemaRef(AgentServerSubscriptionSchema)),
|
|
9459
10372
|
agents: Type.Array(schemaRef(AgentServerAgentSchema)),
|
|
10373
|
+
identities: Type.Array(schemaRef(AgentServerIdentitySchema)),
|
|
10374
|
+
selectedIdentity: Type.Optional(Type.String()),
|
|
9460
10375
|
providers: Type.Record(Type.String(), schemaRef(AgentServerProviderSchema)),
|
|
9461
10376
|
runs: Type.Array(schemaRef(AgentServerRunSchema))
|
|
9462
10377
|
}, { $id: "AgentServerStatus" });
|
|
@@ -9469,17 +10384,14 @@ var PairingParamsSchema = Type.Object({ pairingId: Type.String() });
|
|
|
9469
10384
|
var ProviderParamsSchema = Type.Object({ providerId: Type.String() });
|
|
9470
10385
|
var AgentParamsSchema = Type.Object({ agentName: Type.String() });
|
|
9471
10386
|
var RunParamsSchema = Type.Object({ runId: Type.String() });
|
|
9472
|
-
var CreateAgentSchema = Type.Object({
|
|
9473
|
-
kind: Type.Union([Type.Literal("managed"), Type.Literal("external")]),
|
|
9474
|
-
name: Type.String()
|
|
9475
|
-
}, { anyOf: [Type.Object({
|
|
10387
|
+
var CreateAgentSchema = Type.Union([Type.Object({
|
|
9476
10388
|
kind: Type.Literal("managed"),
|
|
10389
|
+
name: Type.String(),
|
|
9477
10390
|
enrollmentToken: Type.String()
|
|
9478
10391
|
}), Type.Object({
|
|
9479
10392
|
kind: Type.Literal("external"),
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
})] });
|
|
10393
|
+
identityAlias: Type.String()
|
|
10394
|
+
})]);
|
|
9483
10395
|
var ReconcileAgentSchema = Type.Object({ action: Type.Union([Type.Literal("resume"), Type.Literal("abandon")]) });
|
|
9484
10396
|
var ReconcileAgentResultSchema = Type.Union([schemaRef(AgentServerAgentSchema), Type.Object({ abandoned: Type.Literal(true) })], { $id: "ReconcileAgentResult" });
|
|
9485
10397
|
var PutProviderSchema = Type.Object({
|
|
@@ -9494,7 +10406,7 @@ var StartRunSchema = Type.Object({
|
|
|
9494
10406
|
agent: Type.String(),
|
|
9495
10407
|
teamId: Type.String(),
|
|
9496
10408
|
profiles: StringList,
|
|
9497
|
-
taskTypes:
|
|
10409
|
+
taskTypes: Type.Array(AgentServerTaskTypeSchema),
|
|
9498
10410
|
mode: Type.Union([Type.Literal("poll"), Type.Literal("drain")])
|
|
9499
10411
|
});
|
|
9500
10412
|
var CancelledSubscriptionSchema = Type.Object({
|
|
@@ -9509,6 +10421,8 @@ var AGENT_SERVER_SCHEMAS = [
|
|
|
9509
10421
|
AgentServerHealthSchema,
|
|
9510
10422
|
AgentServerProblemSchema,
|
|
9511
10423
|
AgentServerAgentSchema,
|
|
10424
|
+
AgentServerIdentitySchema,
|
|
10425
|
+
AgentServerTaskTypeSchema,
|
|
9512
10426
|
AgentServerProviderSchema,
|
|
9513
10427
|
AgentServerRunRecordSchema,
|
|
9514
10428
|
AgentServerRunSchema,
|
|
@@ -9804,8 +10718,8 @@ function stringArray(body, field, options = {}) {
|
|
|
9804
10718
|
}
|
|
9805
10719
|
function requestOperationSignal(request, shutdownSignal) {
|
|
9806
10720
|
const disconnected = new AbortController();
|
|
9807
|
-
if (request.raw.aborted) disconnected.abort();
|
|
9808
|
-
else request.raw.once("aborted", () => disconnected.abort());
|
|
10721
|
+
if (request.raw.aborted) disconnected.abort({ source: "request" });
|
|
10722
|
+
else request.raw.once("aborted", () => disconnected.abort({ source: "request" }));
|
|
9809
10723
|
return shutdownSignal ? AbortSignal.any([disconnected.signal, shutdownSignal]) : disconnected.signal;
|
|
9810
10724
|
}
|
|
9811
10725
|
function buildAgentServer(options) {
|
|
@@ -9924,12 +10838,15 @@ function registerStatusRoute(app, options, requirePairedOrigin) {
|
|
|
9924
10838
|
const { store, runs } = options;
|
|
9925
10839
|
app.get("/v1/status", { schema: AgentServerRouteSchemas.status }, async (request) => {
|
|
9926
10840
|
requirePairedOrigin(request);
|
|
10841
|
+
const selected = selectedIdentity(store, options.activeIdentity);
|
|
9927
10842
|
return {
|
|
9928
10843
|
version: options.version,
|
|
9929
10844
|
platform: process.platform,
|
|
9930
10845
|
subscriptions: options.subscriptions.list(),
|
|
9931
10846
|
agents: store.listActivations().map((activation) => publicAgentView(store, activation)),
|
|
9932
|
-
|
|
10847
|
+
identities: identityViews(store),
|
|
10848
|
+
...selected ? { selectedIdentity: selected } : {},
|
|
10849
|
+
providers: options.providers.list(),
|
|
9933
10850
|
runs: runViews(runs)
|
|
9934
10851
|
};
|
|
9935
10852
|
});
|
|
@@ -9959,11 +10876,10 @@ function registerAgentRoutes(app, options, requirePairedOrigin) {
|
|
|
9959
10876
|
return reply.code(201).send(publicAgentView(store, entry.activation));
|
|
9960
10877
|
}
|
|
9961
10878
|
if (kind === "external") {
|
|
9962
|
-
const
|
|
10879
|
+
const identityAlias = requireString(body, "identityAlias");
|
|
9963
10880
|
const entry = await attachExternalAgent(store, options.externalSecretProviders, {
|
|
9964
|
-
name:
|
|
9965
|
-
configDir:
|
|
9966
|
-
...apiUrl ? { apiUrl } : {},
|
|
10881
|
+
name: identityAlias,
|
|
10882
|
+
configDir: store.identityDir(identityAlias),
|
|
9967
10883
|
signal
|
|
9968
10884
|
});
|
|
9969
10885
|
return reply.code(201).send(publicAgentView(store, entry.activation));
|
|
@@ -9982,153 +10898,60 @@ function registerAgentRoutes(app, options, requirePairedOrigin) {
|
|
|
9982
10898
|
return reconciled ? publicAgentView(store, reconciled.activation) : { abandoned: true };
|
|
9983
10899
|
});
|
|
9984
10900
|
}
|
|
10901
|
+
function selectedIdentity(store, activeIdentity) {
|
|
10902
|
+
try {
|
|
10903
|
+
return store.resolveIdentityAlias(void 0, activeIdentity);
|
|
10904
|
+
} catch (error) {
|
|
10905
|
+
if (error instanceof AgentServerStoreError && error.code === "not_found") return;
|
|
10906
|
+
throw error;
|
|
10907
|
+
}
|
|
10908
|
+
}
|
|
10909
|
+
function identityViews(store) {
|
|
10910
|
+
const activated = new Set(store.listActivations().map((activation) => activation.alias));
|
|
10911
|
+
return store.listIdentityAliases().map((alias) => ({
|
|
10912
|
+
alias,
|
|
10913
|
+
activated: activated.has(alias),
|
|
10914
|
+
hasAgentKey: Boolean(store.readAgentConfig(alias)?.agent_key_ref)
|
|
10915
|
+
}));
|
|
10916
|
+
}
|
|
9985
10917
|
function registerProviderRoutes(app, options, requirePairedOrigin) {
|
|
9986
|
-
const { store } = options;
|
|
9987
|
-
let mutationQueue = Promise.resolve();
|
|
9988
|
-
const serialize = (mutation) => {
|
|
9989
|
-
const result = mutationQueue.then(mutation, mutation);
|
|
9990
|
-
mutationQueue = result.then(() => void 0, () => void 0);
|
|
9991
|
-
return result;
|
|
9992
|
-
};
|
|
9993
10918
|
app.get("/v1/providers", { schema: AgentServerRouteSchemas.listProviders }, async (request) => {
|
|
9994
10919
|
requirePairedOrigin(request);
|
|
9995
|
-
return
|
|
10920
|
+
return options.providers.list();
|
|
9996
10921
|
});
|
|
9997
10922
|
app.post("/v1/providers/:providerId/discover-models", { schema: AgentServerRouteSchemas.discoverModels }, async (request) => {
|
|
9998
10923
|
requirePairedOrigin(request);
|
|
9999
|
-
const { providerId
|
|
10000
|
-
|
|
10001
|
-
const provider = store.readProviders()[providerId];
|
|
10002
|
-
if (!provider) throw new AgentServerHttpError(404, "provider_not_found", `provider "${providerId}" was not found`);
|
|
10003
|
-
const parsed = parseProviderBaseUrl(provider.baseUrl, providerId);
|
|
10004
|
-
const baseUrl = parsed.href.replace(/\/$/u, "");
|
|
10005
|
-
let apiKey;
|
|
10006
|
-
if (provider.apiKeyRef) try {
|
|
10007
|
-
apiKey = await options.secretProviders.resolve(parseSecretReferenceString(provider.apiKeyRef));
|
|
10008
|
-
} catch (error) {
|
|
10009
|
-
request.log.warn({
|
|
10010
|
-
...safeErrorContext(error),
|
|
10011
|
-
code: "agent_server_provider_secret_unavailable",
|
|
10012
|
-
providerId
|
|
10013
|
-
}, "Provider API key could not be resolved for model discovery");
|
|
10014
|
-
throw new AgentServerHttpError(400, "provider_secret_unavailable", `provider "${providerId}" API key could not be resolved`);
|
|
10015
|
-
}
|
|
10016
|
-
const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
10017
|
-
const fetchImpl = options.discoverFetch ?? fetch;
|
|
10018
|
-
const failures = [];
|
|
10019
|
-
const collector = new ModelDiscoveryCollector();
|
|
10020
|
-
const tryJson = async (endpoint, url) => {
|
|
10021
|
-
let response;
|
|
10022
|
-
try {
|
|
10023
|
-
response = await fetchImpl(url, {
|
|
10024
|
-
headers,
|
|
10025
|
-
redirect: "error",
|
|
10026
|
-
signal: AbortSignal.timeout(1e4)
|
|
10027
|
-
});
|
|
10028
|
-
} catch (error) {
|
|
10029
|
-
const errorType = error instanceof Error ? error.name : typeof error;
|
|
10030
|
-
failures.push({
|
|
10031
|
-
kind: "network",
|
|
10032
|
-
errorType
|
|
10033
|
-
});
|
|
10034
|
-
request.log.warn({
|
|
10035
|
-
code: "agent_server_provider_discovery_request_failed",
|
|
10036
|
-
endpoint,
|
|
10037
|
-
errorType,
|
|
10038
|
-
providerId
|
|
10039
|
-
}, "Provider model discovery request failed");
|
|
10040
|
-
return null;
|
|
10041
|
-
}
|
|
10042
|
-
if (!response.ok) {
|
|
10043
|
-
failures.push({
|
|
10044
|
-
kind: "http",
|
|
10045
|
-
status: response.status
|
|
10046
|
-
});
|
|
10047
|
-
const context = {
|
|
10048
|
-
code: "agent_server_provider_discovery_upstream_error",
|
|
10049
|
-
endpoint,
|
|
10050
|
-
providerId,
|
|
10051
|
-
statusCode: response.status
|
|
10052
|
-
};
|
|
10053
|
-
if (response.status >= 500 || response.status === 401 || response.status === 403) request.log.warn(context, "Provider model discovery was rejected");
|
|
10054
|
-
else request.log.info(context, "Provider model discovery endpoint unavailable");
|
|
10055
|
-
return null;
|
|
10056
|
-
}
|
|
10057
|
-
try {
|
|
10058
|
-
return await response.json();
|
|
10059
|
-
} catch {
|
|
10060
|
-
failures.push({ kind: "invalid_response" });
|
|
10061
|
-
request.log.warn({
|
|
10062
|
-
code: "agent_server_provider_discovery_invalid_json",
|
|
10063
|
-
endpoint,
|
|
10064
|
-
providerId
|
|
10065
|
-
}, "Provider model discovery returned invalid JSON");
|
|
10066
|
-
return null;
|
|
10067
|
-
}
|
|
10068
|
-
};
|
|
10069
|
-
collector.addOpenAiResponse(await tryJson("openai_models", `${baseUrl}/models`));
|
|
10070
|
-
if (collector.size === 0) collector.addOllamaResponse(await tryJson("ollama_tags", `${parsed.origin}/api/tags`));
|
|
10071
|
-
const result = collector.result(providerId, failures);
|
|
10072
|
-
if (result.discoveredCount > result.models.length) request.log.warn({
|
|
10073
|
-
code: "agent_server_provider_discovery_truncated",
|
|
10074
|
-
discoveredCount: result.discoveredCount,
|
|
10075
|
-
providerId,
|
|
10076
|
-
returnedCount: 500
|
|
10077
|
-
}, "Provider model discovery result was truncated");
|
|
10078
|
-
request.log.info({
|
|
10079
|
-
code: "agent_server_provider_discovery_completed",
|
|
10080
|
-
modelCount: result.models.length,
|
|
10081
|
-
providerId
|
|
10082
|
-
}, "Provider model discovery completed");
|
|
10083
|
-
return { models: result.models };
|
|
10924
|
+
const { providerId } = request.params;
|
|
10925
|
+
return options.providers.discover(providerId, { signal: requestOperationSignal(request, options.shutdownSignal) });
|
|
10084
10926
|
});
|
|
10085
10927
|
app.put("/v1/providers/:providerId", {
|
|
10086
10928
|
schema: AgentServerRouteSchemas.putProvider,
|
|
10087
10929
|
attachValidation: true
|
|
10088
10930
|
}, async (request, reply) => {
|
|
10089
10931
|
requirePairedOrigin(request);
|
|
10090
|
-
const { providerId
|
|
10091
|
-
const providerId = assertProviderId(rawProviderId);
|
|
10932
|
+
const { providerId } = request.params;
|
|
10092
10933
|
const body = requireBody(request);
|
|
10093
|
-
const
|
|
10094
|
-
parseProviderBaseUrl(baseUrl, providerId);
|
|
10095
|
-
const entry = {
|
|
10934
|
+
const entry = await options.providers.set(providerId, {
|
|
10096
10935
|
api: requireString(body, "api"),
|
|
10097
|
-
baseUrl,
|
|
10098
|
-
envName:
|
|
10099
|
-
models: stringArray(body, "models", { allowEmpty: true })
|
|
10100
|
-
|
|
10101
|
-
const apiKey = optionalString(body, "apiKey");
|
|
10102
|
-
await serialize(async () => {
|
|
10103
|
-
const providers = store.readProviders();
|
|
10104
|
-
if (apiKey) {
|
|
10105
|
-
const key = `pi-provider/${providerId}`;
|
|
10106
|
-
await options.secrets.write(key, apiKey);
|
|
10107
|
-
entry.apiKeyRef = formatSecretReferenceString({
|
|
10108
|
-
provider: FILE_SECRET_PROVIDER,
|
|
10109
|
-
key
|
|
10110
|
-
});
|
|
10111
|
-
} else if (providers[providerId]?.apiKeyRef) entry.apiKeyRef = providers[providerId].apiKeyRef;
|
|
10112
|
-
providers[providerId] = entry;
|
|
10113
|
-
store.writeProviders(providers);
|
|
10936
|
+
baseUrl: requireString(body, "baseUrl"),
|
|
10937
|
+
envName: requireString(body, "envName"),
|
|
10938
|
+
models: stringArray(body, "models", { allowEmpty: true }),
|
|
10939
|
+
...optionalString(body, "apiKey") ? { apiKey: optionalString(body, "apiKey") } : {}
|
|
10114
10940
|
});
|
|
10115
|
-
return reply.code(200).send(
|
|
10941
|
+
return reply.code(200).send(entry);
|
|
10116
10942
|
});
|
|
10117
10943
|
app.delete("/v1/providers/:providerId", {
|
|
10118
10944
|
schema: AgentServerRouteSchemas.deleteProvider,
|
|
10119
10945
|
attachValidation: true
|
|
10120
10946
|
}, async (request, reply) => {
|
|
10121
10947
|
requirePairedOrigin(request);
|
|
10122
|
-
const { providerId
|
|
10123
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
|
|
10127
|
-
|
|
10128
|
-
|
|
10129
|
-
delete providers[providerId];
|
|
10130
|
-
store.writeProviders(providers);
|
|
10131
|
-
});
|
|
10948
|
+
const { providerId } = request.params;
|
|
10949
|
+
try {
|
|
10950
|
+
await options.providers.remove(providerId);
|
|
10951
|
+
} catch (error) {
|
|
10952
|
+
if (error instanceof ProviderConfigurationError && error.code === "provider_not_found") throw new AgentServerHttpError(404, "agent_server_provider_not_found", error.message);
|
|
10953
|
+
throw error;
|
|
10954
|
+
}
|
|
10132
10955
|
return reply.code(204).send(null);
|
|
10133
10956
|
});
|
|
10134
10957
|
}
|
|
@@ -10277,40 +11100,6 @@ function registerRunLogRoute(app, options, requirePairedOrigin) {
|
|
|
10277
11100
|
return reply;
|
|
10278
11101
|
});
|
|
10279
11102
|
}
|
|
10280
|
-
function safeErrorContext(error) {
|
|
10281
|
-
const context = { errorType: error instanceof Error ? error.name : typeof error };
|
|
10282
|
-
const applicationCode = safeErrorToken(error?.code);
|
|
10283
|
-
if (applicationCode) context["applicationCode"] = applicationCode;
|
|
10284
|
-
const cause = error instanceof Error ? error.cause : void 0;
|
|
10285
|
-
if (cause instanceof Error) {
|
|
10286
|
-
context["causeType"] = cause.name;
|
|
10287
|
-
const causeMessage = safeLogMessage(cause.message);
|
|
10288
|
-
if (causeMessage) context["causeMessage"] = causeMessage;
|
|
10289
|
-
}
|
|
10290
|
-
const fsCode = safeErrorToken(cause?.code);
|
|
10291
|
-
const syscall = safeErrorToken(cause?.syscall);
|
|
10292
|
-
if (fsCode) context["fsCode"] = fsCode;
|
|
10293
|
-
if (syscall) context["syscall"] = syscall;
|
|
10294
|
-
const causeStatus = cause?.statusCode;
|
|
10295
|
-
if (typeof causeStatus === "number") context["causeStatusCode"] = causeStatus;
|
|
10296
|
-
return context;
|
|
10297
|
-
}
|
|
10298
|
-
function safeLogMessage(value) {
|
|
10299
|
-
const normalized = value.replace(/[\r\n\t]/gu, " ").trim();
|
|
10300
|
-
return normalized ? normalized.slice(0, 500) : void 0;
|
|
10301
|
-
}
|
|
10302
|
-
function safeErrorToken(value) {
|
|
10303
|
-
return typeof value === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(value) ? value : void 0;
|
|
10304
|
-
}
|
|
10305
|
-
function providerView(provider) {
|
|
10306
|
-
return {
|
|
10307
|
-
api: provider.api,
|
|
10308
|
-
baseUrl: provider.baseUrl,
|
|
10309
|
-
envName: provider.envName,
|
|
10310
|
-
models: provider.models,
|
|
10311
|
-
hasApiKey: Boolean(provider.apiKeyRef)
|
|
10312
|
-
};
|
|
10313
|
-
}
|
|
10314
11103
|
function corsHeadersFor(request, options) {
|
|
10315
11104
|
const origin = request.headers.origin;
|
|
10316
11105
|
if (isConfiguredOrigin(origin, options)) return {
|
|
@@ -10358,6 +11147,11 @@ function normalizeAgentServerError(error) {
|
|
|
10358
11147
|
code: error.code,
|
|
10359
11148
|
message: error.message
|
|
10360
11149
|
};
|
|
11150
|
+
if (error instanceof ProviderConfigurationError) return {
|
|
11151
|
+
statusCode: error.statusCode,
|
|
11152
|
+
code: error.code,
|
|
11153
|
+
message: error.message
|
|
11154
|
+
};
|
|
10361
11155
|
if (error instanceof AgentServerIdentityError) return {
|
|
10362
11156
|
statusCode: error.code === "agent_exists" ? 409 : 400,
|
|
10363
11157
|
code: error.code,
|
|
@@ -10585,6 +11379,12 @@ async function runAgentServer(argv) {
|
|
|
10585
11379
|
authPath: store.piAuthJsonPath,
|
|
10586
11380
|
logger
|
|
10587
11381
|
});
|
|
11382
|
+
const providers = new ProviderConfigurationService({
|
|
11383
|
+
store,
|
|
11384
|
+
secrets,
|
|
11385
|
+
secretProviders,
|
|
11386
|
+
logger
|
|
11387
|
+
});
|
|
10588
11388
|
const runs = new RunManager({
|
|
10589
11389
|
store,
|
|
10590
11390
|
secretProviders,
|
|
@@ -10602,6 +11402,7 @@ async function runAgentServer(argv) {
|
|
|
10602
11402
|
pairing,
|
|
10603
11403
|
runs,
|
|
10604
11404
|
subscriptions,
|
|
11405
|
+
providers,
|
|
10605
11406
|
allowedOrigins,
|
|
10606
11407
|
selfOrigin: `${tls ? "https" : "http"}://127.0.0.1:${port}`,
|
|
10607
11408
|
...tls ? { tls: {
|
|
@@ -10609,6 +11410,7 @@ async function runAgentServer(argv) {
|
|
|
10609
11410
|
cert: tls.cert
|
|
10610
11411
|
} } : {},
|
|
10611
11412
|
defaultApiUrl,
|
|
11413
|
+
...envConfig.activeIdentity ? { activeIdentity: envConfig.activeIdentity } : {},
|
|
10612
11414
|
version: "dev",
|
|
10613
11415
|
logger,
|
|
10614
11416
|
shutdownSignal: shutdownController.signal
|
|
@@ -10680,7 +11482,7 @@ function waitForAgentServerShutdown(runs, app, shutdownController) {
|
|
|
10680
11482
|
const shutdown = () => {
|
|
10681
11483
|
if (shuttingDown) return;
|
|
10682
11484
|
shuttingDown = true;
|
|
10683
|
-
shutdownController.abort();
|
|
11485
|
+
shutdownController.abort({ source: "shutdown" });
|
|
10684
11486
|
(async () => {
|
|
10685
11487
|
app.server.closeAllConnections();
|
|
10686
11488
|
const cleanupPromise = Promise.allSettled([runs.stopAll(), app.close()]);
|
|
@@ -11052,7 +11854,7 @@ async function writeCache(cache) {
|
|
|
11052
11854
|
}
|
|
11053
11855
|
//#endregion
|
|
11054
11856
|
//#region src/version.ts
|
|
11055
|
-
var DAEMON_VERSION = "0.
|
|
11857
|
+
var DAEMON_VERSION = "0.56.1";
|
|
11056
11858
|
//#endregion
|
|
11057
11859
|
//#region src/cli.ts
|
|
11058
11860
|
async function runAgentDaemonCli(options) {
|
|
@@ -11072,6 +11874,7 @@ async function runAgentDaemonCli(options) {
|
|
|
11072
11874
|
case "once": return runOnce(rest, options.runtime);
|
|
11073
11875
|
case "drain": return runDrain(rest, options.runtime);
|
|
11074
11876
|
case "server": return runAgentServer(rest);
|
|
11877
|
+
case "providers": return runProviders(rest);
|
|
11075
11878
|
case "sync-sessions": return runSyncSessions(rest);
|
|
11076
11879
|
case "update": return runUpdate(rest);
|
|
11077
11880
|
case "runtime": return runRuntime(rest);
|