@themoltnet/agent-daemon 0.55.1 → 0.56.0

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