@themoltnet/agent-daemon 0.47.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2268 -20
- package/dist/main.js +13 -0
- package/dist/pi.js +2 -2
- package/package.json +16 -9
package/dist/cli.js
CHANGED
|
@@ -6,15 +6,15 @@ import "multiformats/cid";
|
|
|
6
6
|
import "multiformats/codecs/json";
|
|
7
7
|
import "multiformats/hashes/sha2";
|
|
8
8
|
import "typebox/value";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
10
10
|
import { parseArgs, promisify } from "node:util";
|
|
11
11
|
import { AgentRuntime, ApiTaskReporter, ApiTaskSource, PollingApiTaskSource, createLocalSeedSigner, resolveAgentIdentity, resolveProfileWarmSessionTtlSec, resolveRuntimeProfile, resolveRuntimeProfiles, validateRuntimeProfilePrerequisites } from "@themoltnet/agent-runtime";
|
|
12
|
-
import { GuestEnvironmentBoundaryError, assertGuestEnvironmentBoundary, createPiRetryTriage, findMainWorktree, isResolvedPathInsideRoot, normalizeRetryTriageResult, redactRetryTriageSecrets } from "@themoltnet/pi-runtime";
|
|
13
|
-
import { connect, createNodeSecretProviderRegistry } from "@themoltnet/sdk/node";
|
|
14
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
15
|
-
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
|
16
|
-
import { AuthenticationError, MoltNetError, createExecutorAttestor, readConfig, resolveEnvSecretReference, resolveIdentitySeed } from "@themoltnet/sdk";
|
|
17
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import { GuestEnvironmentBoundaryError, assertGuestEnvironmentBoundary, createPiRetryTriage, findMainWorktree, isResolvedPathInsideRoot, normalizeRetryTriageResult, redactRetryTriageSecrets, resolveRuntimeProfileModel } from "@themoltnet/pi-runtime";
|
|
13
|
+
import { FILE_SECRET_PROVIDER, FileSecretProvider, connect, createNodeSecretProviderRegistry } from "@themoltnet/sdk/node";
|
|
14
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import { constants, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { AuthenticationError, MoltNetError, agentKeyKey, assertTrustedConfigApiUrl, createExecutorAttestor, deriveMcpUrl, formatSecretReferenceString, identitySeedKey, parseSecretReferenceString, readConfig, register, requireSecureCredentialApiUrl, resolveAgentKey, resolveEnvSecretReference, resolveIdentitySeed, resolveOAuth2ClientSecret } from "@themoltnet/sdk";
|
|
17
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
18
18
|
import { once } from "node:events";
|
|
19
19
|
import { pino, transport } from "pino";
|
|
20
20
|
import { metrics } from "@opentelemetry/api";
|
|
@@ -26,10 +26,17 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
|
26
26
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
27
27
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
28
28
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
-
import {
|
|
30
|
-
import { mkdir, realpath, stat } from "node:fs/promises";
|
|
29
|
+
import { mkdir, open, realpath, stat } from "node:fs/promises";
|
|
31
30
|
import { pipeline } from "node:stream/promises";
|
|
32
|
-
import
|
|
31
|
+
import cors from "@fastify/cors";
|
|
32
|
+
import helmet from "@fastify/helmet";
|
|
33
|
+
import { Transform, Writable } from "node:stream";
|
|
34
|
+
import { writePiConfig } from "@themoltnet/pi-runtime/pi-config";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { lock } from "proper-lockfile";
|
|
37
|
+
import { StringDecoder } from "node:string_decoder";
|
|
38
|
+
import rateLimit from "@fastify/rate-limit";
|
|
39
|
+
import Fastify from "fastify";
|
|
33
40
|
import { createGzip } from "node:zlib";
|
|
34
41
|
//#region ../../libs/tasks/src/rubric.ts
|
|
35
42
|
/**
|
|
@@ -3417,6 +3424,9 @@ Commands:
|
|
|
3417
3424
|
once Claim and execute one specific queued task by id, then exit.
|
|
3418
3425
|
drain Poll until the queue has nothing claimable, then exit.
|
|
3419
3426
|
Useful for batch eval runs and demos.
|
|
3427
|
+
serve Loopback supervisor for console-managed runs: pairing,
|
|
3428
|
+
agent/provider config store, and start/stop of poll/drain
|
|
3429
|
+
child processes. Binds 127.0.0.1 only.
|
|
3420
3430
|
sync-sessions
|
|
3421
3431
|
Repair durable runtime-session checkpoints from local slot files.
|
|
3422
3432
|
|
|
@@ -3549,6 +3559,39 @@ Example:
|
|
|
3549
3559
|
function isHelpFlag(args) {
|
|
3550
3560
|
return args.includes("--help") || args.includes("-h");
|
|
3551
3561
|
}
|
|
3562
|
+
var SERVE_HELP = `\
|
|
3563
|
+
agent-daemon serve — loopback supervisor for console-managed runs.
|
|
3564
|
+
|
|
3565
|
+
Binds 127.0.0.1 only. A paired Console origin configures agents and
|
|
3566
|
+
providers (secret references only) and starts/stops poll/drain runs as
|
|
3567
|
+
child processes of this supervisor.
|
|
3568
|
+
|
|
3569
|
+
Options:
|
|
3570
|
+
--port <n> Loopback port. Default: 17374.
|
|
3571
|
+
Env: MOLTNET_SERVE_PORT.
|
|
3572
|
+
--allowed-origins <csv> Exact Console origins allowed to pair.
|
|
3573
|
+
Default: https://console.themolt.net.
|
|
3574
|
+
Env: MOLTNET_SERVE_ALLOWED_ORIGINS.
|
|
3575
|
+
--root <path> Config root. Default: ~/.config/moltnet
|
|
3576
|
+
(or MOLTNET_SERVE_ROOT).
|
|
3577
|
+
--api-url <url> Default MoltNet API for new managed agents.
|
|
3578
|
+
Default: https://api.themolt.net.
|
|
3579
|
+
`;
|
|
3580
|
+
//#endregion
|
|
3581
|
+
//#region src/lib/identity-pin.ts
|
|
3582
|
+
/** Compare every pinned field without choosing a caller-specific error type. */
|
|
3583
|
+
function assessIdentityPin(current, expected) {
|
|
3584
|
+
for (const [field, label] of [
|
|
3585
|
+
["identityId", "identity id"],
|
|
3586
|
+
["publicKey", "public key"],
|
|
3587
|
+
["fingerprint", "fingerprint"]
|
|
3588
|
+
]) if (!current[field] || current[field] !== expected[field]) return {
|
|
3589
|
+
ok: false,
|
|
3590
|
+
field,
|
|
3591
|
+
label
|
|
3592
|
+
};
|
|
3593
|
+
return { ok: true };
|
|
3594
|
+
}
|
|
3552
3595
|
//#endregion
|
|
3553
3596
|
//#region src/lib/agent-context.ts
|
|
3554
3597
|
/**
|
|
@@ -3612,6 +3655,8 @@ async function validateStartupBinding(options) {
|
|
|
3612
3655
|
}
|
|
3613
3656
|
const assessment = assessStartupBinding(whoami, options.teamId);
|
|
3614
3657
|
if (!assessment.ok) throw new Error(`Daemon startup validation failed: ${assessment.reason}`);
|
|
3658
|
+
const expected = options.expectedIdentity;
|
|
3659
|
+
if (expected && !assessIdentityPin(whoami, expected).ok) throw new Error("Daemon startup validation failed: authenticated identity does not match the serve activation.");
|
|
3615
3660
|
return whoami;
|
|
3616
3661
|
}
|
|
3617
3662
|
/**
|
|
@@ -3690,6 +3735,7 @@ function resolveCredentialRoots(agentRootDir) {
|
|
|
3690
3735
|
function loadConfig() {
|
|
3691
3736
|
assertSingleCredentialForm("MOLTNET_AGENT_KEY", "MOLTNET_AGENT_KEY_REF");
|
|
3692
3737
|
assertSingleCredentialForm("MOLTNET_PRIVATE_KEY", "MOLTNET_PRIVATE_KEY_REF");
|
|
3738
|
+
const expectedIdentity = readExpectedIdentity();
|
|
3693
3739
|
return {
|
|
3694
3740
|
otelEndpoint: process.env["MOLTNET_OTEL_ENDPOINT"] ?? "",
|
|
3695
3741
|
logLevel: process.env["LOG_LEVEL"] ?? "",
|
|
@@ -3703,7 +3749,25 @@ function loadConfig() {
|
|
|
3703
3749
|
profileCredentialRequirements: process.env["MOLTNET_PROFILE_CREDENTIAL_REQUIREMENTS"] ?? "",
|
|
3704
3750
|
credentialBindings: process.env["MOLTNET_CREDENTIAL_BINDINGS"] ?? "",
|
|
3705
3751
|
credentialEnforcement: process.env["MOLTNET_CREDENTIAL_ENFORCEMENT"] ?? "",
|
|
3706
|
-
traceIdlePolling: readBoolean("MOLTNET_TRACE_IDLE_POLLING", process.env["MOLTNET_TRACE_IDLE_POLLING"])
|
|
3752
|
+
traceIdlePolling: readBoolean("MOLTNET_TRACE_IDLE_POLLING", process.env["MOLTNET_TRACE_IDLE_POLLING"]),
|
|
3753
|
+
...expectedIdentity ? { expectedIdentity } : {}
|
|
3754
|
+
};
|
|
3755
|
+
}
|
|
3756
|
+
function readExpectedIdentity() {
|
|
3757
|
+
const identityId = process.env["MOLTNET_EXPECTED_IDENTITY_ID"]?.trim() ?? "";
|
|
3758
|
+
const publicKey = process.env["MOLTNET_EXPECTED_PUBLIC_KEY"]?.trim() ?? "";
|
|
3759
|
+
const fingerprint = process.env["MOLTNET_EXPECTED_FINGERPRINT"]?.trim() ?? "";
|
|
3760
|
+
const present = [
|
|
3761
|
+
identityId,
|
|
3762
|
+
publicKey,
|
|
3763
|
+
fingerprint
|
|
3764
|
+
].filter(Boolean).length;
|
|
3765
|
+
if (present === 0) return void 0;
|
|
3766
|
+
if (present !== 3) throw new Error("MOLTNET_EXPECTED_IDENTITY_ID, MOLTNET_EXPECTED_PUBLIC_KEY, and MOLTNET_EXPECTED_FINGERPRINT must be set together");
|
|
3767
|
+
return {
|
|
3768
|
+
identityId,
|
|
3769
|
+
publicKey,
|
|
3770
|
+
fingerprint
|
|
3707
3771
|
};
|
|
3708
3772
|
}
|
|
3709
3773
|
function assertSingleCredentialForm(valueName, refName) {
|
|
@@ -3718,6 +3782,20 @@ function readBoolean(name, value) {
|
|
|
3718
3782
|
function activatePiCodingAgentDir(path) {
|
|
3719
3783
|
process.env["PI_CODING_AGENT_DIR"] = path;
|
|
3720
3784
|
}
|
|
3785
|
+
function loadServeEnvConfig() {
|
|
3786
|
+
return {
|
|
3787
|
+
port: process.env["MOLTNET_SERVE_PORT"] ?? "",
|
|
3788
|
+
allowedOrigins: process.env["MOLTNET_SERVE_ALLOWED_ORIGINS"] ?? "",
|
|
3789
|
+
root: process.env["MOLTNET_SERVE_ROOT"] ?? "",
|
|
3790
|
+
xdgConfigHome: process.env["XDG_CONFIG_HOME"] ?? "",
|
|
3791
|
+
apiUrl: process.env["MOLTNET_API_URL"] ?? "",
|
|
3792
|
+
logLevel: process.env["LOG_LEVEL"] ?? ""
|
|
3793
|
+
};
|
|
3794
|
+
}
|
|
3795
|
+
/** Full process environment for spawned serve run children. */
|
|
3796
|
+
function processEnvSnapshot() {
|
|
3797
|
+
return process.env;
|
|
3798
|
+
}
|
|
3721
3799
|
//#endregion
|
|
3722
3800
|
//#region src/lib/abort-active-attempt.ts
|
|
3723
3801
|
/** Best-effort signal cleanup; lease expiry remains the final backstop. */
|
|
@@ -5170,13 +5248,17 @@ function runWithDaemonRuntimeContext(context, callback) {
|
|
|
5170
5248
|
//#endregion
|
|
5171
5249
|
//#region src/lib/runtime-profile-retry-triage.ts
|
|
5172
5250
|
function createRuntimeProfileRetryTriage(options) {
|
|
5173
|
-
return
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5251
|
+
return async (input) => {
|
|
5252
|
+
const { modelHandle, modelRuntime } = await resolveRuntimeProfileModel(options.piAgentDir, options.runtimeProfile.provider, options.runtimeProfile.model);
|
|
5253
|
+
return createPiRetryTriage({
|
|
5254
|
+
model: modelHandle,
|
|
5255
|
+
modelRuntime,
|
|
5256
|
+
thinkingLevel: options.runtimeProfile.thinkingLevel,
|
|
5257
|
+
piAgentDir: options.piAgentDir,
|
|
5258
|
+
timeoutMs: options.timeoutMs,
|
|
5259
|
+
cwd: options.cwd
|
|
5260
|
+
})(input);
|
|
5261
|
+
};
|
|
5180
5262
|
}
|
|
5181
5263
|
//#endregion
|
|
5182
5264
|
//#region src/lib/runtime-resource-reaper.ts
|
|
@@ -5790,7 +5872,8 @@ async function runPolling(opts) {
|
|
|
5790
5872
|
gate = "authenticate_and_bind";
|
|
5791
5873
|
const whoami = await validateStartupBinding({
|
|
5792
5874
|
agent: resolvedContext.agent,
|
|
5793
|
-
teamId
|
|
5875
|
+
teamId,
|
|
5876
|
+
expectedIdentity: cfg.expectedIdentity
|
|
5794
5877
|
});
|
|
5795
5878
|
gate = "resolve_signing_material";
|
|
5796
5879
|
const privateKey = await resolveExecutorSigningPrivateKey({
|
|
@@ -6443,7 +6526,8 @@ async function runOnce(argv, runtimeAdapter = defaultPiDaemonAdapter) {
|
|
|
6443
6526
|
gate = "authenticate_and_bind";
|
|
6444
6527
|
const whoami = await validateStartupBinding({
|
|
6445
6528
|
agent: resolvedContext.agent,
|
|
6446
|
-
teamId: values.team
|
|
6529
|
+
teamId: values.team,
|
|
6530
|
+
expectedIdentity: cfg.expectedIdentity
|
|
6447
6531
|
});
|
|
6448
6532
|
gate = "resolve_signing_material";
|
|
6449
6533
|
const privateKey = await resolveExecutorSigningPrivateKey({
|
|
@@ -6854,6 +6938,2169 @@ function runPoll(argv, runtimeAdapter) {
|
|
|
6854
6938
|
});
|
|
6855
6939
|
}
|
|
6856
6940
|
//#endregion
|
|
6941
|
+
//#region ../../libs/loopback-companion/src/errors.ts
|
|
6942
|
+
var LoopbackViolationError = class extends Error {
|
|
6943
|
+
name = "LoopbackViolationError";
|
|
6944
|
+
constructor(kind, message, options) {
|
|
6945
|
+
super(message, options);
|
|
6946
|
+
this.kind = kind;
|
|
6947
|
+
}
|
|
6948
|
+
};
|
|
6949
|
+
function isLoopbackViolation(error) {
|
|
6950
|
+
return error instanceof LoopbackViolationError;
|
|
6951
|
+
}
|
|
6952
|
+
//#endregion
|
|
6953
|
+
//#region ../../libs/loopback-companion/src/origin.ts
|
|
6954
|
+
/** Hostnames accepted as loopback for companion servers and origins. */
|
|
6955
|
+
function isLoopbackHostname(hostname) {
|
|
6956
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
6957
|
+
}
|
|
6958
|
+
/**
|
|
6959
|
+
* Normalize and validate a browser origin. Accepts exact `https:` origins,
|
|
6960
|
+
* or `http:` origins whose host is loopback. Rejects values that carry a
|
|
6961
|
+
* path, trailing slash, credentials, or any other non-origin decoration
|
|
6962
|
+
* (`url.origin !== value` catches all of those).
|
|
6963
|
+
*/
|
|
6964
|
+
function normalizeOrigin(value) {
|
|
6965
|
+
let url;
|
|
6966
|
+
try {
|
|
6967
|
+
url = new URL(value);
|
|
6968
|
+
} catch (cause) {
|
|
6969
|
+
throw new LoopbackViolationError("origin_invalid", "Origin is not valid", { cause });
|
|
6970
|
+
}
|
|
6971
|
+
if (url.origin !== value || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new LoopbackViolationError("origin_invalid", "Origin is not valid");
|
|
6972
|
+
return url.origin;
|
|
6973
|
+
}
|
|
6974
|
+
/** Parse a comma-separated origin list (config format shared by companions). */
|
|
6975
|
+
function parseAllowedOrigins(csv) {
|
|
6976
|
+
return csv.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
6977
|
+
}
|
|
6978
|
+
/**
|
|
6979
|
+
* Exact-origin allowlist. Every configured origin is normalized eagerly so a
|
|
6980
|
+
* misconfigured allowlist fails at startup, not at request time.
|
|
6981
|
+
*/
|
|
6982
|
+
var OriginAllowlist = class {
|
|
6983
|
+
origins;
|
|
6984
|
+
constructor(allowedOrigins) {
|
|
6985
|
+
if (allowedOrigins.length === 0) throw new Error("OriginAllowlist requires at least one origin");
|
|
6986
|
+
this.origins = new Set(allowedOrigins.map((origin) => normalizeOrigin(origin)));
|
|
6987
|
+
}
|
|
6988
|
+
has(origin) {
|
|
6989
|
+
try {
|
|
6990
|
+
return this.origins.has(normalizeOrigin(origin));
|
|
6991
|
+
} catch {
|
|
6992
|
+
return false;
|
|
6993
|
+
}
|
|
6994
|
+
}
|
|
6995
|
+
/** Return the normalized origin or throw `origin_not_allowed`. */
|
|
6996
|
+
assert(value) {
|
|
6997
|
+
let origin;
|
|
6998
|
+
try {
|
|
6999
|
+
origin = normalizeOrigin(value);
|
|
7000
|
+
} catch {
|
|
7001
|
+
throw new LoopbackViolationError("origin_not_allowed", "Origin is not allowed");
|
|
7002
|
+
}
|
|
7003
|
+
if (!this.origins.has(origin)) throw new LoopbackViolationError("origin_not_allowed", "Origin is not allowed");
|
|
7004
|
+
return origin;
|
|
7005
|
+
}
|
|
7006
|
+
};
|
|
7007
|
+
/** Extract and require the `Origin` header value. */
|
|
7008
|
+
function requireOriginHeader(headers) {
|
|
7009
|
+
const origin = headers.origin;
|
|
7010
|
+
if (typeof origin !== "string" || origin.length === 0) throw new LoopbackViolationError("origin_required", "Origin is required");
|
|
7011
|
+
return origin;
|
|
7012
|
+
}
|
|
7013
|
+
//#endregion
|
|
7014
|
+
//#region ../../libs/loopback-companion/src/fastify.ts
|
|
7015
|
+
var CORS_PREFLIGHT_MAX_AGE_SECONDS = 600;
|
|
7016
|
+
/**
|
|
7017
|
+
* Enforce that the `Host` header identifies loopback. Blocks DNS-rebinding
|
|
7018
|
+
* setups where a public hostname resolves to 127.0.0.1: the browser then
|
|
7019
|
+
* sends that hostname as `Host`, and the request is refused here even
|
|
7020
|
+
* though the socket is loopback.
|
|
7021
|
+
*/
|
|
7022
|
+
function requireLoopbackHost(request) {
|
|
7023
|
+
const host = request.headers.host;
|
|
7024
|
+
if (!host) throw new LoopbackViolationError("host_required", "Host header is required");
|
|
7025
|
+
let hostname;
|
|
7026
|
+
try {
|
|
7027
|
+
hostname = new URL(`http://${host}`).hostname;
|
|
7028
|
+
} catch {
|
|
7029
|
+
throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
7030
|
+
}
|
|
7031
|
+
if (!isLoopbackHostname(hostname === "::1" ? "[::1]" : hostname)) throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
7032
|
+
}
|
|
7033
|
+
/**
|
|
7034
|
+
* Register the loopback-companion security profile on a Fastify app:
|
|
7035
|
+
*
|
|
7036
|
+
* - loopback `Host` enforcement on every request;
|
|
7037
|
+
* - `cache-control: no-store` on every response;
|
|
7038
|
+
* - strict UTF-8 JSON body parsing (invalid bodies raise a typed violation);
|
|
7039
|
+
* - exact-origin CORS (opaque/`null` origins get no CORS response but are
|
|
7040
|
+
* not rejected here — route-level controls stay mandatory);
|
|
7041
|
+
* - hardened helmet defaults.
|
|
7042
|
+
*
|
|
7043
|
+
*/
|
|
7044
|
+
function registerLoopbackSecurity(app, options) {
|
|
7045
|
+
if (!options.allowedOrigins && !options.isOriginAllowed) throw new Error("registerLoopbackSecurity requires allowedOrigins or isOriginAllowed");
|
|
7046
|
+
const primaryAllowlist = options.allowedOrigins ? new OriginAllowlist(options.allowedOrigins) : null;
|
|
7047
|
+
const selfAllowlist = options.selfOrigins && options.selfOrigins.length > 0 ? new OriginAllowlist(options.selfOrigins) : null;
|
|
7048
|
+
const isOriginAllowed = (origin) => selfAllowlist?.has(origin) === true || primaryAllowlist?.has(origin) === true || options.isOriginAllowed?.(origin) === true;
|
|
7049
|
+
app.addHook("onRequest", (request, _reply, done) => {
|
|
7050
|
+
requireLoopbackHost(request);
|
|
7051
|
+
done();
|
|
7052
|
+
});
|
|
7053
|
+
app.addHook("onSend", async (_request, reply, payload) => {
|
|
7054
|
+
reply.header("cache-control", "no-store");
|
|
7055
|
+
return payload;
|
|
7056
|
+
});
|
|
7057
|
+
app.removeContentTypeParser("application/json");
|
|
7058
|
+
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
7059
|
+
try {
|
|
7060
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(typeof body === "string" ? Buffer.from(body) : body);
|
|
7061
|
+
done(null, JSON.parse(json));
|
|
7062
|
+
} catch (cause) {
|
|
7063
|
+
done(new LoopbackViolationError("body_not_utf8_json", "Request body must be valid UTF-8 JSON", { cause }), void 0);
|
|
7064
|
+
}
|
|
7065
|
+
});
|
|
7066
|
+
app.register(cors, {
|
|
7067
|
+
allowedHeaders: ["content-type", ...options.allowedHeaders ?? []],
|
|
7068
|
+
maxAge: CORS_PREFLIGHT_MAX_AGE_SECONDS,
|
|
7069
|
+
methods: [...options.methods ?? [
|
|
7070
|
+
"GET",
|
|
7071
|
+
"POST",
|
|
7072
|
+
"OPTIONS"
|
|
7073
|
+
]],
|
|
7074
|
+
origin: (origin, callback) => {
|
|
7075
|
+
if (!origin || origin === "null") {
|
|
7076
|
+
callback(null, false);
|
|
7077
|
+
return;
|
|
7078
|
+
}
|
|
7079
|
+
if (isOriginAllowed(origin)) {
|
|
7080
|
+
callback(null, true);
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
7083
|
+
callback(new LoopbackViolationError("origin_not_allowed", "Origin is not allowed"), false);
|
|
7084
|
+
}
|
|
7085
|
+
});
|
|
7086
|
+
app.register(helmet, {
|
|
7087
|
+
contentSecurityPolicy: {
|
|
7088
|
+
useDefaults: false,
|
|
7089
|
+
directives: options.contentSecurityPolicyDirectives ?? {
|
|
7090
|
+
baseUri: ["'none'"],
|
|
7091
|
+
defaultSrc: ["'none'"],
|
|
7092
|
+
formAction: ["'self'"],
|
|
7093
|
+
frameAncestors: ["'none'"],
|
|
7094
|
+
styleSrc: ["'unsafe-inline'"]
|
|
7095
|
+
}
|
|
7096
|
+
},
|
|
7097
|
+
crossOriginEmbedderPolicy: false,
|
|
7098
|
+
crossOriginOpenerPolicy: false,
|
|
7099
|
+
crossOriginResourcePolicy: { policy: "same-origin" },
|
|
7100
|
+
hsts: false,
|
|
7101
|
+
referrerPolicy: { policy: "no-referrer" }
|
|
7102
|
+
});
|
|
7103
|
+
}
|
|
7104
|
+
//#endregion
|
|
7105
|
+
//#region ../../libs/loopback-companion/src/fetch-metadata.ts
|
|
7106
|
+
function headerValue(headers, name) {
|
|
7107
|
+
const value = headers[name];
|
|
7108
|
+
return typeof value === "string" ? value : void 0;
|
|
7109
|
+
}
|
|
7110
|
+
/**
|
|
7111
|
+
* Require that a request is a top-level browser navigation (used by local
|
|
7112
|
+
* approval pages that must be opened as a document, never fetched).
|
|
7113
|
+
*/
|
|
7114
|
+
function assertNavigationRequest(headers) {
|
|
7115
|
+
const site = headerValue(headers, "sec-fetch-site");
|
|
7116
|
+
const mode = headerValue(headers, "sec-fetch-mode");
|
|
7117
|
+
const destination = headerValue(headers, "sec-fetch-dest");
|
|
7118
|
+
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");
|
|
7119
|
+
}
|
|
7120
|
+
/**
|
|
7121
|
+
* Reject an explicit cross-site Fetch-Metadata signal. Only the explicit
|
|
7122
|
+
* `cross-site` value is rejected: Safari may omit Fetch Metadata on
|
|
7123
|
+
* same-origin form submissions, so the absence of the header is not treated
|
|
7124
|
+
* as a violation — callers keep their one-time token as the primary control.
|
|
7125
|
+
*/
|
|
7126
|
+
function rejectExplicitCrossSite(headers) {
|
|
7127
|
+
if (headerValue(headers, "sec-fetch-site") === "cross-site") throw new LoopbackViolationError("cross_site_rejected", "Request must not originate cross-site");
|
|
7128
|
+
}
|
|
7129
|
+
//#endregion
|
|
7130
|
+
//#region src/lib/serve/pairing.ts
|
|
7131
|
+
/**
|
|
7132
|
+
* One-click pairing ceremony for the serve supervisor, mirroring the signer's
|
|
7133
|
+
* session/ceremony pattern (#2062 design):
|
|
7134
|
+
*
|
|
7135
|
+
* 1. Console (allowed origin) POSTs `/v1/pairings` → pending pairing bound
|
|
7136
|
+
* to that origin, with a one-time confirmation token.
|
|
7137
|
+
* 2. Console opens `http://127.0.0.1:<port>/pairings/<id>` in a new tab —
|
|
7138
|
+
* a navigation-gated local approval page naming the origin.
|
|
7139
|
+
* 3. One click POSTs the confirmation form (explicit cross-site rejected;
|
|
7140
|
+
* the one-time token is the primary CSRF control).
|
|
7141
|
+
* 4. Console claims `/v1/pairings/<id>/claim` from the same origin and
|
|
7142
|
+
* receives the bearer token exactly once; only its SHA-256 remains in
|
|
7143
|
+
* this supervisor process.
|
|
7144
|
+
*
|
|
7145
|
+
* The token exists for shared-machine cross-user protection and to bind
|
|
7146
|
+
* "this console session is the operator" — browser-vs-browser isolation is
|
|
7147
|
+
* already covered by the loopback-companion origin checks. Grants are
|
|
7148
|
+
* deliberately process-scoped: after the listening socket changes owners, a
|
|
7149
|
+
* token disclosed to an impostor on that port cannot authenticate to a later
|
|
7150
|
+
* supervisor process.
|
|
7151
|
+
*/
|
|
7152
|
+
var PENDING_TTL_MS = 600 * 1e3;
|
|
7153
|
+
var ServePairingError = class extends Error {
|
|
7154
|
+
name = "ServePairingError";
|
|
7155
|
+
constructor(code, message) {
|
|
7156
|
+
super(message);
|
|
7157
|
+
this.code = code;
|
|
7158
|
+
}
|
|
7159
|
+
};
|
|
7160
|
+
function sha256Hex(value) {
|
|
7161
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
7162
|
+
}
|
|
7163
|
+
function safeEqual(a, b) {
|
|
7164
|
+
const left = Buffer.from(a, "utf8");
|
|
7165
|
+
const right = Buffer.from(b, "utf8");
|
|
7166
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
7167
|
+
}
|
|
7168
|
+
var PairingService = class {
|
|
7169
|
+
pending = /* @__PURE__ */ new Map();
|
|
7170
|
+
paired = /* @__PURE__ */ new Map();
|
|
7171
|
+
constructor(options = {}) {
|
|
7172
|
+
this.options = options;
|
|
7173
|
+
}
|
|
7174
|
+
now() {
|
|
7175
|
+
return this.options.now?.() ?? Date.now();
|
|
7176
|
+
}
|
|
7177
|
+
token() {
|
|
7178
|
+
return this.options.randomToken?.() ?? randomBytes(32).toString("base64url");
|
|
7179
|
+
}
|
|
7180
|
+
sweep() {
|
|
7181
|
+
const now = this.now();
|
|
7182
|
+
for (const [id, pairing] of this.pending) if (pairing.expiresAt <= now) this.pending.delete(id);
|
|
7183
|
+
}
|
|
7184
|
+
start(origin) {
|
|
7185
|
+
this.sweep();
|
|
7186
|
+
const pairingId = randomBytes(12).toString("hex");
|
|
7187
|
+
this.pending.set(pairingId, {
|
|
7188
|
+
origin,
|
|
7189
|
+
confirmToken: this.token(),
|
|
7190
|
+
expiresAt: this.now() + PENDING_TTL_MS,
|
|
7191
|
+
approved: false,
|
|
7192
|
+
bearerToken: null
|
|
7193
|
+
});
|
|
7194
|
+
return {
|
|
7195
|
+
pairingId,
|
|
7196
|
+
approvalPath: `/pairings/${pairingId}`
|
|
7197
|
+
};
|
|
7198
|
+
}
|
|
7199
|
+
/** Data for the local approval page. */
|
|
7200
|
+
approval(pairingId) {
|
|
7201
|
+
const pairing = this.require(pairingId);
|
|
7202
|
+
if (pairing.approved) throw new ServePairingError("pairing_invalid", "Pairing is already approved");
|
|
7203
|
+
return {
|
|
7204
|
+
origin: pairing.origin,
|
|
7205
|
+
confirmToken: pairing.confirmToken
|
|
7206
|
+
};
|
|
7207
|
+
}
|
|
7208
|
+
confirm(pairingId, confirmToken) {
|
|
7209
|
+
const pairing = this.require(pairingId);
|
|
7210
|
+
if (pairing.approved || !safeEqual(pairing.confirmToken, confirmToken)) throw new ServePairingError("pairing_invalid", "Confirmation token is not valid");
|
|
7211
|
+
pairing.approved = true;
|
|
7212
|
+
pairing.bearerToken = this.token();
|
|
7213
|
+
return { origin: pairing.origin };
|
|
7214
|
+
}
|
|
7215
|
+
claim(pairingId, origin) {
|
|
7216
|
+
const pairing = this.require(pairingId);
|
|
7217
|
+
if (pairing.origin !== origin) throw new ServePairingError("pairing_origin_mismatch", "Pairing belongs to a different origin");
|
|
7218
|
+
if (!pairing.approved || !pairing.bearerToken) throw new ServePairingError("pairing_not_approved", "Pairing has not been approved yet");
|
|
7219
|
+
const token = pairing.bearerToken;
|
|
7220
|
+
this.pending.delete(pairingId);
|
|
7221
|
+
this.paired.set(origin, sha256Hex(token));
|
|
7222
|
+
return { token };
|
|
7223
|
+
}
|
|
7224
|
+
verify(origin, token) {
|
|
7225
|
+
const tokenHash = this.paired.get(origin);
|
|
7226
|
+
if (!tokenHash || !safeEqual(tokenHash, sha256Hex(token))) throw new ServePairingError("pairing_token_invalid", "Pairing token is not valid for this origin");
|
|
7227
|
+
}
|
|
7228
|
+
require(pairingId) {
|
|
7229
|
+
this.sweep();
|
|
7230
|
+
const pairing = this.pending.get(pairingId);
|
|
7231
|
+
if (!pairing) throw new ServePairingError("pairing_not_found", "Pairing was not found or has expired");
|
|
7232
|
+
return pairing;
|
|
7233
|
+
}
|
|
7234
|
+
};
|
|
7235
|
+
function escapeHtml(value) {
|
|
7236
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
7237
|
+
}
|
|
7238
|
+
/** Minimal, dependency-free local approval page. */
|
|
7239
|
+
function renderPairingApprovalPage(input) {
|
|
7240
|
+
return `<!doctype html>
|
|
7241
|
+
<html lang="en">
|
|
7242
|
+
<head>
|
|
7243
|
+
<meta charset="utf-8" />
|
|
7244
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7245
|
+
<title>MoltNet Agent — approve connection</title>
|
|
7246
|
+
<style>
|
|
7247
|
+
:root { color-scheme: light dark; }
|
|
7248
|
+
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; background: Canvas; color: CanvasText; }
|
|
7249
|
+
main { max-width: 26rem; padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 12px; }
|
|
7250
|
+
h1 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
|
7251
|
+
code { font-size: 0.95em; word-break: break-all; }
|
|
7252
|
+
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; }
|
|
7253
|
+
p.small { font-size: 0.85rem; opacity: 0.75; }
|
|
7254
|
+
</style>
|
|
7255
|
+
</head>
|
|
7256
|
+
<body>
|
|
7257
|
+
<main>
|
|
7258
|
+
<h1>Allow this site to manage local MoltNet agents?</h1>
|
|
7259
|
+
<p><code>${escapeHtml(input.origin)}</code> asks to configure agents and start or stop local daemon runs on this machine.</p>
|
|
7260
|
+
<p class="small">Approve only if you opened that page yourself. This grant lasts until the local supervisor stops.</p>
|
|
7261
|
+
<form method="post" action="/pairings/${escapeHtml(input.pairingId)}/confirm">
|
|
7262
|
+
<input type="hidden" name="confirmToken" value="${escapeHtml(input.confirmToken)}" />
|
|
7263
|
+
<button type="submit">Approve</button>
|
|
7264
|
+
</form>
|
|
7265
|
+
</main>
|
|
7266
|
+
</body>
|
|
7267
|
+
</html>
|
|
7268
|
+
`;
|
|
7269
|
+
}
|
|
7270
|
+
function renderPairingResultPage(input) {
|
|
7271
|
+
return `<!doctype html>
|
|
7272
|
+
<html lang="en">
|
|
7273
|
+
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(input.title)}</title>
|
|
7274
|
+
<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>
|
|
7275
|
+
</head>
|
|
7276
|
+
<body><main role="status"><h1>${escapeHtml(input.title)}</h1><p>${escapeHtml(input.message)}</p><p>You can close this tab.</p></main></body>
|
|
7277
|
+
</html>
|
|
7278
|
+
`;
|
|
7279
|
+
}
|
|
7280
|
+
var NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
7281
|
+
var PROVIDER_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
7282
|
+
function assertStoreName(kind, value) {
|
|
7283
|
+
if (!NAME_RE.test(value)) throw new ServeStoreError("invalid_name", `${kind} must match ${NAME_RE.source}`);
|
|
7284
|
+
return value;
|
|
7285
|
+
}
|
|
7286
|
+
function assertProviderId(value) {
|
|
7287
|
+
if (!PROVIDER_ID_RE.test(value)) throw new ServeStoreError("invalid_name", `provider id must match ${PROVIDER_ID_RE.source}`);
|
|
7288
|
+
return value;
|
|
7289
|
+
}
|
|
7290
|
+
var ServeStoreError = class extends Error {
|
|
7291
|
+
name = "ServeStoreError";
|
|
7292
|
+
constructor(code, message, options) {
|
|
7293
|
+
super(message, options);
|
|
7294
|
+
this.code = code;
|
|
7295
|
+
}
|
|
7296
|
+
};
|
|
7297
|
+
/** `MOLTNET_SERVE_ROOT` override, else `$XDG_CONFIG_HOME/moltnet`, else `~/.config/moltnet`. */
|
|
7298
|
+
function resolveServeRoot(input) {
|
|
7299
|
+
const override = input.root?.trim();
|
|
7300
|
+
if (override) return override;
|
|
7301
|
+
const xdg = input.xdgConfigHome?.trim();
|
|
7302
|
+
return join(xdg || join(homedir(), ".config"), "moltnet");
|
|
7303
|
+
}
|
|
7304
|
+
function providerEnvName(providerId) {
|
|
7305
|
+
return `MOLTNET_PROVIDER_${assertProviderId(providerId).replaceAll("-", "_").toUpperCase()}_API_KEY`;
|
|
7306
|
+
}
|
|
7307
|
+
function assertProviderEnvName(providerId, value) {
|
|
7308
|
+
const expected = providerEnvName(providerId);
|
|
7309
|
+
if (value !== expected) throw new ServeStoreError("invalid_state", `provider envName must be ${expected}`);
|
|
7310
|
+
return value;
|
|
7311
|
+
}
|
|
7312
|
+
function readJson(path) {
|
|
7313
|
+
let raw;
|
|
7314
|
+
try {
|
|
7315
|
+
raw = readFileSync(path, "utf8");
|
|
7316
|
+
} catch (cause) {
|
|
7317
|
+
if (cause.code === "ENOENT") return null;
|
|
7318
|
+
throw new ServeStoreError("io_error", `could not read state at ${path}`, { cause });
|
|
7319
|
+
}
|
|
7320
|
+
try {
|
|
7321
|
+
return JSON.parse(raw);
|
|
7322
|
+
} catch (cause) {
|
|
7323
|
+
throw new ServeStoreError("invalid_state", `corrupt JSON at ${path}`, { cause });
|
|
7324
|
+
}
|
|
7325
|
+
}
|
|
7326
|
+
function writeJsonAtomic(path, value) {
|
|
7327
|
+
const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
7328
|
+
try {
|
|
7329
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
7330
|
+
renameSync(temp, path);
|
|
7331
|
+
} catch (cause) {
|
|
7332
|
+
try {
|
|
7333
|
+
rmSync(temp, { force: true });
|
|
7334
|
+
} catch {}
|
|
7335
|
+
throw cause;
|
|
7336
|
+
}
|
|
7337
|
+
}
|
|
7338
|
+
var ServeStore = class {
|
|
7339
|
+
root;
|
|
7340
|
+
agentsDir;
|
|
7341
|
+
runsDir;
|
|
7342
|
+
secretsDir;
|
|
7343
|
+
constructor(root) {
|
|
7344
|
+
this.root = root;
|
|
7345
|
+
this.agentsDir = join(root, "agents");
|
|
7346
|
+
this.runsDir = join(root, "runs");
|
|
7347
|
+
this.secretsDir = join(root, "secrets");
|
|
7348
|
+
}
|
|
7349
|
+
/** Create the directory layout (0700) if missing. Idempotent. */
|
|
7350
|
+
ensure() {
|
|
7351
|
+
for (const dir of [
|
|
7352
|
+
this.root,
|
|
7353
|
+
this.agentsDir,
|
|
7354
|
+
this.runsDir,
|
|
7355
|
+
this.secretsDir
|
|
7356
|
+
]) mkdirSync(dir, {
|
|
7357
|
+
recursive: true,
|
|
7358
|
+
mode: 448
|
|
7359
|
+
});
|
|
7360
|
+
return this;
|
|
7361
|
+
}
|
|
7362
|
+
get servePath() {
|
|
7363
|
+
return join(this.root, "serve.json");
|
|
7364
|
+
}
|
|
7365
|
+
readServeState() {
|
|
7366
|
+
const state = readJson(this.servePath);
|
|
7367
|
+
if (!state) return {
|
|
7368
|
+
version: 1,
|
|
7369
|
+
pendingRegistrations: {},
|
|
7370
|
+
activations: {}
|
|
7371
|
+
};
|
|
7372
|
+
if (!isRecord(state) || state.version !== 1) throw new ServeStoreError("invalid_state", `serve.json version ${String(isRecord(state) ? state.version : void 0)} is not supported`);
|
|
7373
|
+
if ("pairedOrigins" in state) throw new ServeStoreError("invalid_state", "serve.json uses the obsolete pairing format; clear the unreleased serve store and reconfigure it");
|
|
7374
|
+
if (!isRecord(state.pendingRegistrations) || !isRecord(state.activations)) throw new ServeStoreError("invalid_state", "serve.json is missing the version 1 activation map; clear the unreleased serve store and reconfigure it");
|
|
7375
|
+
for (const [alias, activation] of Object.entries(state.activations)) validateActivation(alias, activation);
|
|
7376
|
+
for (const [alias, registration] of Object.entries(state.pendingRegistrations)) {
|
|
7377
|
+
assertStoreName("agent name", alias);
|
|
7378
|
+
if (!isRecord(registration) || typeof registration.apiUrl !== "string" || registration.apiUrl.length === 0 || typeof registration.createdAt !== "string" || registration.createdAt.length === 0) throw new ServeStoreError("invalid_state", `pending registration "${alias}" is not valid`);
|
|
7379
|
+
}
|
|
7380
|
+
return {
|
|
7381
|
+
version: 1,
|
|
7382
|
+
pendingRegistrations: state.pendingRegistrations,
|
|
7383
|
+
activations: state.activations
|
|
7384
|
+
};
|
|
7385
|
+
}
|
|
7386
|
+
writeServeState(state) {
|
|
7387
|
+
writeJsonAtomic(this.servePath, state);
|
|
7388
|
+
}
|
|
7389
|
+
agentPath(name) {
|
|
7390
|
+
return storeChildPath(this.agentsDir, "agent name", name, ".json");
|
|
7391
|
+
}
|
|
7392
|
+
readAgentConfig(alias) {
|
|
7393
|
+
return readJson(this.agentPath(alias));
|
|
7394
|
+
}
|
|
7395
|
+
writeAgentConfig(alias, config) {
|
|
7396
|
+
writeJsonAtomic(this.agentPath(alias), config);
|
|
7397
|
+
}
|
|
7398
|
+
removeAgentConfig(alias) {
|
|
7399
|
+
rmSync(this.agentPath(alias), { force: true });
|
|
7400
|
+
}
|
|
7401
|
+
readActivation(alias) {
|
|
7402
|
+
return this.readServeState().activations[assertStoreName("agent name", alias)] ?? null;
|
|
7403
|
+
}
|
|
7404
|
+
hasPendingRegistration(alias) {
|
|
7405
|
+
return Boolean(this.readServeState().pendingRegistrations[assertStoreName("agent name", alias)]);
|
|
7406
|
+
}
|
|
7407
|
+
reserveRegistration(alias, apiUrl) {
|
|
7408
|
+
const name = assertStoreName("agent name", alias);
|
|
7409
|
+
const state = this.readServeState();
|
|
7410
|
+
if (state.activations[name] || state.pendingRegistrations[name]) throw new ServeStoreError("already_exists", `agent "${name}" already exists in the serve store`);
|
|
7411
|
+
state.pendingRegistrations[name] = {
|
|
7412
|
+
apiUrl,
|
|
7413
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7414
|
+
};
|
|
7415
|
+
this.writeServeState(state);
|
|
7416
|
+
}
|
|
7417
|
+
clearPendingRegistration(alias) {
|
|
7418
|
+
const name = assertStoreName("agent name", alias);
|
|
7419
|
+
const state = this.readServeState();
|
|
7420
|
+
delete state.pendingRegistrations[name];
|
|
7421
|
+
this.writeServeState(state);
|
|
7422
|
+
}
|
|
7423
|
+
writeActivation(activation) {
|
|
7424
|
+
const alias = assertStoreName("agent name", activation.alias);
|
|
7425
|
+
validateActivation(alias, activation);
|
|
7426
|
+
const state = this.readServeState();
|
|
7427
|
+
state.activations[alias] = activation;
|
|
7428
|
+
if (activation.source === "managed") delete state.pendingRegistrations[alias];
|
|
7429
|
+
this.writeServeState(state);
|
|
7430
|
+
}
|
|
7431
|
+
listActivations() {
|
|
7432
|
+
return Object.values(this.readServeState().activations).sort((a, b) => a.alias.localeCompare(b.alias));
|
|
7433
|
+
}
|
|
7434
|
+
get providersPath() {
|
|
7435
|
+
return join(this.root, "providers.json");
|
|
7436
|
+
}
|
|
7437
|
+
readProviders() {
|
|
7438
|
+
const state = readJson(this.providersPath) ?? {};
|
|
7439
|
+
this.validateProviders(state);
|
|
7440
|
+
return state;
|
|
7441
|
+
}
|
|
7442
|
+
writeProviders(state) {
|
|
7443
|
+
this.validateProviders(state);
|
|
7444
|
+
writeJsonAtomic(this.providersPath, state);
|
|
7445
|
+
}
|
|
7446
|
+
validateProviders(state) {
|
|
7447
|
+
for (const [id, provider] of Object.entries(state)) {
|
|
7448
|
+
assertProviderId(id);
|
|
7449
|
+
assertProviderEnvName(id, provider.envName);
|
|
7450
|
+
}
|
|
7451
|
+
}
|
|
7452
|
+
runDir(id) {
|
|
7453
|
+
return storeChildPath(this.runsDir, "run id", id);
|
|
7454
|
+
}
|
|
7455
|
+
resolveRunLogPath(id) {
|
|
7456
|
+
let root;
|
|
7457
|
+
let runDir;
|
|
7458
|
+
try {
|
|
7459
|
+
root = realpathSync(this.runsDir);
|
|
7460
|
+
runDir = realpathSync(this.runDir(id));
|
|
7461
|
+
} catch (cause) {
|
|
7462
|
+
throw new ServeStoreError("io_error", "could not resolve run directory", { cause });
|
|
7463
|
+
}
|
|
7464
|
+
if (!isStrictDescendant(root, runDir)) throw new ServeStoreError("invalid_state", "run directory escapes its store");
|
|
7465
|
+
const logPath = join(runDir, "daemon.log");
|
|
7466
|
+
try {
|
|
7467
|
+
if (lstatSync(logPath).isSymbolicLink()) throw new ServeStoreError("invalid_state", "run log must not be a symbolic link");
|
|
7468
|
+
const resolvedLog = realpathSync(logPath);
|
|
7469
|
+
if (!isStrictDescendant(runDir, resolvedLog)) throw new ServeStoreError("invalid_state", "run log escapes its store");
|
|
7470
|
+
} catch (cause) {
|
|
7471
|
+
if (cause instanceof ServeStoreError) throw cause;
|
|
7472
|
+
if (cause.code !== "ENOENT") throw new ServeStoreError("io_error", "could not resolve run log", { cause });
|
|
7473
|
+
}
|
|
7474
|
+
return logPath;
|
|
7475
|
+
}
|
|
7476
|
+
createRunDir(id) {
|
|
7477
|
+
const dir = this.runDir(id);
|
|
7478
|
+
const piDir = join(dir, "pi");
|
|
7479
|
+
mkdirSync(piDir, {
|
|
7480
|
+
recursive: true,
|
|
7481
|
+
mode: 448
|
|
7482
|
+
});
|
|
7483
|
+
return {
|
|
7484
|
+
dir,
|
|
7485
|
+
piDir,
|
|
7486
|
+
logPath: join(dir, "daemon.log")
|
|
7487
|
+
};
|
|
7488
|
+
}
|
|
7489
|
+
readRun(id) {
|
|
7490
|
+
return readJson(join(this.runDir(id), "run.json"));
|
|
7491
|
+
}
|
|
7492
|
+
writeRun(record) {
|
|
7493
|
+
writeJsonAtomic(join(this.runDir(record.id), "run.json"), record);
|
|
7494
|
+
}
|
|
7495
|
+
listRuns(limit = Number.POSITIVE_INFINITY) {
|
|
7496
|
+
let ids;
|
|
7497
|
+
try {
|
|
7498
|
+
ids = readdirSync(this.runsDir);
|
|
7499
|
+
} catch {
|
|
7500
|
+
return [];
|
|
7501
|
+
}
|
|
7502
|
+
const sortedIds = ids.filter((id) => NAME_RE.test(id)).sort().reverse();
|
|
7503
|
+
const selectedIds = Number.isFinite(limit) ? sortedIds.slice(0, Math.max(0, limit)) : sortedIds;
|
|
7504
|
+
const records = [];
|
|
7505
|
+
for (const id of selectedIds) {
|
|
7506
|
+
const record = this.readRun(id);
|
|
7507
|
+
if (record) records.push(record);
|
|
7508
|
+
}
|
|
7509
|
+
return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
7510
|
+
}
|
|
7511
|
+
/** Remove only completed run directories outside the configured budget. */
|
|
7512
|
+
pruneCompletedRuns(options) {
|
|
7513
|
+
const now = (options.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
7514
|
+
let retainedBytes = 0;
|
|
7515
|
+
let retainedCount = 0;
|
|
7516
|
+
const removed = [];
|
|
7517
|
+
for (const record of this.listRuns()) {
|
|
7518
|
+
if (record.status === "running") continue;
|
|
7519
|
+
const dir = this.runDir(record.id);
|
|
7520
|
+
const bytes = directoryBytes(dir);
|
|
7521
|
+
const endedAt = Date.parse(record.endedAt ?? record.startedAt);
|
|
7522
|
+
const expired = !Number.isFinite(endedAt) || now - endedAt > options.maxAgeMs;
|
|
7523
|
+
const overCount = retainedCount >= options.maxCount;
|
|
7524
|
+
const overBytes = retainedBytes + bytes > options.maxBytes;
|
|
7525
|
+
if (expired || overCount || overBytes) {
|
|
7526
|
+
rmSync(dir, {
|
|
7527
|
+
recursive: true,
|
|
7528
|
+
force: true
|
|
7529
|
+
});
|
|
7530
|
+
removed.push(record.id);
|
|
7531
|
+
continue;
|
|
7532
|
+
}
|
|
7533
|
+
retainedCount += 1;
|
|
7534
|
+
retainedBytes += bytes;
|
|
7535
|
+
}
|
|
7536
|
+
return removed;
|
|
7537
|
+
}
|
|
7538
|
+
};
|
|
7539
|
+
function directoryBytes(path) {
|
|
7540
|
+
let info;
|
|
7541
|
+
try {
|
|
7542
|
+
info = lstatSync(path);
|
|
7543
|
+
} catch {
|
|
7544
|
+
return 0;
|
|
7545
|
+
}
|
|
7546
|
+
if (info.isSymbolicLink()) return 0;
|
|
7547
|
+
if (!info.isDirectory()) return info.size;
|
|
7548
|
+
let total = 0;
|
|
7549
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) total += directoryBytes(join(path, entry.name));
|
|
7550
|
+
return total;
|
|
7551
|
+
}
|
|
7552
|
+
function isRecord(value) {
|
|
7553
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7554
|
+
}
|
|
7555
|
+
function storeChildPath(root, kind, value, suffix = "") {
|
|
7556
|
+
const name = assertStoreName(kind, value);
|
|
7557
|
+
const normalizedRoot = resolve(root);
|
|
7558
|
+
const candidate = resolve(normalizedRoot, `${name}${suffix}`);
|
|
7559
|
+
if (!isStrictDescendant(normalizedRoot, candidate)) throw new ServeStoreError("invalid_name", `${kind} escapes its store`);
|
|
7560
|
+
return candidate;
|
|
7561
|
+
}
|
|
7562
|
+
function isStrictDescendant(root, candidate) {
|
|
7563
|
+
const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
7564
|
+
return candidate !== root && candidate.startsWith(rootPrefix);
|
|
7565
|
+
}
|
|
7566
|
+
function validateActivation(alias, value) {
|
|
7567
|
+
const invalid = () => {
|
|
7568
|
+
throw new ServeStoreError("invalid_state", `activation "${alias}" is not a valid version 1 activation`);
|
|
7569
|
+
};
|
|
7570
|
+
if (!isRecord(value)) invalid();
|
|
7571
|
+
const activation = value;
|
|
7572
|
+
if (activation.alias !== alias) invalid();
|
|
7573
|
+
if (![
|
|
7574
|
+
"identityId",
|
|
7575
|
+
"publicKey",
|
|
7576
|
+
"fingerprint",
|
|
7577
|
+
"createdAt"
|
|
7578
|
+
].every((field) => typeof activation[field] === "string" && activation[field].length > 0)) invalid();
|
|
7579
|
+
if (activation.source === "managed") {
|
|
7580
|
+
if (typeof activation.apiUrl !== "string" || activation.apiUrl.length === 0 || activation.configPath !== void 0 || activation.configApiUrl !== void 0) invalid();
|
|
7581
|
+
return;
|
|
7582
|
+
}
|
|
7583
|
+
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();
|
|
7584
|
+
}
|
|
7585
|
+
//#endregion
|
|
7586
|
+
//#region src/lib/serve/identity.ts
|
|
7587
|
+
/**
|
|
7588
|
+
* Serve identity activation (#2061/#1834 boundary).
|
|
7589
|
+
*
|
|
7590
|
+
* Managed agent files are canonical `MoltNetConfig` documents. Alias,
|
|
7591
|
+
* provenance, pinned identity material, and external config paths live only
|
|
7592
|
+
* in the versioned activation map in `serve.json`. Every attach and run loads
|
|
7593
|
+
* the current config and verifies it with authenticated `whoami` before use.
|
|
7594
|
+
*/
|
|
7595
|
+
var MAX_CONFIG_BYTES = 64 * 1024;
|
|
7596
|
+
var IDENTITY_OPERATION_TIMEOUT_MS = 15e3;
|
|
7597
|
+
var pendingAliases = /* @__PURE__ */ new WeakMap();
|
|
7598
|
+
var ServeIdentityError = class extends Error {
|
|
7599
|
+
name = "ServeIdentityError";
|
|
7600
|
+
constructor(code, message, options) {
|
|
7601
|
+
super(message, options);
|
|
7602
|
+
this.code = code;
|
|
7603
|
+
}
|
|
7604
|
+
};
|
|
7605
|
+
function reserveAlias(store, alias) {
|
|
7606
|
+
let pending = pendingAliases.get(store);
|
|
7607
|
+
if (!pending) {
|
|
7608
|
+
pending = /* @__PURE__ */ new Set();
|
|
7609
|
+
pendingAliases.set(store, pending);
|
|
7610
|
+
}
|
|
7611
|
+
if (pending.has(alias) || store.hasPendingRegistration(alias) || store.readActivation(alias) || store.readAgentConfig(alias)) throw new ServeIdentityError("agent_exists", `agent "${alias}" already exists in the serve store`);
|
|
7612
|
+
pending.add(alias);
|
|
7613
|
+
return () => pending.delete(alias);
|
|
7614
|
+
}
|
|
7615
|
+
async function createManagedAgent(store, secrets, input) {
|
|
7616
|
+
const alias = assertStoreName("agent name", input.name);
|
|
7617
|
+
const releaseAlias = reserveAlias(store, alias);
|
|
7618
|
+
let registeredIdentityId;
|
|
7619
|
+
try {
|
|
7620
|
+
let apiUrl;
|
|
7621
|
+
try {
|
|
7622
|
+
apiUrl = requireSecureCredentialApiUrl(input.apiUrl);
|
|
7623
|
+
} catch (cause) {
|
|
7624
|
+
throw new ServeIdentityError("registration_failed", "registration API URL must use HTTPS or HTTP loopback", { cause });
|
|
7625
|
+
}
|
|
7626
|
+
store.reserveRegistration(alias, apiUrl);
|
|
7627
|
+
const result = await register({
|
|
7628
|
+
credentialType: "agent_key",
|
|
7629
|
+
apiUrl,
|
|
7630
|
+
...input.enrollmentToken ? { enrollmentToken: input.enrollmentToken } : {},
|
|
7631
|
+
signal: boundedIdentitySignal(input.signal)
|
|
7632
|
+
});
|
|
7633
|
+
if (result.credentials.type !== "agent_key") throw new ServeIdentityError("unsupported_credential", `registration returned credential type "${result.credentials.type}"; serve manages agent-key credentials only`);
|
|
7634
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7635
|
+
const { identityId, fingerprint, publicKey, privateKey } = result.identity;
|
|
7636
|
+
registeredIdentityId = identityId;
|
|
7637
|
+
const agentKeyReference = {
|
|
7638
|
+
provider: FILE_SECRET_PROVIDER,
|
|
7639
|
+
key: agentKeyKey(identityId)
|
|
7640
|
+
};
|
|
7641
|
+
const seedReference = {
|
|
7642
|
+
provider: FILE_SECRET_PROVIDER,
|
|
7643
|
+
key: identitySeedKey(fingerprint)
|
|
7644
|
+
};
|
|
7645
|
+
const config = {
|
|
7646
|
+
identity_id: identityId,
|
|
7647
|
+
registered_at: now,
|
|
7648
|
+
agent_key_ref: agentKeyReference,
|
|
7649
|
+
keys: {
|
|
7650
|
+
public_key: publicKey,
|
|
7651
|
+
fingerprint,
|
|
7652
|
+
private_key_ref: seedReference
|
|
7653
|
+
},
|
|
7654
|
+
endpoints: {
|
|
7655
|
+
api: result.apiUrl,
|
|
7656
|
+
mcp: deriveMcpUrl(result.apiUrl)
|
|
7657
|
+
}
|
|
7658
|
+
};
|
|
7659
|
+
const activation = {
|
|
7660
|
+
alias,
|
|
7661
|
+
source: "managed",
|
|
7662
|
+
identityId,
|
|
7663
|
+
publicKey,
|
|
7664
|
+
fingerprint,
|
|
7665
|
+
createdAt: now,
|
|
7666
|
+
apiUrl: result.apiUrl
|
|
7667
|
+
};
|
|
7668
|
+
store.writeAgentConfig(alias, config);
|
|
7669
|
+
await secrets.write(agentKeyReference.key, result.credentials.secret);
|
|
7670
|
+
await secrets.write(seedReference.key, privateKey);
|
|
7671
|
+
store.writeActivation(activation);
|
|
7672
|
+
return {
|
|
7673
|
+
activation,
|
|
7674
|
+
config
|
|
7675
|
+
};
|
|
7676
|
+
} catch (cause) {
|
|
7677
|
+
if (!registeredIdentityId && cause instanceof MoltNetError && cause.statusCode !== void 0 && cause.statusCode >= 400 && cause.statusCode < 500) {
|
|
7678
|
+
store.clearPendingRegistration(alias);
|
|
7679
|
+
throw new ServeIdentityError("registration_failed", `registration for "${alias}" was rejected`, { cause });
|
|
7680
|
+
}
|
|
7681
|
+
if (store.hasPendingRegistration(alias)) throw new ServeIdentityError("registration_incomplete", registeredIdentityId ? `identity "${registeredIdentityId}" was registered but local activation is incomplete; reconcile or clear its pending serve record before retrying` : `registration for "${alias}" may be incomplete; inspect the remote API before changing its pending serve record`, { cause });
|
|
7682
|
+
throw cause;
|
|
7683
|
+
} finally {
|
|
7684
|
+
releaseAlias();
|
|
7685
|
+
}
|
|
7686
|
+
}
|
|
7687
|
+
/** Resume a fully persisted registration or explicitly abandon local recovery. */
|
|
7688
|
+
async function reconcileManagedRegistration(store, secrets, aliasInput, action, connectAgent = connect, signal) {
|
|
7689
|
+
const alias = assertStoreName("agent name", aliasInput);
|
|
7690
|
+
const activation = store.readActivation(alias);
|
|
7691
|
+
if (activation) {
|
|
7692
|
+
const config = store.readAgentConfig(alias);
|
|
7693
|
+
if (!config) throw new ServeIdentityError("registration_incomplete", `managed activation for "${alias}" is missing its canonical config`);
|
|
7694
|
+
return {
|
|
7695
|
+
activation,
|
|
7696
|
+
config
|
|
7697
|
+
};
|
|
7698
|
+
}
|
|
7699
|
+
if (!store.hasPendingRegistration(alias)) throw new ServeIdentityError("config_not_found", `agent "${alias}" has no pending registration to reconcile`);
|
|
7700
|
+
const config = store.readAgentConfig(alias);
|
|
7701
|
+
if (action === "abandon") {
|
|
7702
|
+
store.removeAgentConfig(alias);
|
|
7703
|
+
store.clearPendingRegistration(alias);
|
|
7704
|
+
return null;
|
|
7705
|
+
}
|
|
7706
|
+
if (!config?.agent_key_ref || config.agent_key_ref.provider !== FILE_SECRET_PROVIDER || config.keys.private_key_ref?.provider !== FILE_SECRET_PROVIDER) throw new ServeIdentityError("registration_incomplete", `pending registration for "${alias}" does not have complete managed references`);
|
|
7707
|
+
const [agentKey, privateKeyState] = await Promise.all([secrets.read(config.agent_key_ref.key), secrets.probe(config.keys.private_key_ref.key)]);
|
|
7708
|
+
if (!agentKey || privateKeyState !== "present") throw new ServeIdentityError("registration_incomplete", `pending registration for "${alias}" is missing persisted secret material`);
|
|
7709
|
+
const identity = identityFromConfig(config);
|
|
7710
|
+
const apiUrl = requireConfigApiUrl(config, store.agentPath(alias));
|
|
7711
|
+
assertIdentityMatches(await callWhoami(connectAgent, {
|
|
7712
|
+
agentKey,
|
|
7713
|
+
apiUrl
|
|
7714
|
+
}, store.agentPath(alias), signal), identity, "authenticated whoami", `pending registration "${alias}" config`);
|
|
7715
|
+
const recovered = {
|
|
7716
|
+
alias,
|
|
7717
|
+
source: "managed",
|
|
7718
|
+
...identity,
|
|
7719
|
+
createdAt: config.registered_at,
|
|
7720
|
+
apiUrl
|
|
7721
|
+
};
|
|
7722
|
+
store.writeActivation(recovered);
|
|
7723
|
+
return {
|
|
7724
|
+
activation: recovered,
|
|
7725
|
+
config
|
|
7726
|
+
};
|
|
7727
|
+
}
|
|
7728
|
+
async function attachExternalAgent(store, secretProviders, input, connectAgent = connect) {
|
|
7729
|
+
const alias = assertStoreName("agent name", input.name);
|
|
7730
|
+
const releaseAlias = reserveAlias(store, alias);
|
|
7731
|
+
try {
|
|
7732
|
+
if (!isAbsolute(input.configDir)) throw new ServeIdentityError("config_not_found", "external configDir must be an absolute path");
|
|
7733
|
+
const configPath = join(input.configDir, "moltnet.json");
|
|
7734
|
+
externalAgentLocation(configPath);
|
|
7735
|
+
const config = await readCurrentConfig(configPath);
|
|
7736
|
+
const configApiUrl = requireTrustedConfigApiUrl(config, configPath);
|
|
7737
|
+
const effectiveApiUrl = requireTrustedApiOverride(input.apiUrl, configApiUrl, configPath);
|
|
7738
|
+
const whoami = await authenticateConfig(input.configDir, effectiveApiUrl, secretProviders, connectAgent, input.signal);
|
|
7739
|
+
const identity = identityFromConfig(config);
|
|
7740
|
+
assertIdentityMatches(identity, whoami, `external config ${configPath}`, "authenticated whoami");
|
|
7741
|
+
const activation = {
|
|
7742
|
+
alias,
|
|
7743
|
+
source: "external",
|
|
7744
|
+
...identity,
|
|
7745
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7746
|
+
configPath,
|
|
7747
|
+
configApiUrl,
|
|
7748
|
+
...input.apiUrl ? { apiUrl: input.apiUrl } : {}
|
|
7749
|
+
};
|
|
7750
|
+
store.writeActivation(activation);
|
|
7751
|
+
return {
|
|
7752
|
+
activation,
|
|
7753
|
+
config
|
|
7754
|
+
};
|
|
7755
|
+
} finally {
|
|
7756
|
+
releaseAlias();
|
|
7757
|
+
}
|
|
7758
|
+
}
|
|
7759
|
+
/** Load and authenticate the current config, then compare all pinned fields. */
|
|
7760
|
+
async function verifyAgentActivation(store, alias, managedSecretProviders, externalSecretProviders, connectAgent = connect, signal) {
|
|
7761
|
+
const activation = requireActivation(store, alias);
|
|
7762
|
+
const verified = activation.source === "managed" ? await verifyManagedActivation(store, activation, managedSecretProviders, connectAgent, signal) : await verifyExternalActivation(activation, externalSecretProviders, connectAgent, signal);
|
|
7763
|
+
assertIdentityMatches(verified.whoami, activation, "authenticated whoami", `agent "${activation.alias}" pinned activation`);
|
|
7764
|
+
return {
|
|
7765
|
+
activation,
|
|
7766
|
+
config: verified.config
|
|
7767
|
+
};
|
|
7768
|
+
}
|
|
7769
|
+
async function verifyManagedActivation(store, activation, secretProviders, connectAgent, signal) {
|
|
7770
|
+
const configPath = store.agentPath(activation.alias);
|
|
7771
|
+
const config = await readCurrentConfig(configPath);
|
|
7772
|
+
assertActivatedConfig(config, activation, configPath, requireConfigApiUrl(config, configPath), activation.apiUrl);
|
|
7773
|
+
let agentKey;
|
|
7774
|
+
try {
|
|
7775
|
+
agentKey = await resolveAgentKey(config, secretProviders);
|
|
7776
|
+
} catch (cause) {
|
|
7777
|
+
throw verificationError(`could not resolve the managed agent key for "${activation.alias}"`, cause);
|
|
7778
|
+
}
|
|
7779
|
+
if (!agentKey) throw new ServeIdentityError("verification_failed", `managed config for "${activation.alias}" has no agent_key_ref`);
|
|
7780
|
+
return {
|
|
7781
|
+
config,
|
|
7782
|
+
whoami: await callWhoami(connectAgent, {
|
|
7783
|
+
agentKey,
|
|
7784
|
+
apiUrl: activation.apiUrl
|
|
7785
|
+
}, configPath, signal)
|
|
7786
|
+
};
|
|
7787
|
+
}
|
|
7788
|
+
async function verifyExternalActivation(activation, secretProviders, connectAgent, signal) {
|
|
7789
|
+
externalAgentLocation(activation.configPath);
|
|
7790
|
+
assertTrustedConfigApiUrl(activation.configApiUrl);
|
|
7791
|
+
const config = await readCurrentConfig(activation.configPath);
|
|
7792
|
+
assertActivatedConfig(config, activation, activation.configPath, requireTrustedConfigApiUrl(config, activation.configPath), activation.configApiUrl);
|
|
7793
|
+
const effectiveApiUrl = requireTrustedApiOverride(activation.apiUrl, activation.configApiUrl, activation.configPath);
|
|
7794
|
+
return {
|
|
7795
|
+
config,
|
|
7796
|
+
whoami: await authenticateConfig(dirname(activation.configPath), effectiveApiUrl, secretProviders, connectAgent, signal)
|
|
7797
|
+
};
|
|
7798
|
+
}
|
|
7799
|
+
/** Never let request or activation metadata redirect persisted credentials. */
|
|
7800
|
+
function requireTrustedApiOverride(override, configApiUrl, configPath) {
|
|
7801
|
+
if (!override) return configApiUrl;
|
|
7802
|
+
if (override !== configApiUrl) throw new ServeIdentityError("verification_failed", `API override for ${configPath} does not match its configured endpoint`);
|
|
7803
|
+
return configApiUrl;
|
|
7804
|
+
}
|
|
7805
|
+
function assertActivatedConfig(config, activation, configPath, currentApiUrl, pinnedApiUrl) {
|
|
7806
|
+
if (currentApiUrl !== pinnedApiUrl) throw new ServeIdentityError("verification_failed", `agent config at ${configPath} API endpoint does not match its pinned activation`);
|
|
7807
|
+
assertIdentityMatches(identityFromConfig(config), activation, configPath, `agent "${activation.alias}" pinned activation`);
|
|
7808
|
+
}
|
|
7809
|
+
function requireConfigApiUrl(config, configPath) {
|
|
7810
|
+
const apiUrl = config?.endpoints?.api?.trim();
|
|
7811
|
+
if (!apiUrl) throw new ServeIdentityError("verification_failed", `agent config at ${configPath} is missing endpoints.api`);
|
|
7812
|
+
return apiUrl;
|
|
7813
|
+
}
|
|
7814
|
+
function requireTrustedConfigApiUrl(config, configPath) {
|
|
7815
|
+
const apiUrl = requireConfigApiUrl(config, configPath);
|
|
7816
|
+
try {
|
|
7817
|
+
assertTrustedConfigApiUrl(apiUrl);
|
|
7818
|
+
return apiUrl;
|
|
7819
|
+
} catch (cause) {
|
|
7820
|
+
throw verificationError(`agent config at ${configPath} has an untrusted endpoints.api`, cause);
|
|
7821
|
+
}
|
|
7822
|
+
}
|
|
7823
|
+
async function readCurrentConfig(configPath) {
|
|
7824
|
+
let file;
|
|
7825
|
+
try {
|
|
7826
|
+
file = await open(configPath, "r");
|
|
7827
|
+
} catch (cause) {
|
|
7828
|
+
throw new ServeIdentityError("config_not_found", `agent config is missing at ${configPath}`, { cause });
|
|
7829
|
+
}
|
|
7830
|
+
let raw;
|
|
7831
|
+
try {
|
|
7832
|
+
if (!(await file.stat()).isFile()) throw new ServeIdentityError("verification_failed", `agent config at ${configPath} must be a regular file no larger than ${MAX_CONFIG_BYTES} bytes`);
|
|
7833
|
+
const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
|
|
7834
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
7835
|
+
if (bytesRead > MAX_CONFIG_BYTES) throw new ServeIdentityError("verification_failed", `agent config at ${configPath} must be a regular file no larger than ${MAX_CONFIG_BYTES} bytes`);
|
|
7836
|
+
raw = buffer.toString("utf8", 0, bytesRead);
|
|
7837
|
+
} finally {
|
|
7838
|
+
await file.close();
|
|
7839
|
+
}
|
|
7840
|
+
try {
|
|
7841
|
+
return JSON.parse(raw);
|
|
7842
|
+
} catch (cause) {
|
|
7843
|
+
throw verificationError(`agent config is not valid JSON at ${configPath}`, cause);
|
|
7844
|
+
}
|
|
7845
|
+
}
|
|
7846
|
+
/** Resolve the unchanged daemon CLI's root/name pair for an external config. */
|
|
7847
|
+
function externalAgentLocation(configPath) {
|
|
7848
|
+
const configDir = dirname(configPath);
|
|
7849
|
+
const moltnetDir = dirname(configDir);
|
|
7850
|
+
if (!isAbsolute(configPath) || basename(configPath) !== "moltnet.json" || basename(moltnetDir) !== ".moltnet") throw new ServeIdentityError("config_not_found", `external config must be at an absolute <agent-root>/.moltnet/<agent>/moltnet.json path`);
|
|
7851
|
+
return {
|
|
7852
|
+
agentName: assertStoreName("external config agent name", basename(configDir)),
|
|
7853
|
+
agentRoot: dirname(moltnetDir)
|
|
7854
|
+
};
|
|
7855
|
+
}
|
|
7856
|
+
async function authenticateConfig(configDir, apiUrl, secretProviders, connectAgent, signal) {
|
|
7857
|
+
return callWhoami(connectAgent, {
|
|
7858
|
+
configDir,
|
|
7859
|
+
...apiUrl ? { apiUrl } : {},
|
|
7860
|
+
secretProviders
|
|
7861
|
+
}, configDir, signal);
|
|
7862
|
+
}
|
|
7863
|
+
async function callWhoami(connectAgent, options, source, signal) {
|
|
7864
|
+
try {
|
|
7865
|
+
const boundedSignal = boundedIdentitySignal(signal);
|
|
7866
|
+
return await (await connectAgent({
|
|
7867
|
+
...options,
|
|
7868
|
+
signal: boundedSignal
|
|
7869
|
+
})).agents.whoami({ signal: boundedSignal });
|
|
7870
|
+
} catch (cause) {
|
|
7871
|
+
throw verificationError(`could not authenticate ${source} against the API`, cause);
|
|
7872
|
+
}
|
|
7873
|
+
}
|
|
7874
|
+
function boundedIdentitySignal(signal) {
|
|
7875
|
+
const timeout = AbortSignal.timeout(IDENTITY_OPERATION_TIMEOUT_MS);
|
|
7876
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
7877
|
+
}
|
|
7878
|
+
function identityFromConfig(config) {
|
|
7879
|
+
const identityId = config?.identity_id?.trim();
|
|
7880
|
+
const publicKey = config?.keys?.public_key?.trim();
|
|
7881
|
+
const fingerprint = config?.keys?.fingerprint?.trim();
|
|
7882
|
+
if (!identityId || !publicKey || !fingerprint) throw new ServeIdentityError("verification_failed", "agent config is missing canonical identity_id, keys.public_key, or keys.fingerprint");
|
|
7883
|
+
return {
|
|
7884
|
+
identityId,
|
|
7885
|
+
publicKey,
|
|
7886
|
+
fingerprint
|
|
7887
|
+
};
|
|
7888
|
+
}
|
|
7889
|
+
function assertIdentityMatches(current, expected, currentLabel, expectedLabel) {
|
|
7890
|
+
const assessment = assessIdentityPin(current, expected);
|
|
7891
|
+
if (!assessment.ok) throw new ServeIdentityError("verification_failed", `${currentLabel} ${assessment.label} does not match ${expectedLabel}`);
|
|
7892
|
+
}
|
|
7893
|
+
function verificationError(message, cause) {
|
|
7894
|
+
return new ServeIdentityError("verification_failed", message, { cause });
|
|
7895
|
+
}
|
|
7896
|
+
/** Non-secret projection preserving the existing `/v1` response shape. */
|
|
7897
|
+
function publicAgentView(store, activation) {
|
|
7898
|
+
if (activation.source === "managed") {
|
|
7899
|
+
const config = store.readAgentConfig(activation.alias);
|
|
7900
|
+
return {
|
|
7901
|
+
kind: "managed",
|
|
7902
|
+
agentName: activation.alias,
|
|
7903
|
+
identityId: activation.identityId,
|
|
7904
|
+
fingerprint: activation.fingerprint,
|
|
7905
|
+
apiUrl: activation.apiUrl,
|
|
7906
|
+
createdAt: activation.createdAt,
|
|
7907
|
+
hasAgentKey: Boolean(config?.agent_key_ref),
|
|
7908
|
+
hasPrivateKey: Boolean(config?.keys.private_key_ref)
|
|
7909
|
+
};
|
|
7910
|
+
}
|
|
7911
|
+
return {
|
|
7912
|
+
kind: "external",
|
|
7913
|
+
agentName: activation.alias,
|
|
7914
|
+
configDir: dirname(activation.configPath),
|
|
7915
|
+
...activation.apiUrl ? { apiUrl: activation.apiUrl } : {},
|
|
7916
|
+
identityId: activation.identityId,
|
|
7917
|
+
fingerprint: activation.fingerprint,
|
|
7918
|
+
createdAt: activation.createdAt
|
|
7919
|
+
};
|
|
7920
|
+
}
|
|
7921
|
+
function requireActivation(store, alias) {
|
|
7922
|
+
const activation = store.readActivation(alias);
|
|
7923
|
+
if (!activation) throw new ServeStoreError("not_found", `agent "${alias}" is not configured`);
|
|
7924
|
+
return activation;
|
|
7925
|
+
}
|
|
7926
|
+
//#endregion
|
|
7927
|
+
//#region src/lib/serve/runs.ts
|
|
7928
|
+
var STOP_GRACE_MS = 1e4;
|
|
7929
|
+
var STOP_FORCE_MS = 2e3;
|
|
7930
|
+
var DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
7931
|
+
var DEFAULT_MAX_COMPLETED_RUNS = 100;
|
|
7932
|
+
var DEFAULT_MAX_RUN_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
7933
|
+
var DEFAULT_MAX_RUN_STORAGE_BYTES = 512 * 1024 * 1024;
|
|
7934
|
+
var DEFAULT_MAX_ACTIVE_RUNS = 16;
|
|
7935
|
+
var DEFAULT_MAX_ACTIVE_RUNS_PER_AGENT = 4;
|
|
7936
|
+
var LOG_TRUNCATION_MARKER = Buffer.from("[truncated]\n");
|
|
7937
|
+
var INHERITED_ENV_NAMES = new Set([
|
|
7938
|
+
"COLORTERM",
|
|
7939
|
+
"FORCE_COLOR",
|
|
7940
|
+
"LANG",
|
|
7941
|
+
"LC_ALL",
|
|
7942
|
+
"LC_CTYPE",
|
|
7943
|
+
"LOG_LEVEL",
|
|
7944
|
+
"NO_COLOR",
|
|
7945
|
+
"PATH",
|
|
7946
|
+
"SSL_CERT_DIR",
|
|
7947
|
+
"SSL_CERT_FILE",
|
|
7948
|
+
"TEMP",
|
|
7949
|
+
"TERM",
|
|
7950
|
+
"TMP",
|
|
7951
|
+
"TMPDIR",
|
|
7952
|
+
"TZ"
|
|
7953
|
+
]);
|
|
7954
|
+
var INHERITED_MOLTNET_ENV_NAMES = new Set([
|
|
7955
|
+
"MOLTNET_CLI_LINUX_BINARY",
|
|
7956
|
+
"MOLTNET_CREDENTIAL_BINDINGS",
|
|
7957
|
+
"MOLTNET_CREDENTIAL_ENFORCEMENT",
|
|
7958
|
+
"MOLTNET_DIARY_ID",
|
|
7959
|
+
"MOLTNET_GIT_AUTHOR",
|
|
7960
|
+
"MOLTNET_OTEL_ENDPOINT",
|
|
7961
|
+
"MOLTNET_PI_VM_INTEGRATION",
|
|
7962
|
+
"MOLTNET_PROFILE_CREDENTIAL_REQUIREMENTS",
|
|
7963
|
+
"MOLTNET_SIGNER_URL",
|
|
7964
|
+
"MOLTNET_TRACE_IDLE_POLLING"
|
|
7965
|
+
]);
|
|
7966
|
+
var ServeRunError = class extends Error {
|
|
7967
|
+
name = "ServeRunError";
|
|
7968
|
+
constructor(code, message) {
|
|
7969
|
+
super(message);
|
|
7970
|
+
this.code = code;
|
|
7971
|
+
}
|
|
7972
|
+
};
|
|
7973
|
+
function validateRunSpec(spec) {
|
|
7974
|
+
if (spec.mode !== "poll" && spec.mode !== "drain") throw new ServeRunError("invalid_spec", "mode must be poll or drain");
|
|
7975
|
+
if (!spec.agent) throw new ServeRunError("invalid_spec", "agent is required");
|
|
7976
|
+
if (!spec.teamId) throw new ServeRunError("invalid_spec", "teamId is required");
|
|
7977
|
+
if (!Array.isArray(spec.profiles) || spec.profiles.length === 0) throw new ServeRunError("invalid_spec", "at least one profile is required");
|
|
7978
|
+
if (!Array.isArray(spec.taskTypes) || spec.taskTypes.length === 0) throw new ServeRunError("invalid_spec", "at least one task type is required");
|
|
7979
|
+
}
|
|
7980
|
+
var RunManager = class {
|
|
7981
|
+
active = /* @__PURE__ */ new Map();
|
|
7982
|
+
startingByAgent = /* @__PURE__ */ new Map();
|
|
7983
|
+
starting = 0;
|
|
7984
|
+
closing = false;
|
|
7985
|
+
constructor(options) {
|
|
7986
|
+
this.options = options;
|
|
7987
|
+
this.reconcileInterruptedRuns();
|
|
7988
|
+
this.pruneCompletedRuns();
|
|
7989
|
+
}
|
|
7990
|
+
get store() {
|
|
7991
|
+
return this.options.store;
|
|
7992
|
+
}
|
|
7993
|
+
entrypoint() {
|
|
7994
|
+
return this.options.entrypoint ?? {
|
|
7995
|
+
execPath: process.execPath,
|
|
7996
|
+
execArgv: process.execArgv,
|
|
7997
|
+
scriptPath: process.argv[1] ?? ""
|
|
7998
|
+
};
|
|
7999
|
+
}
|
|
8000
|
+
/** Assemble child env + args for a run. Exposed for tests. */
|
|
8001
|
+
async prepare(spec, agent, piDir, providers = this.store.readProviders()) {
|
|
8002
|
+
const { activation, config } = agent;
|
|
8003
|
+
const homeDir = join(dirname(piDir), "home");
|
|
8004
|
+
const env = {
|
|
8005
|
+
HOME: homeDir,
|
|
8006
|
+
PI_CODING_AGENT_DIR: piDir,
|
|
8007
|
+
XDG_CACHE_HOME: join(homeDir, ".cache"),
|
|
8008
|
+
XDG_CONFIG_HOME: join(homeDir, ".config"),
|
|
8009
|
+
XDG_DATA_HOME: join(homeDir, ".local", "share"),
|
|
8010
|
+
MOLTNET_TEAM_ID: spec.teamId
|
|
8011
|
+
};
|
|
8012
|
+
const target = activation.source === "managed" ? {
|
|
8013
|
+
agentName: activation.alias,
|
|
8014
|
+
cwd: dirname(piDir),
|
|
8015
|
+
extraArgs: []
|
|
8016
|
+
} : (() => {
|
|
8017
|
+
const { agentName, agentRoot } = externalAgentLocation(activation.configPath);
|
|
8018
|
+
return {
|
|
8019
|
+
agentName,
|
|
8020
|
+
cwd: agentRoot,
|
|
8021
|
+
extraArgs: ["--agent-root", agentRoot]
|
|
8022
|
+
};
|
|
8023
|
+
})();
|
|
8024
|
+
const args = [
|
|
8025
|
+
spec.mode,
|
|
8026
|
+
"--agent",
|
|
8027
|
+
target.agentName,
|
|
8028
|
+
"--team",
|
|
8029
|
+
spec.teamId,
|
|
8030
|
+
...spec.profiles.flatMap((profile) => ["--profile", profile]),
|
|
8031
|
+
"--task-types",
|
|
8032
|
+
spec.taskTypes.join(","),
|
|
8033
|
+
...target.extraArgs
|
|
8034
|
+
];
|
|
8035
|
+
if (activation.source === "managed") {
|
|
8036
|
+
if (!config.agent_key_ref || !config.keys.private_key_ref) throw new ServeRunError("invalid_spec", `managed config for "${activation.alias}" is missing canonical secret references`);
|
|
8037
|
+
env["MOLTNET_API_URL"] = activation.apiUrl;
|
|
8038
|
+
env["MOLTNET_AGENT_KEY_REF"] = formatSecretReferenceString(config.agent_key_ref);
|
|
8039
|
+
env["MOLTNET_PRIVATE_KEY_REF"] = formatSecretReferenceString(config.keys.private_key_ref);
|
|
8040
|
+
env["MOLTNET_SECRET_ROOT"] = this.store.secretsDir;
|
|
8041
|
+
} else {
|
|
8042
|
+
env["MOLTNET_API_URL"] = activation.apiUrl ?? activation.configApiUrl;
|
|
8043
|
+
try {
|
|
8044
|
+
const agentKey = await resolveAgentKey(config, this.options.externalSecretProviders);
|
|
8045
|
+
if (agentKey) {
|
|
8046
|
+
env["MOLTNET_AGENT_KEY"] = agentKey;
|
|
8047
|
+
env["MOLTNET_PRIVATE_KEY"] = await resolveIdentitySeed(config, this.options.externalSecretProviders);
|
|
8048
|
+
} else if (config.oauth2?.client_secret_ref) {
|
|
8049
|
+
env["MOLTNET_CLIENT_ID"] = config.oauth2.client_id;
|
|
8050
|
+
env["MOLTNET_CLIENT_SECRET"] = await resolveOAuth2ClientSecret(config, this.options.externalSecretProviders);
|
|
8051
|
+
}
|
|
8052
|
+
if (!agentKey && config.keys.private_key_ref) env["MOLTNET_PRIVATE_KEY"] = await resolveIdentitySeed(config, this.options.externalSecretProviders);
|
|
8053
|
+
} catch {
|
|
8054
|
+
throw new ServeRunError("invalid_spec", `external credentials for "${activation.alias}" could not be projected`);
|
|
8055
|
+
}
|
|
8056
|
+
}
|
|
8057
|
+
env["MOLTNET_EXPECTED_IDENTITY_ID"] = activation.identityId;
|
|
8058
|
+
env["MOLTNET_EXPECTED_PUBLIC_KEY"] = activation.publicKey;
|
|
8059
|
+
env["MOLTNET_EXPECTED_FINGERPRINT"] = activation.fingerprint;
|
|
8060
|
+
env["MOLTNET_SUPERVISED_RUN"] = "1";
|
|
8061
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
8062
|
+
if (!provider.apiKeyRef) continue;
|
|
8063
|
+
let value;
|
|
8064
|
+
try {
|
|
8065
|
+
value = await this.options.secretProviders.resolve(parseSecretReferenceString(provider.apiKeyRef));
|
|
8066
|
+
} catch {
|
|
8067
|
+
throw new ServeRunError("invalid_spec", `provider "${providerId}" API key could not be resolved`);
|
|
8068
|
+
}
|
|
8069
|
+
env[provider.envName] = value;
|
|
8070
|
+
}
|
|
8071
|
+
return {
|
|
8072
|
+
args,
|
|
8073
|
+
env,
|
|
8074
|
+
cwd: target.cwd
|
|
8075
|
+
};
|
|
8076
|
+
}
|
|
8077
|
+
async start(spec, signal) {
|
|
8078
|
+
validateRunSpec(spec);
|
|
8079
|
+
const releaseStart = this.reserveStart(spec.agent);
|
|
8080
|
+
try {
|
|
8081
|
+
return await this.startReserved(spec, signal);
|
|
8082
|
+
} finally {
|
|
8083
|
+
releaseStart();
|
|
8084
|
+
}
|
|
8085
|
+
}
|
|
8086
|
+
async startReserved(spec, signal) {
|
|
8087
|
+
this.assertStartOpen(signal);
|
|
8088
|
+
const agent = await (this.options.verifyActivationImpl ?? verifyAgentActivation)(this.store, spec.agent, this.options.secretProviders, this.options.externalSecretProviders, void 0, signal);
|
|
8089
|
+
this.assertStartOpen(signal);
|
|
8090
|
+
const id = `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
8091
|
+
const runDir = this.store.runDir(id);
|
|
8092
|
+
const piDir = join(runDir, "pi");
|
|
8093
|
+
const providers = this.store.readProviders();
|
|
8094
|
+
const { args, env, cwd } = await this.prepare(spec, agent, piDir, providers);
|
|
8095
|
+
this.assertStartOpen(signal);
|
|
8096
|
+
let child;
|
|
8097
|
+
let logStream;
|
|
8098
|
+
let logLimiter;
|
|
8099
|
+
try {
|
|
8100
|
+
const { logPath } = this.store.createRunDir(id);
|
|
8101
|
+
for (const dir of [
|
|
8102
|
+
env.HOME,
|
|
8103
|
+
env.XDG_CACHE_HOME,
|
|
8104
|
+
env.XDG_CONFIG_HOME,
|
|
8105
|
+
env.XDG_DATA_HOME
|
|
8106
|
+
]) mkdirSync(dir, {
|
|
8107
|
+
recursive: true,
|
|
8108
|
+
mode: 448
|
|
8109
|
+
});
|
|
8110
|
+
writePiConfig({
|
|
8111
|
+
piDir,
|
|
8112
|
+
providers: Object.fromEntries(Object.entries(providers).map(([providerId, provider]) => [providerId, {
|
|
8113
|
+
api: provider.api,
|
|
8114
|
+
...provider.apiKeyRef ? { apiKeyEnvRef: `$${provider.envName}` } : {},
|
|
8115
|
+
baseUrl: provider.baseUrl,
|
|
8116
|
+
models: provider.models
|
|
8117
|
+
}]))
|
|
8118
|
+
});
|
|
8119
|
+
const entry = this.entrypoint();
|
|
8120
|
+
logStream = createWriteStream(logPath, {
|
|
8121
|
+
fd: openSync(logPath, "a", 384),
|
|
8122
|
+
autoClose: true
|
|
8123
|
+
});
|
|
8124
|
+
logLimiter = createByteLimitTransform(this.options.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES, () => this.log("warn", "serve run log output truncated", {
|
|
8125
|
+
...runContext(id, spec.agent, child),
|
|
8126
|
+
transition: "log_truncated"
|
|
8127
|
+
}));
|
|
8128
|
+
logLimiter.pipe(logStream);
|
|
8129
|
+
logStream.on("error", (error) => {
|
|
8130
|
+
this.log("error", "serve run log stream failed", {
|
|
8131
|
+
...runContext(id, spec.agent, child),
|
|
8132
|
+
transition: "log_failed",
|
|
8133
|
+
...safeRunError(error)
|
|
8134
|
+
});
|
|
8135
|
+
child?.kill("SIGKILL");
|
|
8136
|
+
});
|
|
8137
|
+
const spawnImpl = this.options.spawnImpl ?? spawn;
|
|
8138
|
+
this.assertStartOpen(signal);
|
|
8139
|
+
child = spawnImpl(entry.execPath, [
|
|
8140
|
+
...entry.execArgv,
|
|
8141
|
+
entry.scriptPath,
|
|
8142
|
+
...args
|
|
8143
|
+
], {
|
|
8144
|
+
cwd,
|
|
8145
|
+
env: {
|
|
8146
|
+
...sanitizeChildBaseEnv(this.options.baseEnv),
|
|
8147
|
+
...env
|
|
8148
|
+
},
|
|
8149
|
+
stdio: [
|
|
8150
|
+
"ignore",
|
|
8151
|
+
"pipe",
|
|
8152
|
+
"pipe",
|
|
8153
|
+
"ipc"
|
|
8154
|
+
],
|
|
8155
|
+
detached: false
|
|
8156
|
+
});
|
|
8157
|
+
this.active.set(id, {
|
|
8158
|
+
agent: spec.agent,
|
|
8159
|
+
child,
|
|
8160
|
+
stopRequested: false
|
|
8161
|
+
});
|
|
8162
|
+
child.stdout?.pipe(logLimiter, { end: false });
|
|
8163
|
+
child.stderr?.pipe(logLimiter, { end: false });
|
|
8164
|
+
const record = {
|
|
8165
|
+
...spec,
|
|
8166
|
+
id,
|
|
8167
|
+
status: "running",
|
|
8168
|
+
pid: child.pid,
|
|
8169
|
+
startedAt: (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
8170
|
+
};
|
|
8171
|
+
child.once("exit", (code, signal) => {
|
|
8172
|
+
const activeRun = this.active.get(id);
|
|
8173
|
+
this.active.delete(id);
|
|
8174
|
+
logLimiter?.end();
|
|
8175
|
+
const status = activeRun?.stopRequested ? "stopped" : code === 0 ? "exited" : "failed";
|
|
8176
|
+
this.log(status === "failed" ? "error" : "info", "serve run exited", {
|
|
8177
|
+
...runContext(id, spec.agent, child),
|
|
8178
|
+
transition: status,
|
|
8179
|
+
exitCode: code,
|
|
8180
|
+
signal
|
|
8181
|
+
});
|
|
8182
|
+
this.persistRunCompletion(id, spec.agent, {
|
|
8183
|
+
status,
|
|
8184
|
+
exitCode: code
|
|
8185
|
+
});
|
|
8186
|
+
});
|
|
8187
|
+
child.once("error", (error) => {
|
|
8188
|
+
this.active.delete(id);
|
|
8189
|
+
logLimiter?.end();
|
|
8190
|
+
this.log("error", "serve run child process failed", {
|
|
8191
|
+
...runContext(id, spec.agent, child),
|
|
8192
|
+
transition: "spawn_failed",
|
|
8193
|
+
...safeRunError(error)
|
|
8194
|
+
});
|
|
8195
|
+
this.persistRunCompletion(id, spec.agent, { status: "failed" });
|
|
8196
|
+
});
|
|
8197
|
+
this.store.writeRun(record);
|
|
8198
|
+
this.log("info", "serve run started", {
|
|
8199
|
+
...runContext(id, spec.agent, child),
|
|
8200
|
+
transition: "running"
|
|
8201
|
+
});
|
|
8202
|
+
return record;
|
|
8203
|
+
} catch (cause) {
|
|
8204
|
+
logLimiter?.destroy();
|
|
8205
|
+
logStream?.destroy();
|
|
8206
|
+
if (child && !await terminateChild(child)) throw new AggregateError([cause], `run "${id}" failed to start and its child did not exit after SIGKILL`);
|
|
8207
|
+
this.active.delete(id);
|
|
8208
|
+
rmSync(runDir, {
|
|
8209
|
+
recursive: true,
|
|
8210
|
+
force: true
|
|
8211
|
+
});
|
|
8212
|
+
this.log("error", "serve run failed to start", {
|
|
8213
|
+
...runContext(id, spec.agent, child),
|
|
8214
|
+
transition: "start_failed",
|
|
8215
|
+
...safeRunError(cause)
|
|
8216
|
+
});
|
|
8217
|
+
throw cause;
|
|
8218
|
+
}
|
|
8219
|
+
}
|
|
8220
|
+
stop(id) {
|
|
8221
|
+
const record = this.store.readRun(id);
|
|
8222
|
+
if (!record) throw new ServeRunError("run_not_found", `run "${id}" was not found`);
|
|
8223
|
+
const activeRun = this.active.get(id);
|
|
8224
|
+
if (!activeRun) throw new ServeRunError("run_not_active", `run "${id}" is not running`);
|
|
8225
|
+
activeRun.stopRequested = true;
|
|
8226
|
+
this.log("info", "serve run stop requested", {
|
|
8227
|
+
...runContext(id, record.agent, activeRun.child),
|
|
8228
|
+
transition: "stopping",
|
|
8229
|
+
signal: "SIGTERM"
|
|
8230
|
+
});
|
|
8231
|
+
activeRun.child.kill("SIGTERM");
|
|
8232
|
+
setTimeout(() => {
|
|
8233
|
+
if (this.active.has(id)) activeRun.child.kill("SIGKILL");
|
|
8234
|
+
}, STOP_GRACE_MS).unref();
|
|
8235
|
+
return record;
|
|
8236
|
+
}
|
|
8237
|
+
/** Live status merged over the persisted record. */
|
|
8238
|
+
status(id) {
|
|
8239
|
+
const record = this.store.readRun(id);
|
|
8240
|
+
if (!record) throw new ServeStoreError("not_found", `run "${id}" was not found`);
|
|
8241
|
+
return record;
|
|
8242
|
+
}
|
|
8243
|
+
list(limit = Number.POSITIVE_INFINITY) {
|
|
8244
|
+
if (!Number.isFinite(limit)) return this.store.listRuns();
|
|
8245
|
+
const active = [...this.active.keys()].map((id) => this.store.readRun(id)).filter((record) => record !== null).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
8246
|
+
const activeIds = new Set(active.map((record) => record.id));
|
|
8247
|
+
const history = this.store.listRuns(limit + active.length).filter((record) => !activeIds.has(record.id)).slice(0, limit);
|
|
8248
|
+
return [...active, ...history];
|
|
8249
|
+
}
|
|
8250
|
+
isActive(id) {
|
|
8251
|
+
return this.active.has(id);
|
|
8252
|
+
}
|
|
8253
|
+
async stopAll() {
|
|
8254
|
+
this.closing = true;
|
|
8255
|
+
const waits = [];
|
|
8256
|
+
for (const [id, activeRun] of this.active) {
|
|
8257
|
+
activeRun.stopRequested = true;
|
|
8258
|
+
waits.push(new Promise((resolvePromise, rejectPromise) => {
|
|
8259
|
+
let settled = false;
|
|
8260
|
+
const finish = () => {
|
|
8261
|
+
if (settled) return;
|
|
8262
|
+
settled = true;
|
|
8263
|
+
clearTimeout(killTimer);
|
|
8264
|
+
clearTimeout(forceTimer);
|
|
8265
|
+
resolvePromise();
|
|
8266
|
+
};
|
|
8267
|
+
activeRun.child.once("exit", finish);
|
|
8268
|
+
activeRun.child.kill("SIGTERM");
|
|
8269
|
+
const killTimer = setTimeout(() => {
|
|
8270
|
+
activeRun.child.kill("SIGKILL");
|
|
8271
|
+
}, STOP_GRACE_MS);
|
|
8272
|
+
killTimer.unref();
|
|
8273
|
+
const forceTimer = setTimeout(() => {
|
|
8274
|
+
if (settled) return;
|
|
8275
|
+
settled = true;
|
|
8276
|
+
clearTimeout(killTimer);
|
|
8277
|
+
rejectPromise(/* @__PURE__ */ new Error(`run "${id}" did not exit after SIGKILL`));
|
|
8278
|
+
}, STOP_GRACE_MS + STOP_FORCE_MS);
|
|
8279
|
+
forceTimer.unref();
|
|
8280
|
+
}));
|
|
8281
|
+
}
|
|
8282
|
+
await Promise.all(waits);
|
|
8283
|
+
}
|
|
8284
|
+
forceStopAll() {
|
|
8285
|
+
this.closing = true;
|
|
8286
|
+
for (const [id, activeRun] of this.active) {
|
|
8287
|
+
activeRun.stopRequested = true;
|
|
8288
|
+
this.log("warn", "serve run force stop requested", {
|
|
8289
|
+
...runContext(id, this.store.readRun(id)?.agent ?? "unknown", activeRun.child),
|
|
8290
|
+
transition: "force_stopping",
|
|
8291
|
+
signal: "SIGKILL"
|
|
8292
|
+
});
|
|
8293
|
+
activeRun.child.kill("SIGKILL");
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
reconcileInterruptedRuns() {
|
|
8297
|
+
const endedAt = (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
8298
|
+
for (const record of this.store.listRuns()) {
|
|
8299
|
+
if (record.status !== "running") continue;
|
|
8300
|
+
this.store.writeRun({
|
|
8301
|
+
...record,
|
|
8302
|
+
status: "failed",
|
|
8303
|
+
exitCode: null,
|
|
8304
|
+
endedAt
|
|
8305
|
+
});
|
|
8306
|
+
this.log("warn", "serve run interrupted by supervisor replacement", {
|
|
8307
|
+
runId: record.id,
|
|
8308
|
+
agent: record.agent,
|
|
8309
|
+
pid: record.pid,
|
|
8310
|
+
transition: "interrupted"
|
|
8311
|
+
});
|
|
8312
|
+
}
|
|
8313
|
+
}
|
|
8314
|
+
reserveStart(agent) {
|
|
8315
|
+
if (this.closing) throw new ServeRunError("invalid_spec", "serve is shutting down");
|
|
8316
|
+
const agentStarting = this.startingByAgent.get(agent) ?? 0;
|
|
8317
|
+
const agentActive = [...this.active.values()].filter((run) => run.agent === agent).length;
|
|
8318
|
+
if (this.active.size + this.starting >= (this.options.maxActiveRuns ?? DEFAULT_MAX_ACTIVE_RUNS) || agentActive + agentStarting >= (this.options.maxActiveRunsPerAgent ?? DEFAULT_MAX_ACTIVE_RUNS_PER_AGENT)) throw new ServeRunError("invalid_spec", `active run limit reached for agent "${agent}"`);
|
|
8319
|
+
this.starting += 1;
|
|
8320
|
+
this.startingByAgent.set(agent, agentStarting + 1);
|
|
8321
|
+
return () => {
|
|
8322
|
+
this.starting -= 1;
|
|
8323
|
+
const remaining = (this.startingByAgent.get(agent) ?? 1) - 1;
|
|
8324
|
+
if (remaining > 0) this.startingByAgent.set(agent, remaining);
|
|
8325
|
+
else this.startingByAgent.delete(agent);
|
|
8326
|
+
};
|
|
8327
|
+
}
|
|
8328
|
+
assertStartOpen(signal) {
|
|
8329
|
+
if (this.closing || signal?.aborted) throw new ServeRunError("invalid_spec", "serve is shutting down");
|
|
8330
|
+
}
|
|
8331
|
+
pruneCompletedRuns() {
|
|
8332
|
+
const removed = this.store.pruneCompletedRuns({
|
|
8333
|
+
maxCount: this.options.maxCompletedRuns ?? DEFAULT_MAX_COMPLETED_RUNS,
|
|
8334
|
+
maxAgeMs: this.options.maxRunAgeMs ?? DEFAULT_MAX_RUN_AGE_MS,
|
|
8335
|
+
maxBytes: this.options.maxRunStorageBytes ?? DEFAULT_MAX_RUN_STORAGE_BYTES,
|
|
8336
|
+
now: this.options.now?.() ?? /* @__PURE__ */ new Date()
|
|
8337
|
+
});
|
|
8338
|
+
if (removed.length > 0) this.log("info", "serve run retention removed completed artifacts", {
|
|
8339
|
+
transition: "pruned",
|
|
8340
|
+
removedCount: removed.length
|
|
8341
|
+
});
|
|
8342
|
+
}
|
|
8343
|
+
persistRunCompletion(id, agent, update) {
|
|
8344
|
+
try {
|
|
8345
|
+
const current = this.store.readRun(id);
|
|
8346
|
+
if (current) this.store.writeRun({
|
|
8347
|
+
...current,
|
|
8348
|
+
...update,
|
|
8349
|
+
endedAt: (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
8350
|
+
});
|
|
8351
|
+
this.pruneCompletedRuns();
|
|
8352
|
+
} catch (error) {
|
|
8353
|
+
this.log("error", "serve run completion persistence failed", {
|
|
8354
|
+
runId: id,
|
|
8355
|
+
agent,
|
|
8356
|
+
transition: "persistence_failed",
|
|
8357
|
+
...safeRunError(error)
|
|
8358
|
+
});
|
|
8359
|
+
}
|
|
8360
|
+
}
|
|
8361
|
+
log(level, message, context) {
|
|
8362
|
+
this.options.logger?.[level](context, message);
|
|
8363
|
+
}
|
|
8364
|
+
};
|
|
8365
|
+
function createByteLimitTransform(limit, onTruncated) {
|
|
8366
|
+
const byteLimit = Math.max(0, Math.floor(limit));
|
|
8367
|
+
const contentLimit = Math.max(0, byteLimit - LOG_TRUNCATION_MARKER.length);
|
|
8368
|
+
let emitted = 0;
|
|
8369
|
+
let truncated = false;
|
|
8370
|
+
return new Transform({ transform(chunk, _encoding, callback) {
|
|
8371
|
+
const remaining = Math.max(0, contentLimit - emitted);
|
|
8372
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
8373
|
+
if (remaining > 0) {
|
|
8374
|
+
const selected = bytes.subarray(0, remaining);
|
|
8375
|
+
emitted += selected.length;
|
|
8376
|
+
this.push(selected);
|
|
8377
|
+
}
|
|
8378
|
+
if (!truncated && bytes.length > remaining) {
|
|
8379
|
+
truncated = true;
|
|
8380
|
+
const marker = LOG_TRUNCATION_MARKER.subarray(0, byteLimit - emitted);
|
|
8381
|
+
emitted += marker.length;
|
|
8382
|
+
this.push(marker);
|
|
8383
|
+
onTruncated();
|
|
8384
|
+
}
|
|
8385
|
+
callback();
|
|
8386
|
+
} });
|
|
8387
|
+
}
|
|
8388
|
+
function runContext(runId, agent, child) {
|
|
8389
|
+
return {
|
|
8390
|
+
runId,
|
|
8391
|
+
agent,
|
|
8392
|
+
pid: child?.pid
|
|
8393
|
+
};
|
|
8394
|
+
}
|
|
8395
|
+
function safeRunError(error) {
|
|
8396
|
+
const code = error?.code;
|
|
8397
|
+
return {
|
|
8398
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
8399
|
+
...typeof code === "string" ? { errorCode: code } : {}
|
|
8400
|
+
};
|
|
8401
|
+
}
|
|
8402
|
+
function sanitizeChildBaseEnv(baseEnv) {
|
|
8403
|
+
return Object.fromEntries(Object.entries(baseEnv).filter(([name]) => INHERITED_ENV_NAMES.has(name) || INHERITED_MOLTNET_ENV_NAMES.has(name)));
|
|
8404
|
+
}
|
|
8405
|
+
async function terminateChild(child) {
|
|
8406
|
+
if (child.exitCode !== null && child.exitCode !== void 0) return true;
|
|
8407
|
+
return new Promise((resolvePromise) => {
|
|
8408
|
+
const timer = setTimeout(() => resolvePromise(false), STOP_FORCE_MS);
|
|
8409
|
+
timer.unref();
|
|
8410
|
+
child.once("exit", () => {
|
|
8411
|
+
clearTimeout(timer);
|
|
8412
|
+
resolvePromise(true);
|
|
8413
|
+
});
|
|
8414
|
+
child.kill("SIGKILL");
|
|
8415
|
+
});
|
|
8416
|
+
}
|
|
8417
|
+
//#endregion
|
|
8418
|
+
//#region src/lib/serve/serve-lock.ts
|
|
8419
|
+
var ServeLockError = class extends Error {
|
|
8420
|
+
name = "ServeLockError";
|
|
8421
|
+
constructor(code, message, options) {
|
|
8422
|
+
super(message, options);
|
|
8423
|
+
this.code = code;
|
|
8424
|
+
}
|
|
8425
|
+
};
|
|
8426
|
+
/**
|
|
8427
|
+
* Acquire the per-root singleton lock. `proper-lockfile` uses an atomic lock
|
|
8428
|
+
* directory at exactly `<root>/serve.lock`, recovers stale owners, and keeps
|
|
8429
|
+
* the mtime fresh while the supervisor is alive.
|
|
8430
|
+
*/
|
|
8431
|
+
async function acquireServeLock(root, options = {}) {
|
|
8432
|
+
const path = join(root, "serve.lock");
|
|
8433
|
+
let releaseLock;
|
|
8434
|
+
try {
|
|
8435
|
+
releaseLock = await lock(root, {
|
|
8436
|
+
lockfilePath: path,
|
|
8437
|
+
realpath: false,
|
|
8438
|
+
retries: 0,
|
|
8439
|
+
...options.staleMs === void 0 ? {} : { stale: options.staleMs },
|
|
8440
|
+
...options.updateMs === void 0 ? {} : { update: options.updateMs },
|
|
8441
|
+
onCompromised: (cause) => {
|
|
8442
|
+
const error = new ServeLockError("compromised", `serve lock ${path} was compromised: ${cause.message}`, { cause });
|
|
8443
|
+
if (options.onCompromised) {
|
|
8444
|
+
options.onCompromised(error);
|
|
8445
|
+
return;
|
|
8446
|
+
}
|
|
8447
|
+
throw error;
|
|
8448
|
+
}
|
|
8449
|
+
});
|
|
8450
|
+
} catch (cause) {
|
|
8451
|
+
if (cause.code === "ELOCKED") throw new ServeLockError("held", `another moltnet-agent serve process already owns ${path}`, { cause });
|
|
8452
|
+
throw new ServeLockError("failed", `could not acquire serve lock ${path}: ${cause.message}`, { cause });
|
|
8453
|
+
}
|
|
8454
|
+
let released = false;
|
|
8455
|
+
return {
|
|
8456
|
+
path,
|
|
8457
|
+
async release() {
|
|
8458
|
+
if (released) return;
|
|
8459
|
+
released = true;
|
|
8460
|
+
await releaseLock();
|
|
8461
|
+
}
|
|
8462
|
+
};
|
|
8463
|
+
}
|
|
8464
|
+
/** Always release after normal completion or startup/runtime failure. */
|
|
8465
|
+
async function withServeLock(root, work, options) {
|
|
8466
|
+
const held = await acquireServeLock(root, options);
|
|
8467
|
+
try {
|
|
8468
|
+
return await work();
|
|
8469
|
+
} finally {
|
|
8470
|
+
await held.release();
|
|
8471
|
+
}
|
|
8472
|
+
}
|
|
8473
|
+
//#endregion
|
|
8474
|
+
//#region src/lib/serve/server.ts
|
|
8475
|
+
/**
|
|
8476
|
+
* HTTP surface of `moltnet-agent serve` (#2061), built on the shared
|
|
8477
|
+
* loopback-companion security profile (#2066): loopback Host enforcement,
|
|
8478
|
+
* exact-origin CORS, Fetch-Metadata guards, strict JSON parsing.
|
|
8479
|
+
*
|
|
8480
|
+
* Everything under `/v1` except the pairing bootstrap requires a paired
|
|
8481
|
+
* origin: the `x-moltnet-serve-token` header must verify against the
|
|
8482
|
+
* origin-bound token issued by the one-click pairing ceremony.
|
|
8483
|
+
*/
|
|
8484
|
+
var SERVE_TOKEN_HEADER = "x-moltnet-serve-token";
|
|
8485
|
+
var BODY_LIMIT = 64 * 1024;
|
|
8486
|
+
var LOG_POLL_INTERVAL_MS = 500;
|
|
8487
|
+
var LOG_STREAM_MAX_DURATION_MS = 3600 * 1e3;
|
|
8488
|
+
var MAX_LOG_STREAMS = 32;
|
|
8489
|
+
var LOG_READ_LIMIT_BYTES = 256 * 1024;
|
|
8490
|
+
var RUN_HISTORY_LIMIT = 100;
|
|
8491
|
+
var RATE_LIMIT_MAX = 120;
|
|
8492
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
8493
|
+
async function readServeLogDelta(handle, state, limit = LOG_READ_LIMIT_BYTES) {
|
|
8494
|
+
const info = await handle.stat();
|
|
8495
|
+
if (!info.isFile() || info.size <= state.offset) return {
|
|
8496
|
+
lines: [],
|
|
8497
|
+
omitted: false
|
|
8498
|
+
};
|
|
8499
|
+
const size = info.size;
|
|
8500
|
+
const start = Math.max(state.offset, size - limit);
|
|
8501
|
+
let omitted = start > state.offset;
|
|
8502
|
+
const buffer = Buffer.alloc(size - start);
|
|
8503
|
+
let totalRead = 0;
|
|
8504
|
+
while (totalRead < buffer.length) {
|
|
8505
|
+
const { bytesRead } = await handle.read(buffer, totalRead, buffer.length - totalRead, start + totalRead);
|
|
8506
|
+
if (bytesRead === 0) break;
|
|
8507
|
+
totalRead += bytesRead;
|
|
8508
|
+
}
|
|
8509
|
+
state.offset = start + totalRead;
|
|
8510
|
+
if (omitted) {
|
|
8511
|
+
state.fragment = "";
|
|
8512
|
+
state.decoder = new StringDecoder("utf8");
|
|
8513
|
+
state.discardingLine = true;
|
|
8514
|
+
}
|
|
8515
|
+
state.decoder ??= new StringDecoder("utf8");
|
|
8516
|
+
let text = state.fragment + state.decoder.write(buffer.subarray(0, totalRead));
|
|
8517
|
+
state.fragment = "";
|
|
8518
|
+
if (state.discardingLine) {
|
|
8519
|
+
const boundary = text.indexOf("\n");
|
|
8520
|
+
if (boundary === -1) return {
|
|
8521
|
+
lines: [],
|
|
8522
|
+
omitted
|
|
8523
|
+
};
|
|
8524
|
+
text = text.slice(boundary + 1);
|
|
8525
|
+
state.discardingLine = false;
|
|
8526
|
+
}
|
|
8527
|
+
const parts = text.split("\n");
|
|
8528
|
+
const fragment = parts.pop() ?? "";
|
|
8529
|
+
const lines = [];
|
|
8530
|
+
for (const line of parts) if (line.length > limit) omitted = true;
|
|
8531
|
+
else if (line.length > 0) lines.push(line);
|
|
8532
|
+
if (fragment.length > limit) {
|
|
8533
|
+
state.discardingLine = true;
|
|
8534
|
+
omitted = true;
|
|
8535
|
+
} else state.fragment = fragment;
|
|
8536
|
+
return {
|
|
8537
|
+
lines,
|
|
8538
|
+
omitted
|
|
8539
|
+
};
|
|
8540
|
+
}
|
|
8541
|
+
var ServeHttpError = class extends Error {
|
|
8542
|
+
name = "ServeHttpError";
|
|
8543
|
+
constructor(statusCode, code, message) {
|
|
8544
|
+
super(message);
|
|
8545
|
+
this.statusCode = statusCode;
|
|
8546
|
+
this.code = code;
|
|
8547
|
+
}
|
|
8548
|
+
};
|
|
8549
|
+
function requireBody(request) {
|
|
8550
|
+
const body = request.body;
|
|
8551
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new ServeHttpError(400, "invalid_body", "JSON object body required");
|
|
8552
|
+
return body;
|
|
8553
|
+
}
|
|
8554
|
+
function requireString(body, field) {
|
|
8555
|
+
const value = body[field];
|
|
8556
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new ServeHttpError(400, "invalid_body", `"${field}" must be a non-empty string`);
|
|
8557
|
+
return value.trim();
|
|
8558
|
+
}
|
|
8559
|
+
function optionalString(body, field) {
|
|
8560
|
+
const value = body[field];
|
|
8561
|
+
if (value === void 0 || value === null) return void 0;
|
|
8562
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new ServeHttpError(400, "invalid_body", `"${field}" must be a non-empty string when present`);
|
|
8563
|
+
return value.trim();
|
|
8564
|
+
}
|
|
8565
|
+
function stringArray(body, field) {
|
|
8566
|
+
const value = body[field];
|
|
8567
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new ServeHttpError(400, "invalid_body", `"${field}" must be a non-empty string array`);
|
|
8568
|
+
return value;
|
|
8569
|
+
}
|
|
8570
|
+
function requestOperationSignal(request, shutdownSignal) {
|
|
8571
|
+
const disconnected = new AbortController();
|
|
8572
|
+
if (request.raw.aborted) disconnected.abort();
|
|
8573
|
+
else request.raw.once("aborted", () => disconnected.abort());
|
|
8574
|
+
return shutdownSignal ? AbortSignal.any([disconnected.signal, shutdownSignal]) : disconnected.signal;
|
|
8575
|
+
}
|
|
8576
|
+
function buildServeServer(options) {
|
|
8577
|
+
const { pairing } = options;
|
|
8578
|
+
const app = options.logger ? Fastify({
|
|
8579
|
+
bodyLimit: BODY_LIMIT,
|
|
8580
|
+
loggerInstance: options.logger
|
|
8581
|
+
}) : Fastify({ bodyLimit: BODY_LIMIT });
|
|
8582
|
+
registerLoopbackSecurity(app, {
|
|
8583
|
+
allowedOrigins: options.allowedOrigins,
|
|
8584
|
+
...options.selfOrigin ? { selfOrigins: [options.selfOrigin] } : {},
|
|
8585
|
+
allowedHeaders: [SERVE_TOKEN_HEADER],
|
|
8586
|
+
methods: [
|
|
8587
|
+
"GET",
|
|
8588
|
+
"POST",
|
|
8589
|
+
"PUT",
|
|
8590
|
+
"DELETE",
|
|
8591
|
+
"OPTIONS"
|
|
8592
|
+
]
|
|
8593
|
+
});
|
|
8594
|
+
app.register(rateLimit, {
|
|
8595
|
+
global: false,
|
|
8596
|
+
max: options.rateLimitMax ?? RATE_LIMIT_MAX,
|
|
8597
|
+
timeWindow: RATE_LIMIT_WINDOW_MS,
|
|
8598
|
+
errorResponseBuilder: () => new ServeHttpError(429, "rate_limited", "Too many requests"),
|
|
8599
|
+
keyGenerator: (request) => {
|
|
8600
|
+
const origin = request.headers.origin;
|
|
8601
|
+
return isConfiguredOrigin(origin, options) ? `origin:${origin}` : `ip:${request.ip}`;
|
|
8602
|
+
}
|
|
8603
|
+
});
|
|
8604
|
+
app.after(() => app.addHook("onRequest", app.rateLimit()));
|
|
8605
|
+
app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "buffer" }, (_request, body, done) => {
|
|
8606
|
+
try {
|
|
8607
|
+
const form = new TextDecoder("utf-8", { fatal: true }).decode(typeof body === "string" ? Buffer.from(body) : body);
|
|
8608
|
+
done(null, new URLSearchParams(form));
|
|
8609
|
+
} catch {
|
|
8610
|
+
done(new ServeHttpError(400, "invalid_body", "Form body is invalid"), void 0);
|
|
8611
|
+
}
|
|
8612
|
+
});
|
|
8613
|
+
const requirePairedOrigin = (request) => {
|
|
8614
|
+
const origin = requireOriginHeader(request.headers);
|
|
8615
|
+
const token = request.headers[SERVE_TOKEN_HEADER];
|
|
8616
|
+
if (typeof token !== "string" || token.length === 0) throw new ServeHttpError(401, "pairing_required", "Pairing token is required");
|
|
8617
|
+
pairing.verify(origin, token);
|
|
8618
|
+
return origin;
|
|
8619
|
+
};
|
|
8620
|
+
app.get("/health", async () => ({ status: "ok" }));
|
|
8621
|
+
registerPairingRoutes(app, pairing);
|
|
8622
|
+
registerStatusRoute(app, options, requirePairedOrigin);
|
|
8623
|
+
registerAgentRoutes(app, options, requirePairedOrigin);
|
|
8624
|
+
registerProviderRoutes(app, options, requirePairedOrigin);
|
|
8625
|
+
registerRunRoutes(app, options, requirePairedOrigin);
|
|
8626
|
+
app.setNotFoundHandler(async (_request, reply) => reply.code(404).send({
|
|
8627
|
+
code: "not_found",
|
|
8628
|
+
message: "Route is not available"
|
|
8629
|
+
}));
|
|
8630
|
+
app.setErrorHandler(async (error, request, reply) => {
|
|
8631
|
+
const { statusCode, code, message } = normalizeServeError(error);
|
|
8632
|
+
if (statusCode === 500) request.log.error({
|
|
8633
|
+
...safeErrorContext(error),
|
|
8634
|
+
code: "serve_request_failed",
|
|
8635
|
+
method: request.method,
|
|
8636
|
+
route: request.routeOptions.url
|
|
8637
|
+
}, "Serve request failed");
|
|
8638
|
+
return reply.code(statusCode).send({
|
|
8639
|
+
code,
|
|
8640
|
+
message
|
|
8641
|
+
});
|
|
8642
|
+
});
|
|
8643
|
+
return app;
|
|
8644
|
+
}
|
|
8645
|
+
function registerPairingRoutes(app, pairing) {
|
|
8646
|
+
app.post("/v1/pairings", async (request, reply) => {
|
|
8647
|
+
const origin = requireOriginHeader(request.headers);
|
|
8648
|
+
return reply.code(201).send(pairing.start(origin));
|
|
8649
|
+
});
|
|
8650
|
+
app.get("/pairings/:pairingId", async (request, reply) => {
|
|
8651
|
+
assertNavigationRequest(request.headers);
|
|
8652
|
+
const { pairingId } = request.params;
|
|
8653
|
+
const approval = pairing.approval(pairingId);
|
|
8654
|
+
return reply.type("text/html; charset=utf-8").send(renderPairingApprovalPage({
|
|
8655
|
+
pairingId,
|
|
8656
|
+
origin: approval.origin,
|
|
8657
|
+
confirmToken: approval.confirmToken
|
|
8658
|
+
}));
|
|
8659
|
+
});
|
|
8660
|
+
app.post("/pairings/:pairingId/confirm", async (request, reply) => {
|
|
8661
|
+
rejectExplicitCrossSite(request.headers);
|
|
8662
|
+
const { pairingId } = request.params;
|
|
8663
|
+
if (!(request.body instanceof URLSearchParams)) throw new ServeHttpError(400, "invalid_body", "Confirmation form is invalid");
|
|
8664
|
+
const { origin } = pairing.confirm(pairingId, request.body.get("confirmToken") ?? "");
|
|
8665
|
+
return reply.type("text/html; charset=utf-8").send(renderPairingResultPage({
|
|
8666
|
+
title: "Connection approved",
|
|
8667
|
+
message: `${origin} can now manage local MoltNet agents on this machine.`
|
|
8668
|
+
}));
|
|
8669
|
+
});
|
|
8670
|
+
app.post("/v1/pairings/:pairingId/claim", async (request) => {
|
|
8671
|
+
const origin = requireOriginHeader(request.headers);
|
|
8672
|
+
const { pairingId } = request.params;
|
|
8673
|
+
return pairing.claim(pairingId, origin);
|
|
8674
|
+
});
|
|
8675
|
+
}
|
|
8676
|
+
function registerStatusRoute(app, options, requirePairedOrigin) {
|
|
8677
|
+
const { store, runs } = options;
|
|
8678
|
+
app.get("/v1/status", async (request) => {
|
|
8679
|
+
requirePairedOrigin(request);
|
|
8680
|
+
return {
|
|
8681
|
+
version: options.version,
|
|
8682
|
+
platform: process.platform,
|
|
8683
|
+
agents: store.listActivations().map((activation) => publicAgentView(store, activation)),
|
|
8684
|
+
providers: Object.fromEntries(Object.entries(store.readProviders()).map(([id, provider]) => [id, providerView(provider)])),
|
|
8685
|
+
runs: runViews(runs)
|
|
8686
|
+
};
|
|
8687
|
+
});
|
|
8688
|
+
}
|
|
8689
|
+
function registerAgentRoutes(app, options, requirePairedOrigin) {
|
|
8690
|
+
const { store } = options;
|
|
8691
|
+
app.get("/v1/agents", async (request) => {
|
|
8692
|
+
requirePairedOrigin(request);
|
|
8693
|
+
return store.listActivations().map((activation) => publicAgentView(store, activation));
|
|
8694
|
+
});
|
|
8695
|
+
app.post("/v1/agents", async (request, reply) => {
|
|
8696
|
+
requirePairedOrigin(request);
|
|
8697
|
+
const body = requireBody(request);
|
|
8698
|
+
const signal = requestOperationSignal(request, options.shutdownSignal);
|
|
8699
|
+
const kind = requireString(body, "kind");
|
|
8700
|
+
if (kind === "managed") {
|
|
8701
|
+
const enrollmentToken = optionalString(body, "enrollmentToken");
|
|
8702
|
+
const entry = await createManagedAgent(store, options.secrets, {
|
|
8703
|
+
name: requireString(body, "name"),
|
|
8704
|
+
apiUrl: options.defaultApiUrl,
|
|
8705
|
+
...enrollmentToken ? { enrollmentToken } : {},
|
|
8706
|
+
signal
|
|
8707
|
+
});
|
|
8708
|
+
return reply.code(201).send(publicAgentView(store, entry.activation));
|
|
8709
|
+
}
|
|
8710
|
+
if (kind === "external") {
|
|
8711
|
+
const apiUrl = optionalString(body, "apiUrl");
|
|
8712
|
+
const entry = await attachExternalAgent(store, options.externalSecretProviders, {
|
|
8713
|
+
name: requireString(body, "name"),
|
|
8714
|
+
configDir: requireString(body, "configDir"),
|
|
8715
|
+
...apiUrl ? { apiUrl } : {},
|
|
8716
|
+
signal
|
|
8717
|
+
});
|
|
8718
|
+
return reply.code(201).send(publicAgentView(store, entry.activation));
|
|
8719
|
+
}
|
|
8720
|
+
throw new ServeHttpError(400, "invalid_body", "\"kind\" must be \"managed\" or \"external\"");
|
|
8721
|
+
});
|
|
8722
|
+
app.post("/v1/agents/:agentName/reconcile", async (request) => {
|
|
8723
|
+
requirePairedOrigin(request);
|
|
8724
|
+
const { agentName } = request.params;
|
|
8725
|
+
const action = requireString(requireBody(request), "action");
|
|
8726
|
+
if (action !== "resume" && action !== "abandon") throw new ServeHttpError(400, "invalid_body", "\"action\" must be \"resume\" or \"abandon\"");
|
|
8727
|
+
const reconciled = await reconcileManagedRegistration(store, options.secrets, agentName, action, void 0, requestOperationSignal(request, options.shutdownSignal));
|
|
8728
|
+
return reconciled ? publicAgentView(store, reconciled.activation) : { abandoned: true };
|
|
8729
|
+
});
|
|
8730
|
+
}
|
|
8731
|
+
function registerProviderRoutes(app, options, requirePairedOrigin) {
|
|
8732
|
+
const { store } = options;
|
|
8733
|
+
let mutationQueue = Promise.resolve();
|
|
8734
|
+
const serialize = (mutation) => {
|
|
8735
|
+
const result = mutationQueue.then(mutation, mutation);
|
|
8736
|
+
mutationQueue = result.then(() => void 0, () => void 0);
|
|
8737
|
+
return result;
|
|
8738
|
+
};
|
|
8739
|
+
app.get("/v1/providers", async (request) => {
|
|
8740
|
+
requirePairedOrigin(request);
|
|
8741
|
+
return Object.fromEntries(Object.entries(store.readProviders()).map(([id, provider]) => [id, providerView(provider)]));
|
|
8742
|
+
});
|
|
8743
|
+
app.put("/v1/providers/:providerId", async (request, reply) => {
|
|
8744
|
+
requirePairedOrigin(request);
|
|
8745
|
+
const { providerId: rawProviderId } = request.params;
|
|
8746
|
+
const providerId = assertProviderId(rawProviderId);
|
|
8747
|
+
const body = requireBody(request);
|
|
8748
|
+
const entry = {
|
|
8749
|
+
api: requireString(body, "api"),
|
|
8750
|
+
baseUrl: requireString(body, "baseUrl"),
|
|
8751
|
+
envName: assertProviderEnvName(providerId, requireString(body, "envName")),
|
|
8752
|
+
models: stringArray(body, "models")
|
|
8753
|
+
};
|
|
8754
|
+
const apiKey = optionalString(body, "apiKey");
|
|
8755
|
+
await serialize(async () => {
|
|
8756
|
+
const providers = store.readProviders();
|
|
8757
|
+
if (apiKey) {
|
|
8758
|
+
const key = `pi-provider/${providerId}`;
|
|
8759
|
+
await options.secrets.write(key, apiKey);
|
|
8760
|
+
entry.apiKeyRef = formatSecretReferenceString({
|
|
8761
|
+
provider: FILE_SECRET_PROVIDER,
|
|
8762
|
+
key
|
|
8763
|
+
});
|
|
8764
|
+
} else if (providers[providerId]?.apiKeyRef && providers[providerId].baseUrl === entry.baseUrl) entry.apiKeyRef = providers[providerId].apiKeyRef;
|
|
8765
|
+
providers[providerId] = entry;
|
|
8766
|
+
store.writeProviders(providers);
|
|
8767
|
+
});
|
|
8768
|
+
return reply.code(200).send(providerView(entry));
|
|
8769
|
+
});
|
|
8770
|
+
}
|
|
8771
|
+
function runViews(runs) {
|
|
8772
|
+
return runs.list(RUN_HISTORY_LIMIT).map((record) => ({
|
|
8773
|
+
...record,
|
|
8774
|
+
active: runs.isActive(record.id)
|
|
8775
|
+
}));
|
|
8776
|
+
}
|
|
8777
|
+
function registerRunRoutes(app, options, requirePairedOrigin) {
|
|
8778
|
+
const { runs } = options;
|
|
8779
|
+
app.get("/v1/runs", async (request) => {
|
|
8780
|
+
requirePairedOrigin(request);
|
|
8781
|
+
return runViews(runs);
|
|
8782
|
+
});
|
|
8783
|
+
app.post("/v1/runs", async (request, reply) => {
|
|
8784
|
+
requirePairedOrigin(request);
|
|
8785
|
+
const body = requireBody(request);
|
|
8786
|
+
const record = await runs.start({
|
|
8787
|
+
agent: requireString(body, "agent"),
|
|
8788
|
+
teamId: requireString(body, "teamId"),
|
|
8789
|
+
profiles: stringArray(body, "profiles"),
|
|
8790
|
+
taskTypes: stringArray(body, "taskTypes"),
|
|
8791
|
+
mode: requireString(body, "mode")
|
|
8792
|
+
}, requestOperationSignal(request, options.shutdownSignal));
|
|
8793
|
+
return reply.code(201).send(record);
|
|
8794
|
+
});
|
|
8795
|
+
app.delete("/v1/runs/:runId", async (request) => {
|
|
8796
|
+
requirePairedOrigin(request);
|
|
8797
|
+
const { runId } = request.params;
|
|
8798
|
+
return runs.stop(runId);
|
|
8799
|
+
});
|
|
8800
|
+
registerRunLogRoute(app, options, requirePairedOrigin);
|
|
8801
|
+
}
|
|
8802
|
+
function registerRunLogRoute(app, options, requirePairedOrigin) {
|
|
8803
|
+
const { runs, store } = options;
|
|
8804
|
+
let openStreams = 0;
|
|
8805
|
+
app.get("/v1/runs/:runId/logs", async (request, reply) => {
|
|
8806
|
+
requirePairedOrigin(request);
|
|
8807
|
+
const { runId } = request.params;
|
|
8808
|
+
const record = runs.status(runId);
|
|
8809
|
+
store.resolveRunLogPath(record.id);
|
|
8810
|
+
if (openStreams >= MAX_LOG_STREAMS) throw new ServeHttpError(429, "rate_limited", "Too many concurrent log streams");
|
|
8811
|
+
openStreams += 1;
|
|
8812
|
+
reply.raw.writeHead(200, {
|
|
8813
|
+
"content-type": "text/event-stream",
|
|
8814
|
+
"cache-control": "no-store",
|
|
8815
|
+
connection: "keep-alive",
|
|
8816
|
+
...corsHeadersFor(request, options)
|
|
8817
|
+
});
|
|
8818
|
+
const readState = {
|
|
8819
|
+
offset: 0,
|
|
8820
|
+
fragment: ""
|
|
8821
|
+
};
|
|
8822
|
+
let closed = false;
|
|
8823
|
+
let pollTimer;
|
|
8824
|
+
const durationTimer = setTimeout(() => finish(), LOG_STREAM_MAX_DURATION_MS);
|
|
8825
|
+
durationTimer.unref();
|
|
8826
|
+
function finish(destroy = false) {
|
|
8827
|
+
if (closed) return;
|
|
8828
|
+
closed = true;
|
|
8829
|
+
openStreams -= 1;
|
|
8830
|
+
if (pollTimer) clearTimeout(pollTimer);
|
|
8831
|
+
clearTimeout(durationTimer);
|
|
8832
|
+
if (destroy) reply.raw.destroy();
|
|
8833
|
+
else reply.raw.end();
|
|
8834
|
+
}
|
|
8835
|
+
const writeData = async (line) => {
|
|
8836
|
+
if (closed || reply.raw.write(`data: ${line}\n\n`)) return;
|
|
8837
|
+
await new Promise((resolvePromise) => {
|
|
8838
|
+
const done = () => {
|
|
8839
|
+
reply.raw.off("drain", done);
|
|
8840
|
+
reply.raw.off("close", done);
|
|
8841
|
+
resolvePromise();
|
|
8842
|
+
};
|
|
8843
|
+
reply.raw.once("drain", done);
|
|
8844
|
+
reply.raw.once("close", done);
|
|
8845
|
+
});
|
|
8846
|
+
};
|
|
8847
|
+
const push = async () => {
|
|
8848
|
+
const handle = await open(store.resolveRunLogPath(record.id), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
8849
|
+
try {
|
|
8850
|
+
const { lines, omitted } = await readServeLogDelta(handle, readState);
|
|
8851
|
+
if (omitted) await writeData("[older log output omitted]");
|
|
8852
|
+
for (const line of lines) await writeData(line);
|
|
8853
|
+
} finally {
|
|
8854
|
+
await handle.close();
|
|
8855
|
+
}
|
|
8856
|
+
};
|
|
8857
|
+
const fail = (error) => {
|
|
8858
|
+
if (closed) return;
|
|
8859
|
+
request.log.warn({
|
|
8860
|
+
...safeErrorContext(error),
|
|
8861
|
+
code: "serve_log_tail_failed",
|
|
8862
|
+
runId
|
|
8863
|
+
}, "serve log tail failed");
|
|
8864
|
+
finish(true);
|
|
8865
|
+
};
|
|
8866
|
+
const schedule = () => {
|
|
8867
|
+
if (closed) return;
|
|
8868
|
+
pollTimer = setTimeout(() => {
|
|
8869
|
+
push().then(() => {
|
|
8870
|
+
if (runs.isActive(record.id)) schedule();
|
|
8871
|
+
else finish();
|
|
8872
|
+
}).catch(fail);
|
|
8873
|
+
}, LOG_POLL_INTERVAL_MS);
|
|
8874
|
+
pollTimer.unref();
|
|
8875
|
+
};
|
|
8876
|
+
request.raw.on("close", () => finish());
|
|
8877
|
+
try {
|
|
8878
|
+
await push();
|
|
8879
|
+
if (runs.isActive(record.id)) schedule();
|
|
8880
|
+
else finish();
|
|
8881
|
+
} catch (error) {
|
|
8882
|
+
fail(error);
|
|
8883
|
+
}
|
|
8884
|
+
return reply;
|
|
8885
|
+
});
|
|
8886
|
+
}
|
|
8887
|
+
function safeErrorContext(error) {
|
|
8888
|
+
const context = { errorType: error instanceof Error ? error.name : typeof error };
|
|
8889
|
+
const applicationCode = safeErrorToken(error?.code);
|
|
8890
|
+
if (applicationCode) context["applicationCode"] = applicationCode;
|
|
8891
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
8892
|
+
const fsCode = safeErrorToken(cause?.code);
|
|
8893
|
+
const syscall = safeErrorToken(cause?.syscall);
|
|
8894
|
+
if (fsCode) context["fsCode"] = fsCode;
|
|
8895
|
+
if (syscall) context["syscall"] = syscall;
|
|
8896
|
+
return context;
|
|
8897
|
+
}
|
|
8898
|
+
function safeErrorToken(value) {
|
|
8899
|
+
return typeof value === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(value) ? value : void 0;
|
|
8900
|
+
}
|
|
8901
|
+
function providerView(provider) {
|
|
8902
|
+
return {
|
|
8903
|
+
api: provider.api,
|
|
8904
|
+
baseUrl: provider.baseUrl,
|
|
8905
|
+
envName: provider.envName,
|
|
8906
|
+
models: provider.models,
|
|
8907
|
+
hasApiKey: Boolean(provider.apiKeyRef)
|
|
8908
|
+
};
|
|
8909
|
+
}
|
|
8910
|
+
function corsHeadersFor(request, options) {
|
|
8911
|
+
const origin = request.headers.origin;
|
|
8912
|
+
if (isConfiguredOrigin(origin, options)) return {
|
|
8913
|
+
"access-control-allow-origin": origin,
|
|
8914
|
+
vary: "origin"
|
|
8915
|
+
};
|
|
8916
|
+
return {};
|
|
8917
|
+
}
|
|
8918
|
+
function isConfiguredOrigin(origin, options) {
|
|
8919
|
+
return typeof origin === "string" && (options.allowedOrigins.includes(origin) || origin === options.selfOrigin);
|
|
8920
|
+
}
|
|
8921
|
+
function normalizeServeError(error) {
|
|
8922
|
+
if (error instanceof ServeHttpError) return {
|
|
8923
|
+
statusCode: error.statusCode,
|
|
8924
|
+
code: error.code,
|
|
8925
|
+
message: error.message
|
|
8926
|
+
};
|
|
8927
|
+
if (isLoopbackViolation(error)) return {
|
|
8928
|
+
statusCode: error.kind === "origin_required" || error.kind === "origin_invalid" || error.kind === "origin_not_allowed" ? 403 : 400,
|
|
8929
|
+
code: error.kind,
|
|
8930
|
+
message: error.message
|
|
8931
|
+
};
|
|
8932
|
+
if (error instanceof ServePairingError) return {
|
|
8933
|
+
statusCode: error.code === "pairing_not_found" ? 404 : error.code === "pairing_token_invalid" || error.code === "pairing_not_approved" ? 401 : 403,
|
|
8934
|
+
code: error.code,
|
|
8935
|
+
message: error.message
|
|
8936
|
+
};
|
|
8937
|
+
if (error instanceof ServeStoreError) return {
|
|
8938
|
+
statusCode: error.code === "not_found" ? 404 : error.code === "io_error" ? 500 : 400,
|
|
8939
|
+
code: error.code,
|
|
8940
|
+
message: error.message
|
|
8941
|
+
};
|
|
8942
|
+
if (error instanceof ServeRunError) return {
|
|
8943
|
+
statusCode: error.code === "run_not_found" ? 404 : 400,
|
|
8944
|
+
code: error.code,
|
|
8945
|
+
message: error.message
|
|
8946
|
+
};
|
|
8947
|
+
if (error instanceof ServeIdentityError) return {
|
|
8948
|
+
statusCode: error.code === "agent_exists" ? 409 : 400,
|
|
8949
|
+
code: error.code,
|
|
8950
|
+
message: error.message
|
|
8951
|
+
};
|
|
8952
|
+
return {
|
|
8953
|
+
statusCode: 500,
|
|
8954
|
+
code: "internal_error",
|
|
8955
|
+
message: "Request failed"
|
|
8956
|
+
};
|
|
8957
|
+
}
|
|
8958
|
+
//#endregion
|
|
8959
|
+
//#region src/cli/serve.ts
|
|
8960
|
+
/**
|
|
8961
|
+
* `moltnet-agent serve` — per-user loopback supervisor (#2061).
|
|
8962
|
+
*
|
|
8963
|
+
* Starts nothing on its own: it binds 127.0.0.1 and waits for a paired
|
|
8964
|
+
* Console origin to configure agents/providers and start/stop runs.
|
|
8965
|
+
*/
|
|
8966
|
+
var DEFAULT_PORT = 17374;
|
|
8967
|
+
var DEFAULT_ALLOWED_ORIGINS = "https://console.themolt.net";
|
|
8968
|
+
var DEFAULT_API_URL = "https://api.themolt.net";
|
|
8969
|
+
var SHUTDOWN_TIMEOUT_MS = 15e3;
|
|
8970
|
+
async function runServe(argv) {
|
|
8971
|
+
if (isHelpFlag(argv)) {
|
|
8972
|
+
console.log(SERVE_HELP);
|
|
8973
|
+
return 0;
|
|
8974
|
+
}
|
|
8975
|
+
const envConfig = loadServeEnvConfig();
|
|
8976
|
+
const { values } = parseArgs({
|
|
8977
|
+
args: argv,
|
|
8978
|
+
options: {
|
|
8979
|
+
port: { type: "string" },
|
|
8980
|
+
"allowed-origins": { type: "string" },
|
|
8981
|
+
root: { type: "string" },
|
|
8982
|
+
"api-url": { type: "string" }
|
|
8983
|
+
}
|
|
8984
|
+
});
|
|
8985
|
+
const port = Number.parseInt(values.port ?? (envConfig.port || `${DEFAULT_PORT}`), 10);
|
|
8986
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
8987
|
+
console.error(`Invalid --port: ${String(values.port)}`);
|
|
8988
|
+
return 1;
|
|
8989
|
+
}
|
|
8990
|
+
const allowedOrigins = parseAllowedOrigins(values["allowed-origins"] ?? (envConfig.allowedOrigins || DEFAULT_ALLOWED_ORIGINS));
|
|
8991
|
+
const root = values.root ?? resolveServeRoot({
|
|
8992
|
+
root: envConfig.root,
|
|
8993
|
+
xdgConfigHome: envConfig.xdgConfigHome
|
|
8994
|
+
});
|
|
8995
|
+
const defaultApiUrl = values["api-url"] ?? (envConfig.apiUrl || DEFAULT_API_URL);
|
|
8996
|
+
const store = new ServeStore(root).ensure();
|
|
8997
|
+
const { logger, shutdown: shutdownLogger } = createRootLogger({
|
|
8998
|
+
name: "agent-daemon.serve",
|
|
8999
|
+
level: envConfig.logLevel || "info"
|
|
9000
|
+
});
|
|
9001
|
+
try {
|
|
9002
|
+
try {
|
|
9003
|
+
return await withServeLock(root, async () => {
|
|
9004
|
+
const secrets = new FileSecretProvider({
|
|
9005
|
+
root: store.secretsDir,
|
|
9006
|
+
writable: true
|
|
9007
|
+
});
|
|
9008
|
+
const secretProviders = createNodeSecretProviderRegistry().register(secrets);
|
|
9009
|
+
const externalSecretProviders = createNodeSecretProviderRegistry();
|
|
9010
|
+
const pairing = new PairingService();
|
|
9011
|
+
const shutdownController = new AbortController();
|
|
9012
|
+
const runs = new RunManager({
|
|
9013
|
+
store,
|
|
9014
|
+
secretProviders,
|
|
9015
|
+
externalSecretProviders,
|
|
9016
|
+
baseEnv: processEnvSnapshot(),
|
|
9017
|
+
logger
|
|
9018
|
+
});
|
|
9019
|
+
const app = buildServeServer({
|
|
9020
|
+
store,
|
|
9021
|
+
secrets,
|
|
9022
|
+
externalSecretProviders,
|
|
9023
|
+
pairing,
|
|
9024
|
+
runs,
|
|
9025
|
+
allowedOrigins,
|
|
9026
|
+
selfOrigin: `http://127.0.0.1:${port}`,
|
|
9027
|
+
defaultApiUrl,
|
|
9028
|
+
version: "dev",
|
|
9029
|
+
logger,
|
|
9030
|
+
shutdownSignal: shutdownController.signal
|
|
9031
|
+
});
|
|
9032
|
+
try {
|
|
9033
|
+
const address = await app.listen({
|
|
9034
|
+
host: "127.0.0.1",
|
|
9035
|
+
port
|
|
9036
|
+
});
|
|
9037
|
+
console.error(`moltnet-agent serve listening on ${address}`);
|
|
9038
|
+
console.error(`config root: ${root}`);
|
|
9039
|
+
console.error(`allowed origins: ${allowedOrigins.join(", ")}`);
|
|
9040
|
+
console.error("Pair from the Console \"Local runtime\" page; approve the one-click prompt this server opens.");
|
|
9041
|
+
return await waitForServeShutdown(runs, app, shutdownController);
|
|
9042
|
+
} catch (cause) {
|
|
9043
|
+
await app.close().catch(() => void 0);
|
|
9044
|
+
throw cause;
|
|
9045
|
+
}
|
|
9046
|
+
}, { onCompromised: (error) => {
|
|
9047
|
+
console.error(error.message);
|
|
9048
|
+
process.exitCode = 1;
|
|
9049
|
+
process.kill(process.pid, "SIGTERM");
|
|
9050
|
+
} });
|
|
9051
|
+
} catch (cause) {
|
|
9052
|
+
if (cause instanceof ServeLockError) {
|
|
9053
|
+
console.error(cause.message);
|
|
9054
|
+
return 1;
|
|
9055
|
+
}
|
|
9056
|
+
throw cause;
|
|
9057
|
+
}
|
|
9058
|
+
} finally {
|
|
9059
|
+
await shutdownLogger();
|
|
9060
|
+
}
|
|
9061
|
+
}
|
|
9062
|
+
function waitForServeShutdown(runs, app, shutdownController) {
|
|
9063
|
+
return new Promise((resolvePromise) => {
|
|
9064
|
+
let shuttingDown = false;
|
|
9065
|
+
const shutdown = () => {
|
|
9066
|
+
if (shuttingDown) return;
|
|
9067
|
+
shuttingDown = true;
|
|
9068
|
+
shutdownController.abort();
|
|
9069
|
+
(async () => {
|
|
9070
|
+
app.server.closeAllConnections();
|
|
9071
|
+
const cleanupPromise = Promise.allSettled([runs.stopAll(), app.close()]);
|
|
9072
|
+
let timedOut = false;
|
|
9073
|
+
let deadlineTimer;
|
|
9074
|
+
await Promise.race([cleanupPromise, new Promise((resolveDeadline) => {
|
|
9075
|
+
deadlineTimer = setTimeout(() => {
|
|
9076
|
+
timedOut = true;
|
|
9077
|
+
resolveDeadline();
|
|
9078
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
9079
|
+
})]);
|
|
9080
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
9081
|
+
let forcedExitTimer;
|
|
9082
|
+
if (timedOut) {
|
|
9083
|
+
console.error("shutdown cleanup exceeded its 15 second deadline; force-stopping runs");
|
|
9084
|
+
runs.forceStopAll();
|
|
9085
|
+
app.server.closeAllConnections();
|
|
9086
|
+
forcedExitTimer = setTimeout(() => process.exit(1), 2e3);
|
|
9087
|
+
}
|
|
9088
|
+
const results = await cleanupPromise;
|
|
9089
|
+
if (forcedExitTimer) clearTimeout(forcedExitTimer);
|
|
9090
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
9091
|
+
for (const failure of failures) console.error(`shutdown cleanup failed: ${failure.reason.message}`);
|
|
9092
|
+
handlers.dispose();
|
|
9093
|
+
const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
|
|
9094
|
+
resolvePromise(failures.length > 0 ? 1 : exitCode);
|
|
9095
|
+
})();
|
|
9096
|
+
};
|
|
9097
|
+
const handlers = installShutdownSignalHandlers({
|
|
9098
|
+
logDrain: () => console.error("shutting down: stopping runs…"),
|
|
9099
|
+
drain: shutdown
|
|
9100
|
+
});
|
|
9101
|
+
});
|
|
9102
|
+
}
|
|
9103
|
+
//#endregion
|
|
6857
9104
|
//#region src/lib/runtime-session-sync.ts
|
|
6858
9105
|
async function syncRuntimeSessions(deps, input) {
|
|
6859
9106
|
const result = {
|
|
@@ -7037,6 +9284,7 @@ async function runAgentDaemonCli(options) {
|
|
|
7037
9284
|
case "poll": return runPoll(rest, options.runtime);
|
|
7038
9285
|
case "once": return runOnce(rest, options.runtime);
|
|
7039
9286
|
case "drain": return runDrain(rest, options.runtime);
|
|
9287
|
+
case "serve": return runServe(rest);
|
|
7040
9288
|
case "sync-sessions": return runSyncSessions(rest);
|
|
7041
9289
|
case "-h":
|
|
7042
9290
|
case "--help":
|