@themoltnet/agent-daemon 0.47.0 → 0.49.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 +2808 -36
- package/dist/main.js +13 -0
- package/dist/pi.js +2 -2
- package/package.json +16 -8
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, symlinkSync, 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,19 @@ 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 { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
|
34
|
+
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import { Transform, Writable } from "node:stream";
|
|
36
|
+
import { writePiConfig } from "@themoltnet/pi-runtime/pi-config";
|
|
37
|
+
import { homedir } from "node:os";
|
|
38
|
+
import { lock } from "proper-lockfile";
|
|
39
|
+
import { StringDecoder } from "node:string_decoder";
|
|
40
|
+
import rateLimit from "@fastify/rate-limit";
|
|
41
|
+
import Fastify from "fastify";
|
|
33
42
|
import { createGzip } from "node:zlib";
|
|
34
43
|
//#region ../../libs/tasks/src/rubric.ts
|
|
35
44
|
/**
|
|
@@ -3417,6 +3426,9 @@ Commands:
|
|
|
3417
3426
|
once Claim and execute one specific queued task by id, then exit.
|
|
3418
3427
|
drain Poll until the queue has nothing claimable, then exit.
|
|
3419
3428
|
Useful for batch eval runs and demos.
|
|
3429
|
+
serve Loopback supervisor for console-managed runs: pairing,
|
|
3430
|
+
agent/provider config store, and start/stop of poll/drain
|
|
3431
|
+
child processes. Binds 127.0.0.1 only.
|
|
3420
3432
|
sync-sessions
|
|
3421
3433
|
Repair durable runtime-session checkpoints from local slot files.
|
|
3422
3434
|
|
|
@@ -3549,6 +3561,39 @@ Example:
|
|
|
3549
3561
|
function isHelpFlag(args) {
|
|
3550
3562
|
return args.includes("--help") || args.includes("-h");
|
|
3551
3563
|
}
|
|
3564
|
+
var SERVE_HELP = `\
|
|
3565
|
+
agent-daemon serve — loopback supervisor for console-managed runs.
|
|
3566
|
+
|
|
3567
|
+
Binds 127.0.0.1 only. A paired Console origin configures agents and
|
|
3568
|
+
providers (secret references only) and starts/stops poll/drain runs as
|
|
3569
|
+
child processes of this supervisor.
|
|
3570
|
+
|
|
3571
|
+
Options:
|
|
3572
|
+
--port <n> Loopback port. Default: 17374.
|
|
3573
|
+
Env: MOLTNET_SERVE_PORT.
|
|
3574
|
+
--allowed-origins <csv> Exact Console origins allowed to pair.
|
|
3575
|
+
Default: https://console.themolt.net.
|
|
3576
|
+
Env: MOLTNET_SERVE_ALLOWED_ORIGINS.
|
|
3577
|
+
--root <path> Config root. Default: ~/.config/moltnet
|
|
3578
|
+
(or MOLTNET_SERVE_ROOT).
|
|
3579
|
+
--api-url <url> Default MoltNet API for new managed agents.
|
|
3580
|
+
Default: https://api.themolt.net.
|
|
3581
|
+
`;
|
|
3582
|
+
//#endregion
|
|
3583
|
+
//#region src/lib/identity-pin.ts
|
|
3584
|
+
/** Compare every pinned field without choosing a caller-specific error type. */
|
|
3585
|
+
function assessIdentityPin(current, expected) {
|
|
3586
|
+
for (const [field, label] of [
|
|
3587
|
+
["identityId", "identity id"],
|
|
3588
|
+
["publicKey", "public key"],
|
|
3589
|
+
["fingerprint", "fingerprint"]
|
|
3590
|
+
]) if (!current[field] || current[field] !== expected[field]) return {
|
|
3591
|
+
ok: false,
|
|
3592
|
+
field,
|
|
3593
|
+
label
|
|
3594
|
+
};
|
|
3595
|
+
return { ok: true };
|
|
3596
|
+
}
|
|
3552
3597
|
//#endregion
|
|
3553
3598
|
//#region src/lib/agent-context.ts
|
|
3554
3599
|
/**
|
|
@@ -3612,6 +3657,8 @@ async function validateStartupBinding(options) {
|
|
|
3612
3657
|
}
|
|
3613
3658
|
const assessment = assessStartupBinding(whoami, options.teamId);
|
|
3614
3659
|
if (!assessment.ok) throw new Error(`Daemon startup validation failed: ${assessment.reason}`);
|
|
3660
|
+
const expected = options.expectedIdentity;
|
|
3661
|
+
if (expected && !assessIdentityPin(whoami, expected).ok) throw new Error("Daemon startup validation failed: authenticated identity does not match the serve activation.");
|
|
3615
3662
|
return whoami;
|
|
3616
3663
|
}
|
|
3617
3664
|
/**
|
|
@@ -3690,6 +3737,7 @@ function resolveCredentialRoots(agentRootDir) {
|
|
|
3690
3737
|
function loadConfig() {
|
|
3691
3738
|
assertSingleCredentialForm("MOLTNET_AGENT_KEY", "MOLTNET_AGENT_KEY_REF");
|
|
3692
3739
|
assertSingleCredentialForm("MOLTNET_PRIVATE_KEY", "MOLTNET_PRIVATE_KEY_REF");
|
|
3740
|
+
const expectedIdentity = readExpectedIdentity();
|
|
3693
3741
|
return {
|
|
3694
3742
|
otelEndpoint: process.env["MOLTNET_OTEL_ENDPOINT"] ?? "",
|
|
3695
3743
|
logLevel: process.env["LOG_LEVEL"] ?? "",
|
|
@@ -3703,7 +3751,25 @@ function loadConfig() {
|
|
|
3703
3751
|
profileCredentialRequirements: process.env["MOLTNET_PROFILE_CREDENTIAL_REQUIREMENTS"] ?? "",
|
|
3704
3752
|
credentialBindings: process.env["MOLTNET_CREDENTIAL_BINDINGS"] ?? "",
|
|
3705
3753
|
credentialEnforcement: process.env["MOLTNET_CREDENTIAL_ENFORCEMENT"] ?? "",
|
|
3706
|
-
traceIdlePolling: readBoolean("MOLTNET_TRACE_IDLE_POLLING", process.env["MOLTNET_TRACE_IDLE_POLLING"])
|
|
3754
|
+
traceIdlePolling: readBoolean("MOLTNET_TRACE_IDLE_POLLING", process.env["MOLTNET_TRACE_IDLE_POLLING"]),
|
|
3755
|
+
...expectedIdentity ? { expectedIdentity } : {}
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3758
|
+
function readExpectedIdentity() {
|
|
3759
|
+
const identityId = process.env["MOLTNET_EXPECTED_IDENTITY_ID"]?.trim() ?? "";
|
|
3760
|
+
const publicKey = process.env["MOLTNET_EXPECTED_PUBLIC_KEY"]?.trim() ?? "";
|
|
3761
|
+
const fingerprint = process.env["MOLTNET_EXPECTED_FINGERPRINT"]?.trim() ?? "";
|
|
3762
|
+
const present = [
|
|
3763
|
+
identityId,
|
|
3764
|
+
publicKey,
|
|
3765
|
+
fingerprint
|
|
3766
|
+
].filter(Boolean).length;
|
|
3767
|
+
if (present === 0) return void 0;
|
|
3768
|
+
if (present !== 3) throw new Error("MOLTNET_EXPECTED_IDENTITY_ID, MOLTNET_EXPECTED_PUBLIC_KEY, and MOLTNET_EXPECTED_FINGERPRINT must be set together");
|
|
3769
|
+
return {
|
|
3770
|
+
identityId,
|
|
3771
|
+
publicKey,
|
|
3772
|
+
fingerprint
|
|
3707
3773
|
};
|
|
3708
3774
|
}
|
|
3709
3775
|
function assertSingleCredentialForm(valueName, refName) {
|
|
@@ -3718,6 +3784,20 @@ function readBoolean(name, value) {
|
|
|
3718
3784
|
function activatePiCodingAgentDir(path) {
|
|
3719
3785
|
process.env["PI_CODING_AGENT_DIR"] = path;
|
|
3720
3786
|
}
|
|
3787
|
+
function loadServeEnvConfig() {
|
|
3788
|
+
return {
|
|
3789
|
+
port: process.env["MOLTNET_SERVE_PORT"] ?? "",
|
|
3790
|
+
allowedOrigins: process.env["MOLTNET_SERVE_ALLOWED_ORIGINS"] ?? "",
|
|
3791
|
+
root: process.env["MOLTNET_SERVE_ROOT"] ?? "",
|
|
3792
|
+
xdgConfigHome: process.env["XDG_CONFIG_HOME"] ?? "",
|
|
3793
|
+
apiUrl: process.env["MOLTNET_API_URL"] ?? "",
|
|
3794
|
+
logLevel: process.env["LOG_LEVEL"] ?? ""
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3797
|
+
/** Full process environment for spawned serve run children. */
|
|
3798
|
+
function processEnvSnapshot() {
|
|
3799
|
+
return process.env;
|
|
3800
|
+
}
|
|
3721
3801
|
//#endregion
|
|
3722
3802
|
//#region src/lib/abort-active-attempt.ts
|
|
3723
3803
|
/** Best-effort signal cleanup; lease expiry remains the final backstop. */
|
|
@@ -4346,13 +4426,11 @@ function attestPreparedRuntime(prepared, signingPrivateKey) {
|
|
|
4346
4426
|
//#endregion
|
|
4347
4427
|
//#region src/lib/retry-triage.ts
|
|
4348
4428
|
var RETRYABLE_CODES = new Set([
|
|
4349
|
-
"checkpoint_upload_failed",
|
|
4350
4429
|
"complete_call_failed",
|
|
4351
4430
|
"daemon_abort",
|
|
4352
4431
|
"dispatch_expired",
|
|
4353
4432
|
"lease_expired",
|
|
4354
4433
|
"llm_api_error",
|
|
4355
|
-
"runtime_session_checkpoint_failed",
|
|
4356
4434
|
"session_prompt_failed"
|
|
4357
4435
|
]);
|
|
4358
4436
|
var NON_RETRYABLE_CODES = new Set([
|
|
@@ -4402,7 +4480,7 @@ var NON_RETRYABLE_MESSAGE_PATTERNS = [
|
|
|
4402
4480
|
async function classifyAttemptFailure(input) {
|
|
4403
4481
|
const deterministic = classifyDeterministically(input.error);
|
|
4404
4482
|
if (input.remainingAttempts !== null && input.remainingAttempts !== void 0 && input.remainingAttempts <= 0) {
|
|
4405
|
-
const deterministicReason = deterministic === "ambiguous" ? "" :
|
|
4483
|
+
const deterministicReason = deterministic === "ambiguous" ? "" : deterministic === "retryable" ? " The failure type is retryable, but no attempts remain." : " The failure type is non-retryable.";
|
|
4406
4484
|
return {
|
|
4407
4485
|
error: withRetryInfo(input.error, {
|
|
4408
4486
|
retryable: false,
|
|
@@ -4468,6 +4546,7 @@ async function classifyAttemptFailure(input) {
|
|
|
4468
4546
|
function classifyDeterministically(error) {
|
|
4469
4547
|
const code = error.code.toLowerCase();
|
|
4470
4548
|
const message = error.message;
|
|
4549
|
+
if (code === "runtime_session_upload_failed") return error.retryable === true ? "retryable" : "non_retryable";
|
|
4471
4550
|
if (NON_RETRYABLE_CODES.has(code)) return "non_retryable";
|
|
4472
4551
|
if (NON_RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) return "non_retryable";
|
|
4473
4552
|
if (RETRYABLE_CODES.has(code)) return "retryable";
|
|
@@ -5170,13 +5249,17 @@ function runWithDaemonRuntimeContext(context, callback) {
|
|
|
5170
5249
|
//#endregion
|
|
5171
5250
|
//#region src/lib/runtime-profile-retry-triage.ts
|
|
5172
5251
|
function createRuntimeProfileRetryTriage(options) {
|
|
5173
|
-
return
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5252
|
+
return async (input) => {
|
|
5253
|
+
const { modelHandle, modelRuntime } = await resolveRuntimeProfileModel(options.piAgentDir, options.runtimeProfile.provider, options.runtimeProfile.model);
|
|
5254
|
+
return createPiRetryTriage({
|
|
5255
|
+
model: modelHandle,
|
|
5256
|
+
modelRuntime,
|
|
5257
|
+
thinkingLevel: options.runtimeProfile.thinkingLevel,
|
|
5258
|
+
piAgentDir: options.piAgentDir,
|
|
5259
|
+
timeoutMs: options.timeoutMs,
|
|
5260
|
+
cwd: options.cwd
|
|
5261
|
+
})(input);
|
|
5262
|
+
};
|
|
5180
5263
|
}
|
|
5181
5264
|
//#endregion
|
|
5182
5265
|
//#region src/lib/runtime-resource-reaper.ts
|
|
@@ -5457,15 +5540,29 @@ function applyRuntimeSessionUploadFailure(output, err) {
|
|
|
5457
5540
|
error: {
|
|
5458
5541
|
code: "runtime_session_upload_failed",
|
|
5459
5542
|
message: "Task completed, but durable runtime session checkpoint upload failed: " + (err instanceof Error ? err.message : String(err)),
|
|
5460
|
-
retryable:
|
|
5543
|
+
retryable: isTransientUploadError(err)
|
|
5461
5544
|
},
|
|
5462
5545
|
output: null,
|
|
5463
5546
|
outputCid: null,
|
|
5464
5547
|
status: "failed"
|
|
5465
5548
|
};
|
|
5466
5549
|
}
|
|
5550
|
+
/**
|
|
5551
|
+
* Transient faults worth retrying in-attempt: network-level errors
|
|
5552
|
+
* (no HTTP status at all) and 5xx/429 responses. A 4xx (auth,
|
|
5553
|
+
* validation, not-found) will not heal on retry.
|
|
5554
|
+
*/
|
|
5555
|
+
function isTransientUploadError(error) {
|
|
5556
|
+
if (!(error instanceof Error)) return false;
|
|
5557
|
+
const statusCode = error.statusCode;
|
|
5558
|
+
if (typeof statusCode !== "number") return true;
|
|
5559
|
+
return statusCode >= 500 || statusCode === 429;
|
|
5560
|
+
}
|
|
5561
|
+
var defaultSleep = (ms) => new Promise((resolve) => {
|
|
5562
|
+
setTimeout(resolve, ms);
|
|
5563
|
+
});
|
|
5467
5564
|
function createApiRuntimeSessionStore(args) {
|
|
5468
|
-
const { agent } = args;
|
|
5565
|
+
const { agent, logger, uploadRetry } = args;
|
|
5469
5566
|
return {
|
|
5470
5567
|
async findRuntimeSessionByTaskAttempt(teamId, taskId, attemptN) {
|
|
5471
5568
|
return agent.runtimeSessions.getForAttempt({
|
|
@@ -5486,18 +5583,40 @@ function createApiRuntimeSessionStore(args) {
|
|
|
5486
5583
|
async uploadAttemptFinal(input) {
|
|
5487
5584
|
const sessionPath = resolveLatestPiSessionPath(input.sessionDir);
|
|
5488
5585
|
if (!sessionPath) throw new Error(`Cannot upload runtime session for ${input.taskId}/${input.attemptN}: no local session file in ${input.sessionDir}`);
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5586
|
+
const maxTries = uploadRetry?.maxTries ?? 3;
|
|
5587
|
+
const baseDelayMs = uploadRetry?.baseDelayMs ?? 750;
|
|
5588
|
+
const sleep = uploadRetry?.sleep ?? defaultSleep;
|
|
5589
|
+
for (let tryN = 1;; tryN += 1) try {
|
|
5590
|
+
await agent.runtimeSessions.upload({
|
|
5591
|
+
attemptN: input.attemptN,
|
|
5592
|
+
taskId: input.taskId
|
|
5593
|
+
}, createReadStream(sessionPath), {
|
|
5594
|
+
parentSessionId: input.parentSessionId ?? void 0,
|
|
5595
|
+
sessionKind: input.sessionKind,
|
|
5596
|
+
sourceRuntimeProfileId: input.sourceRuntimeProfileId ?? void 0,
|
|
5597
|
+
sourceSlotId: input.sourceSlotId ?? void 0
|
|
5598
|
+
}, { teamId: input.teamId });
|
|
5599
|
+
return;
|
|
5600
|
+
} catch (error) {
|
|
5601
|
+
if (tryN >= maxTries || !isTransientUploadError(error)) throw error;
|
|
5602
|
+
const delayMs = baseDelayMs * tryN;
|
|
5603
|
+
logger?.warn({
|
|
5604
|
+
event: "agent-daemon.runtime_session_upload_retry",
|
|
5605
|
+
attemptN: input.attemptN,
|
|
5606
|
+
delayMs,
|
|
5607
|
+
statusCode: uploadStatusCode(error),
|
|
5608
|
+
taskId: input.taskId,
|
|
5609
|
+
tryN
|
|
5610
|
+
}, "Retrying durable runtime session upload");
|
|
5611
|
+
await sleep(delayMs);
|
|
5612
|
+
}
|
|
5498
5613
|
}
|
|
5499
5614
|
};
|
|
5500
5615
|
}
|
|
5616
|
+
function uploadStatusCode(error) {
|
|
5617
|
+
const statusCode = error?.statusCode;
|
|
5618
|
+
return typeof statusCode === "number" ? statusCode : void 0;
|
|
5619
|
+
}
|
|
5501
5620
|
function resolveContinueFrom(claimedTask) {
|
|
5502
5621
|
return claimedTask.task.input.continueFrom;
|
|
5503
5622
|
}
|
|
@@ -5790,7 +5909,8 @@ async function runPolling(opts) {
|
|
|
5790
5909
|
gate = "authenticate_and_bind";
|
|
5791
5910
|
const whoami = await validateStartupBinding({
|
|
5792
5911
|
agent: resolvedContext.agent,
|
|
5793
|
-
teamId
|
|
5912
|
+
teamId,
|
|
5913
|
+
expectedIdentity: cfg.expectedIdentity
|
|
5794
5914
|
});
|
|
5795
5915
|
gate = "resolve_signing_material";
|
|
5796
5916
|
const privateKey = await resolveExecutorSigningPrivateKey({
|
|
@@ -5873,7 +5993,10 @@ async function runPolling(opts) {
|
|
|
5873
5993
|
throw new Error(`No safe runtime profiles remain. ${details}`);
|
|
5874
5994
|
}
|
|
5875
5995
|
const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
|
|
5876
|
-
const runtimeSessionStore = createApiRuntimeSessionStore({
|
|
5996
|
+
const runtimeSessionStore = createApiRuntimeSessionStore({
|
|
5997
|
+
agent: ctx.agent,
|
|
5998
|
+
logger: { warn: (context, message) => rootLogger.warn(context, message) }
|
|
5999
|
+
});
|
|
5877
6000
|
const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
|
|
5878
6001
|
const runtimeInstanceId = createRuntimeInstanceId();
|
|
5879
6002
|
const runtimes = /* @__PURE__ */ new Map();
|
|
@@ -6443,7 +6566,8 @@ async function runOnce(argv, runtimeAdapter = defaultPiDaemonAdapter) {
|
|
|
6443
6566
|
gate = "authenticate_and_bind";
|
|
6444
6567
|
const whoami = await validateStartupBinding({
|
|
6445
6568
|
agent: resolvedContext.agent,
|
|
6446
|
-
teamId: values.team
|
|
6569
|
+
teamId: values.team,
|
|
6570
|
+
expectedIdentity: cfg.expectedIdentity
|
|
6447
6571
|
});
|
|
6448
6572
|
gate = "resolve_signing_material";
|
|
6449
6573
|
const privateKey = await resolveExecutorSigningPrivateKey({
|
|
@@ -6523,7 +6647,10 @@ async function runOnce(argv, runtimeAdapter = defaultPiDaemonAdapter) {
|
|
|
6523
6647
|
activatePiCodingAgentDir(piAgentDir.path);
|
|
6524
6648
|
const stateDirs = ensureDaemonStateDirs(sandbox.rootDir);
|
|
6525
6649
|
const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
|
|
6526
|
-
const runtimeSessionStore = createApiRuntimeSessionStore({
|
|
6650
|
+
const runtimeSessionStore = createApiRuntimeSessionStore({
|
|
6651
|
+
agent: ctx.agent,
|
|
6652
|
+
logger: { warn: (context, message) => rootLogger.warn(context, message) }
|
|
6653
|
+
});
|
|
6527
6654
|
const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
|
|
6528
6655
|
const runtimeInstanceId = createRuntimeInstanceId();
|
|
6529
6656
|
const slotIdentity = {
|
|
@@ -6854,6 +6981,2650 @@ function runPoll(argv, runtimeAdapter) {
|
|
|
6854
6981
|
});
|
|
6855
6982
|
}
|
|
6856
6983
|
//#endregion
|
|
6984
|
+
//#region ../../libs/loopback-companion/src/errors.ts
|
|
6985
|
+
var LoopbackViolationError = class extends Error {
|
|
6986
|
+
name = "LoopbackViolationError";
|
|
6987
|
+
constructor(kind, message, options) {
|
|
6988
|
+
super(message, options);
|
|
6989
|
+
this.kind = kind;
|
|
6990
|
+
}
|
|
6991
|
+
};
|
|
6992
|
+
function isLoopbackViolation(error) {
|
|
6993
|
+
return error instanceof LoopbackViolationError;
|
|
6994
|
+
}
|
|
6995
|
+
//#endregion
|
|
6996
|
+
//#region ../../libs/loopback-companion/src/origin.ts
|
|
6997
|
+
/** Hostnames accepted as loopback for companion servers and origins. */
|
|
6998
|
+
function isLoopbackHostname(hostname) {
|
|
6999
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
7000
|
+
}
|
|
7001
|
+
/**
|
|
7002
|
+
* Normalize and validate a browser origin. Accepts exact `https:` origins,
|
|
7003
|
+
* or `http:` origins whose host is loopback. Rejects values that carry a
|
|
7004
|
+
* path, trailing slash, credentials, or any other non-origin decoration
|
|
7005
|
+
* (`url.origin !== value` catches all of those).
|
|
7006
|
+
*/
|
|
7007
|
+
function normalizeOrigin(value) {
|
|
7008
|
+
let url;
|
|
7009
|
+
try {
|
|
7010
|
+
url = new URL(value);
|
|
7011
|
+
} catch (cause) {
|
|
7012
|
+
throw new LoopbackViolationError("origin_invalid", "Origin is not valid", { cause });
|
|
7013
|
+
}
|
|
7014
|
+
if (url.origin !== value || url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new LoopbackViolationError("origin_invalid", "Origin is not valid");
|
|
7015
|
+
return url.origin;
|
|
7016
|
+
}
|
|
7017
|
+
/** Parse a comma-separated origin list (config format shared by companions). */
|
|
7018
|
+
function parseAllowedOrigins(csv) {
|
|
7019
|
+
return csv.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
7020
|
+
}
|
|
7021
|
+
/**
|
|
7022
|
+
* Exact-origin allowlist. Every configured origin is normalized eagerly so a
|
|
7023
|
+
* misconfigured allowlist fails at startup, not at request time.
|
|
7024
|
+
*/
|
|
7025
|
+
var OriginAllowlist = class {
|
|
7026
|
+
origins;
|
|
7027
|
+
constructor(allowedOrigins) {
|
|
7028
|
+
if (allowedOrigins.length === 0) throw new Error("OriginAllowlist requires at least one origin");
|
|
7029
|
+
this.origins = new Set(allowedOrigins.map((origin) => normalizeOrigin(origin)));
|
|
7030
|
+
}
|
|
7031
|
+
has(origin) {
|
|
7032
|
+
try {
|
|
7033
|
+
return this.origins.has(normalizeOrigin(origin));
|
|
7034
|
+
} catch {
|
|
7035
|
+
return false;
|
|
7036
|
+
}
|
|
7037
|
+
}
|
|
7038
|
+
/** Return the normalized origin or throw `origin_not_allowed`. */
|
|
7039
|
+
assert(value) {
|
|
7040
|
+
let origin;
|
|
7041
|
+
try {
|
|
7042
|
+
origin = normalizeOrigin(value);
|
|
7043
|
+
} catch {
|
|
7044
|
+
throw new LoopbackViolationError("origin_not_allowed", "Origin is not allowed");
|
|
7045
|
+
}
|
|
7046
|
+
if (!this.origins.has(origin)) throw new LoopbackViolationError("origin_not_allowed", "Origin is not allowed");
|
|
7047
|
+
return origin;
|
|
7048
|
+
}
|
|
7049
|
+
};
|
|
7050
|
+
/** Extract and require the `Origin` header value. */
|
|
7051
|
+
function requireOriginHeader(headers) {
|
|
7052
|
+
const origin = headers.origin;
|
|
7053
|
+
if (typeof origin !== "string" || origin.length === 0) throw new LoopbackViolationError("origin_required", "Origin is required");
|
|
7054
|
+
return origin;
|
|
7055
|
+
}
|
|
7056
|
+
//#endregion
|
|
7057
|
+
//#region ../../libs/loopback-companion/src/fastify.ts
|
|
7058
|
+
var CORS_PREFLIGHT_MAX_AGE_SECONDS = 600;
|
|
7059
|
+
/**
|
|
7060
|
+
* Enforce that the `Host` header identifies loopback. Blocks DNS-rebinding
|
|
7061
|
+
* setups where a public hostname resolves to 127.0.0.1: the browser then
|
|
7062
|
+
* sends that hostname as `Host`, and the request is refused here even
|
|
7063
|
+
* though the socket is loopback.
|
|
7064
|
+
*/
|
|
7065
|
+
function requireLoopbackHost(request) {
|
|
7066
|
+
const host = request.headers.host;
|
|
7067
|
+
if (!host) throw new LoopbackViolationError("host_required", "Host header is required");
|
|
7068
|
+
let hostname;
|
|
7069
|
+
try {
|
|
7070
|
+
hostname = new URL(`http://${host}`).hostname;
|
|
7071
|
+
} catch {
|
|
7072
|
+
throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
7073
|
+
}
|
|
7074
|
+
if (!isLoopbackHostname(hostname === "::1" ? "[::1]" : hostname)) throw new LoopbackViolationError("host_not_loopback", "Host header must identify loopback");
|
|
7075
|
+
}
|
|
7076
|
+
/**
|
|
7077
|
+
* Register the loopback-companion security profile on a Fastify app:
|
|
7078
|
+
*
|
|
7079
|
+
* - loopback `Host` enforcement on every request;
|
|
7080
|
+
* - `cache-control: no-store` on every response;
|
|
7081
|
+
* - strict UTF-8 JSON body parsing (invalid bodies raise a typed violation);
|
|
7082
|
+
* - exact-origin CORS (opaque/`null` origins get no CORS response but are
|
|
7083
|
+
* not rejected here — route-level controls stay mandatory);
|
|
7084
|
+
* - hardened helmet defaults.
|
|
7085
|
+
*
|
|
7086
|
+
*/
|
|
7087
|
+
function registerLoopbackSecurity(app, options) {
|
|
7088
|
+
if (!options.allowedOrigins && !options.isOriginAllowed) throw new Error("registerLoopbackSecurity requires allowedOrigins or isOriginAllowed");
|
|
7089
|
+
const primaryAllowlist = options.allowedOrigins ? new OriginAllowlist(options.allowedOrigins) : null;
|
|
7090
|
+
const selfAllowlist = options.selfOrigins && options.selfOrigins.length > 0 ? new OriginAllowlist(options.selfOrigins) : null;
|
|
7091
|
+
const isOriginAllowed = (origin) => selfAllowlist?.has(origin) === true || primaryAllowlist?.has(origin) === true || options.isOriginAllowed?.(origin) === true;
|
|
7092
|
+
app.addHook("onRequest", (request, _reply, done) => {
|
|
7093
|
+
requireLoopbackHost(request);
|
|
7094
|
+
done();
|
|
7095
|
+
});
|
|
7096
|
+
app.addHook("onSend", async (_request, reply, payload) => {
|
|
7097
|
+
reply.header("cache-control", "no-store");
|
|
7098
|
+
return payload;
|
|
7099
|
+
});
|
|
7100
|
+
app.removeContentTypeParser("application/json");
|
|
7101
|
+
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
7102
|
+
try {
|
|
7103
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(typeof body === "string" ? Buffer.from(body) : body);
|
|
7104
|
+
done(null, JSON.parse(json));
|
|
7105
|
+
} catch (cause) {
|
|
7106
|
+
done(new LoopbackViolationError("body_not_utf8_json", "Request body must be valid UTF-8 JSON", { cause }), void 0);
|
|
7107
|
+
}
|
|
7108
|
+
});
|
|
7109
|
+
app.register(cors, {
|
|
7110
|
+
allowedHeaders: ["content-type", ...options.allowedHeaders ?? []],
|
|
7111
|
+
maxAge: CORS_PREFLIGHT_MAX_AGE_SECONDS,
|
|
7112
|
+
methods: [...options.methods ?? [
|
|
7113
|
+
"GET",
|
|
7114
|
+
"POST",
|
|
7115
|
+
"OPTIONS"
|
|
7116
|
+
]],
|
|
7117
|
+
origin: (origin, callback) => {
|
|
7118
|
+
if (!origin || origin === "null") {
|
|
7119
|
+
callback(null, false);
|
|
7120
|
+
return;
|
|
7121
|
+
}
|
|
7122
|
+
if (isOriginAllowed(origin)) {
|
|
7123
|
+
callback(null, true);
|
|
7124
|
+
return;
|
|
7125
|
+
}
|
|
7126
|
+
callback(new LoopbackViolationError("origin_not_allowed", "Origin is not allowed"), false);
|
|
7127
|
+
}
|
|
7128
|
+
});
|
|
7129
|
+
app.register(helmet, {
|
|
7130
|
+
contentSecurityPolicy: {
|
|
7131
|
+
useDefaults: false,
|
|
7132
|
+
directives: options.contentSecurityPolicyDirectives ?? {
|
|
7133
|
+
baseUri: ["'none'"],
|
|
7134
|
+
defaultSrc: ["'none'"],
|
|
7135
|
+
formAction: ["'self'"],
|
|
7136
|
+
frameAncestors: ["'none'"],
|
|
7137
|
+
styleSrc: ["'unsafe-inline'"]
|
|
7138
|
+
}
|
|
7139
|
+
},
|
|
7140
|
+
crossOriginEmbedderPolicy: false,
|
|
7141
|
+
crossOriginOpenerPolicy: false,
|
|
7142
|
+
crossOriginResourcePolicy: { policy: "same-origin" },
|
|
7143
|
+
hsts: false,
|
|
7144
|
+
referrerPolicy: { policy: "no-referrer" }
|
|
7145
|
+
});
|
|
7146
|
+
}
|
|
7147
|
+
//#endregion
|
|
7148
|
+
//#region ../../libs/loopback-companion/src/fetch-metadata.ts
|
|
7149
|
+
function headerValue(headers, name) {
|
|
7150
|
+
const value = headers[name];
|
|
7151
|
+
return typeof value === "string" ? value : void 0;
|
|
7152
|
+
}
|
|
7153
|
+
/**
|
|
7154
|
+
* Require that a request is a top-level browser navigation (used by local
|
|
7155
|
+
* approval pages that must be opened as a document, never fetched).
|
|
7156
|
+
*/
|
|
7157
|
+
function assertNavigationRequest(headers) {
|
|
7158
|
+
const site = headerValue(headers, "sec-fetch-site");
|
|
7159
|
+
const mode = headerValue(headers, "sec-fetch-mode");
|
|
7160
|
+
const destination = headerValue(headers, "sec-fetch-dest");
|
|
7161
|
+
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");
|
|
7162
|
+
}
|
|
7163
|
+
/**
|
|
7164
|
+
* Reject an explicit cross-site Fetch-Metadata signal. Only the explicit
|
|
7165
|
+
* `cross-site` value is rejected: Safari may omit Fetch Metadata on
|
|
7166
|
+
* same-origin form submissions, so the absence of the header is not treated
|
|
7167
|
+
* as a violation — callers keep their one-time token as the primary control.
|
|
7168
|
+
*/
|
|
7169
|
+
function rejectExplicitCrossSite(headers) {
|
|
7170
|
+
if (headerValue(headers, "sec-fetch-site") === "cross-site") throw new LoopbackViolationError("cross_site_rejected", "Request must not originate cross-site");
|
|
7171
|
+
}
|
|
7172
|
+
//#endregion
|
|
7173
|
+
//#region src/lib/serve/pairing.ts
|
|
7174
|
+
/**
|
|
7175
|
+
* One-click pairing ceremony for the serve supervisor, mirroring the signer's
|
|
7176
|
+
* session/ceremony pattern (#2062 design):
|
|
7177
|
+
*
|
|
7178
|
+
* 1. Console (allowed origin) POSTs `/v1/pairings` → pending pairing bound
|
|
7179
|
+
* to that origin, with a one-time confirmation token.
|
|
7180
|
+
* 2. Console opens `http://127.0.0.1:<port>/pairings/<id>` in a new tab —
|
|
7181
|
+
* a navigation-gated local approval page naming the origin.
|
|
7182
|
+
* 3. One click POSTs the confirmation form (explicit cross-site rejected;
|
|
7183
|
+
* the one-time token is the primary CSRF control).
|
|
7184
|
+
* 4. Console claims `/v1/pairings/<id>/claim` from the same origin and
|
|
7185
|
+
* receives the bearer token exactly once; only its SHA-256 remains in
|
|
7186
|
+
* this supervisor process.
|
|
7187
|
+
*
|
|
7188
|
+
* The token exists for shared-machine cross-user protection and to bind
|
|
7189
|
+
* "this console session is the operator" — browser-vs-browser isolation is
|
|
7190
|
+
* already covered by the loopback-companion origin checks. Grants are
|
|
7191
|
+
* deliberately process-scoped: after the listening socket changes owners, a
|
|
7192
|
+
* token disclosed to an impostor on that port cannot authenticate to a later
|
|
7193
|
+
* supervisor process.
|
|
7194
|
+
*/
|
|
7195
|
+
var PENDING_TTL_MS = 600 * 1e3;
|
|
7196
|
+
var ServePairingError = class extends Error {
|
|
7197
|
+
name = "ServePairingError";
|
|
7198
|
+
constructor(code, message) {
|
|
7199
|
+
super(message);
|
|
7200
|
+
this.code = code;
|
|
7201
|
+
}
|
|
7202
|
+
};
|
|
7203
|
+
function sha256Hex(value) {
|
|
7204
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
7205
|
+
}
|
|
7206
|
+
function safeEqual(a, b) {
|
|
7207
|
+
const left = Buffer.from(a, "utf8");
|
|
7208
|
+
const right = Buffer.from(b, "utf8");
|
|
7209
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
7210
|
+
}
|
|
7211
|
+
var PairingService = class {
|
|
7212
|
+
pending = /* @__PURE__ */ new Map();
|
|
7213
|
+
paired = /* @__PURE__ */ new Map();
|
|
7214
|
+
constructor(options = {}) {
|
|
7215
|
+
this.options = options;
|
|
7216
|
+
}
|
|
7217
|
+
now() {
|
|
7218
|
+
return this.options.now?.() ?? Date.now();
|
|
7219
|
+
}
|
|
7220
|
+
token() {
|
|
7221
|
+
return this.options.randomToken?.() ?? randomBytes(32).toString("base64url");
|
|
7222
|
+
}
|
|
7223
|
+
sweep() {
|
|
7224
|
+
const now = this.now();
|
|
7225
|
+
for (const [id, pairing] of this.pending) if (pairing.expiresAt <= now) this.pending.delete(id);
|
|
7226
|
+
}
|
|
7227
|
+
start(origin) {
|
|
7228
|
+
this.sweep();
|
|
7229
|
+
const pairingId = randomBytes(12).toString("hex");
|
|
7230
|
+
this.pending.set(pairingId, {
|
|
7231
|
+
origin,
|
|
7232
|
+
confirmToken: this.token(),
|
|
7233
|
+
expiresAt: this.now() + PENDING_TTL_MS,
|
|
7234
|
+
approved: false,
|
|
7235
|
+
bearerToken: null
|
|
7236
|
+
});
|
|
7237
|
+
return {
|
|
7238
|
+
pairingId,
|
|
7239
|
+
approvalPath: `/pairings/${pairingId}`
|
|
7240
|
+
};
|
|
7241
|
+
}
|
|
7242
|
+
/** Data for the local approval page. */
|
|
7243
|
+
approval(pairingId) {
|
|
7244
|
+
const pairing = this.require(pairingId);
|
|
7245
|
+
if (pairing.approved) throw new ServePairingError("pairing_invalid", "Pairing is already approved");
|
|
7246
|
+
return {
|
|
7247
|
+
origin: pairing.origin,
|
|
7248
|
+
confirmToken: pairing.confirmToken
|
|
7249
|
+
};
|
|
7250
|
+
}
|
|
7251
|
+
confirm(pairingId, confirmToken) {
|
|
7252
|
+
const pairing = this.require(pairingId);
|
|
7253
|
+
if (pairing.approved || !safeEqual(pairing.confirmToken, confirmToken)) throw new ServePairingError("pairing_invalid", "Confirmation token is not valid");
|
|
7254
|
+
pairing.approved = true;
|
|
7255
|
+
pairing.bearerToken = this.token();
|
|
7256
|
+
return { origin: pairing.origin };
|
|
7257
|
+
}
|
|
7258
|
+
claim(pairingId, origin) {
|
|
7259
|
+
const pairing = this.require(pairingId);
|
|
7260
|
+
if (pairing.origin !== origin) throw new ServePairingError("pairing_origin_mismatch", "Pairing belongs to a different origin");
|
|
7261
|
+
if (!pairing.approved || !pairing.bearerToken) throw new ServePairingError("pairing_not_approved", "Pairing has not been approved yet");
|
|
7262
|
+
const token = pairing.bearerToken;
|
|
7263
|
+
this.pending.delete(pairingId);
|
|
7264
|
+
this.paired.set(origin, sha256Hex(token));
|
|
7265
|
+
return { token };
|
|
7266
|
+
}
|
|
7267
|
+
verify(origin, token) {
|
|
7268
|
+
const tokenHash = this.paired.get(origin);
|
|
7269
|
+
if (!tokenHash || !safeEqual(tokenHash, sha256Hex(token))) throw new ServePairingError("pairing_token_invalid", "Pairing token is not valid for this origin");
|
|
7270
|
+
}
|
|
7271
|
+
require(pairingId) {
|
|
7272
|
+
this.sweep();
|
|
7273
|
+
const pairing = this.pending.get(pairingId);
|
|
7274
|
+
if (!pairing) throw new ServePairingError("pairing_not_found", "Pairing was not found or has expired");
|
|
7275
|
+
return pairing;
|
|
7276
|
+
}
|
|
7277
|
+
};
|
|
7278
|
+
function escapeHtml(value) {
|
|
7279
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
7280
|
+
}
|
|
7281
|
+
/** Minimal, dependency-free local approval page. */
|
|
7282
|
+
function renderPairingApprovalPage(input) {
|
|
7283
|
+
return `<!doctype html>
|
|
7284
|
+
<html lang="en">
|
|
7285
|
+
<head>
|
|
7286
|
+
<meta charset="utf-8" />
|
|
7287
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7288
|
+
<title>MoltNet Agent — approve connection</title>
|
|
7289
|
+
<style>
|
|
7290
|
+
:root { color-scheme: light dark; }
|
|
7291
|
+
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; background: Canvas; color: CanvasText; }
|
|
7292
|
+
main { max-width: 26rem; padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 12px; }
|
|
7293
|
+
h1 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
|
7294
|
+
code { font-size: 0.95em; word-break: break-all; }
|
|
7295
|
+
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; }
|
|
7296
|
+
p.small { font-size: 0.85rem; opacity: 0.75; }
|
|
7297
|
+
</style>
|
|
7298
|
+
</head>
|
|
7299
|
+
<body>
|
|
7300
|
+
<main>
|
|
7301
|
+
<h1>Allow this site to manage local MoltNet agents?</h1>
|
|
7302
|
+
<p><code>${escapeHtml(input.origin)}</code> asks to configure agents and start or stop local daemon runs on this machine.</p>
|
|
7303
|
+
<p class="small">Approve only if you opened that page yourself. This grant lasts until the local supervisor stops.</p>
|
|
7304
|
+
<form method="post" action="/pairings/${escapeHtml(input.pairingId)}/confirm">
|
|
7305
|
+
<input type="hidden" name="confirmToken" value="${escapeHtml(input.confirmToken)}" />
|
|
7306
|
+
<button type="submit">Approve</button>
|
|
7307
|
+
</form>
|
|
7308
|
+
</main>
|
|
7309
|
+
</body>
|
|
7310
|
+
</html>
|
|
7311
|
+
`;
|
|
7312
|
+
}
|
|
7313
|
+
function renderPairingResultPage(input) {
|
|
7314
|
+
return `<!doctype html>
|
|
7315
|
+
<html lang="en">
|
|
7316
|
+
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(input.title)}</title>
|
|
7317
|
+
<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>
|
|
7318
|
+
</head>
|
|
7319
|
+
<body><main role="status"><h1>${escapeHtml(input.title)}</h1><p>${escapeHtml(input.message)}</p><p>You can close this tab.</p></main></body>
|
|
7320
|
+
</html>
|
|
7321
|
+
`;
|
|
7322
|
+
}
|
|
7323
|
+
//#endregion
|
|
7324
|
+
//#region src/lib/serve/provider-login.ts
|
|
7325
|
+
/**
|
|
7326
|
+
* Subscription-provider OAuth brokering for `serve` (#2061 slice 4).
|
|
7327
|
+
*
|
|
7328
|
+
* The console clicks "Connect"; serve runs the Pi OAuth flow host-side via
|
|
7329
|
+
* `AuthStorage.login()` (which owns persistence into the shared
|
|
7330
|
+
* `pi/auth.json` and token rotation thereafter). The browser only ever sees
|
|
7331
|
+
* the provider's authorize URL or device code — never tokens.
|
|
7332
|
+
*
|
|
7333
|
+
* Provider ids come from pi-ai's own OAuth registry (`getOAuthProviders`),
|
|
7334
|
+
* so serve stays in lockstep with what Pi can actually authenticate
|
|
7335
|
+
* (anthropic, openai-codex, github-copilot, …) without hardcoding.
|
|
7336
|
+
*/
|
|
7337
|
+
var LOGIN_TTL_MS = 600 * 1e3;
|
|
7338
|
+
/** How long `start()` waits for the flow to surface a URL / device code. */
|
|
7339
|
+
var START_INFO_TIMEOUT_MS = 5e3;
|
|
7340
|
+
var ServeSubscriptionError = class extends Error {
|
|
7341
|
+
name = "ServeSubscriptionError";
|
|
7342
|
+
constructor(code, message) {
|
|
7343
|
+
super(message);
|
|
7344
|
+
this.code = code;
|
|
7345
|
+
}
|
|
7346
|
+
};
|
|
7347
|
+
var silentLogger = {
|
|
7348
|
+
info: () => void 0,
|
|
7349
|
+
warn: () => void 0,
|
|
7350
|
+
error: () => void 0
|
|
7351
|
+
};
|
|
7352
|
+
var ProviderLoginService = class {
|
|
7353
|
+
logins = /* @__PURE__ */ new Map();
|
|
7354
|
+
authStorage;
|
|
7355
|
+
logger;
|
|
7356
|
+
constructor(options) {
|
|
7357
|
+
this.options = options;
|
|
7358
|
+
this.authStorage = options.authStorage ?? AuthStorage.create(this.options.authPath);
|
|
7359
|
+
this.logger = options.logger ?? silentLogger;
|
|
7360
|
+
}
|
|
7361
|
+
now() {
|
|
7362
|
+
return this.options.now?.() ?? Date.now();
|
|
7363
|
+
}
|
|
7364
|
+
providers() {
|
|
7365
|
+
return this.options.listProviders?.() ?? getOAuthProviders().map((provider) => ({
|
|
7366
|
+
id: provider.id,
|
|
7367
|
+
name: provider.name
|
|
7368
|
+
}));
|
|
7369
|
+
}
|
|
7370
|
+
connected(providerId) {
|
|
7371
|
+
if (this.options.isConnected) return this.options.isConnected(providerId);
|
|
7372
|
+
try {
|
|
7373
|
+
return (this.options.authStorage ? this.authStorage : AuthStorage.create(this.options.authPath)).getAuthStatus(providerId).configured;
|
|
7374
|
+
} catch (error) {
|
|
7375
|
+
this.logger.warn({
|
|
7376
|
+
event: "serve.subscription_auth_read_failed",
|
|
7377
|
+
providerId,
|
|
7378
|
+
...safeLoginError(error)
|
|
7379
|
+
}, "Could not read subscription authentication state");
|
|
7380
|
+
return false;
|
|
7381
|
+
}
|
|
7382
|
+
}
|
|
7383
|
+
list() {
|
|
7384
|
+
this.sweep();
|
|
7385
|
+
return this.providers().map((provider) => ({
|
|
7386
|
+
...provider,
|
|
7387
|
+
connected: this.connected(provider.id)
|
|
7388
|
+
}));
|
|
7389
|
+
}
|
|
7390
|
+
sweep() {
|
|
7391
|
+
const now = this.now();
|
|
7392
|
+
for (const [id, login] of this.logins) if (login.startedAt + LOGIN_TTL_MS <= now) {
|
|
7393
|
+
this.invalidate(login, "expired");
|
|
7394
|
+
this.logins.delete(id);
|
|
7395
|
+
}
|
|
7396
|
+
}
|
|
7397
|
+
restoreCredential(login) {
|
|
7398
|
+
try {
|
|
7399
|
+
if (login.previousCredential) this.authStorage.set(login.providerId, login.previousCredential);
|
|
7400
|
+
else this.authStorage.logout(login.providerId);
|
|
7401
|
+
} catch (error) {
|
|
7402
|
+
this.logger.error({
|
|
7403
|
+
event: "serve.subscription_login_cleanup_failed",
|
|
7404
|
+
operationId: login.operationId,
|
|
7405
|
+
providerId: login.providerId,
|
|
7406
|
+
...safeLoginError(error)
|
|
7407
|
+
}, "Could not restore subscription credentials after an invalidated login");
|
|
7408
|
+
}
|
|
7409
|
+
}
|
|
7410
|
+
invalidate(login, transition) {
|
|
7411
|
+
if (login.invalidated) return;
|
|
7412
|
+
login.invalidated = true;
|
|
7413
|
+
login.abort.abort(/* @__PURE__ */ new Error(`subscription login ${transition}`));
|
|
7414
|
+
this.restoreCredential(login);
|
|
7415
|
+
login.infoArrived();
|
|
7416
|
+
this.logger.info({
|
|
7417
|
+
event: "serve.subscription_login_transition",
|
|
7418
|
+
operationId: login.operationId,
|
|
7419
|
+
providerId: login.providerId,
|
|
7420
|
+
transition
|
|
7421
|
+
}, "Subscription login invalidated");
|
|
7422
|
+
}
|
|
7423
|
+
status(providerId) {
|
|
7424
|
+
this.sweep();
|
|
7425
|
+
const login = this.logins.get(providerId);
|
|
7426
|
+
if (!login) throw new ServeSubscriptionError("login_not_found", `no login in progress for "${providerId}"`);
|
|
7427
|
+
return snapshot(login);
|
|
7428
|
+
}
|
|
7429
|
+
/**
|
|
7430
|
+
* Start (or return the in-flight) login for a provider. Resolves once the
|
|
7431
|
+
* flow has surfaced an authorize URL / device code, completed, or the
|
|
7432
|
+
* start window elapsed — whichever comes first.
|
|
7433
|
+
*/
|
|
7434
|
+
async start(providerId) {
|
|
7435
|
+
this.sweep();
|
|
7436
|
+
if (!this.providers().some((provider) => provider.id === providerId)) throw new ServeSubscriptionError("provider_unknown", `"${providerId}" is not a known subscription provider`);
|
|
7437
|
+
const existing = this.logins.get(providerId);
|
|
7438
|
+
if (existing && existing.status === "pending") return snapshot(existing);
|
|
7439
|
+
let infoArrived = () => void 0;
|
|
7440
|
+
const infoPromise = new Promise((resolvePromise) => {
|
|
7441
|
+
infoArrived = () => resolvePromise();
|
|
7442
|
+
});
|
|
7443
|
+
const abort = new AbortController();
|
|
7444
|
+
const login = {
|
|
7445
|
+
providerId,
|
|
7446
|
+
status: "pending",
|
|
7447
|
+
operationId: randomUUID(),
|
|
7448
|
+
startedAt: this.now(),
|
|
7449
|
+
infoArrived,
|
|
7450
|
+
abort,
|
|
7451
|
+
invalidated: false,
|
|
7452
|
+
previousCredential: this.authStorage.get(providerId)
|
|
7453
|
+
};
|
|
7454
|
+
this.logins.set(providerId, login);
|
|
7455
|
+
this.logger.info({
|
|
7456
|
+
event: "serve.subscription_login_transition",
|
|
7457
|
+
operationId: login.operationId,
|
|
7458
|
+
providerId,
|
|
7459
|
+
transition: "started"
|
|
7460
|
+
}, "Subscription login started");
|
|
7461
|
+
const callbacks = createLoginCallbacks(login, this.logger);
|
|
7462
|
+
(this.options.runLogin ?? ((id, loginCallbacks) => this.authStorage.login(id, loginCallbacks)))(providerId, callbacks).then(() => {
|
|
7463
|
+
if (login.invalidated) {
|
|
7464
|
+
this.restoreCredential(login);
|
|
7465
|
+
return;
|
|
7466
|
+
}
|
|
7467
|
+
if (!this.options.runLogin && !this.connected(providerId)) {
|
|
7468
|
+
login.status = "failed";
|
|
7469
|
+
login.error = "Subscription sign-in completed, but credentials were not persisted. Start again to retry.";
|
|
7470
|
+
this.logger.error({
|
|
7471
|
+
event: "serve.subscription_login_transition",
|
|
7472
|
+
operationId: login.operationId,
|
|
7473
|
+
providerId,
|
|
7474
|
+
transition: "persistence_failed"
|
|
7475
|
+
}, "Subscription login credentials were not persisted");
|
|
7476
|
+
} else {
|
|
7477
|
+
login.status = "completed";
|
|
7478
|
+
this.logger.info({
|
|
7479
|
+
event: "serve.subscription_login_transition",
|
|
7480
|
+
operationId: login.operationId,
|
|
7481
|
+
providerId,
|
|
7482
|
+
transition: "completed"
|
|
7483
|
+
}, "Subscription login completed");
|
|
7484
|
+
}
|
|
7485
|
+
login.infoArrived();
|
|
7486
|
+
}, (error) => {
|
|
7487
|
+
if (login.invalidated) {
|
|
7488
|
+
this.restoreCredential(login);
|
|
7489
|
+
return;
|
|
7490
|
+
}
|
|
7491
|
+
login.status = "failed";
|
|
7492
|
+
login.error = publicLoginError(error);
|
|
7493
|
+
this.logger.warn({
|
|
7494
|
+
event: "serve.subscription_login_transition",
|
|
7495
|
+
operationId: login.operationId,
|
|
7496
|
+
providerId,
|
|
7497
|
+
transition: "failed",
|
|
7498
|
+
...safeLoginError(error)
|
|
7499
|
+
}, "Subscription login failed");
|
|
7500
|
+
login.infoArrived();
|
|
7501
|
+
});
|
|
7502
|
+
await Promise.race([infoPromise, new Promise((resolvePromise) => {
|
|
7503
|
+
setTimeout(resolvePromise, START_INFO_TIMEOUT_MS).unref?.();
|
|
7504
|
+
})]);
|
|
7505
|
+
return snapshot(login);
|
|
7506
|
+
}
|
|
7507
|
+
/** Abort an in-flight login and forget it. */
|
|
7508
|
+
cancel(providerId) {
|
|
7509
|
+
const login = this.logins.get(providerId);
|
|
7510
|
+
if (!login) throw new ServeSubscriptionError("login_not_found", `no login in progress for "${providerId}"`);
|
|
7511
|
+
this.invalidate(login, "cancelled");
|
|
7512
|
+
this.logins.delete(providerId);
|
|
7513
|
+
return {
|
|
7514
|
+
providerId,
|
|
7515
|
+
status: "cancelled"
|
|
7516
|
+
};
|
|
7517
|
+
}
|
|
7518
|
+
/** Abort every pending flow during supervisor shutdown. */
|
|
7519
|
+
close() {
|
|
7520
|
+
for (const login of this.logins.values()) this.invalidate(login, "shutdown");
|
|
7521
|
+
this.logins.clear();
|
|
7522
|
+
}
|
|
7523
|
+
};
|
|
7524
|
+
function createLoginCallbacks(login, logger) {
|
|
7525
|
+
return {
|
|
7526
|
+
onAuth: (info) => {
|
|
7527
|
+
login.authUrl = info.url;
|
|
7528
|
+
if (info.instructions) login.instructions = info.instructions;
|
|
7529
|
+
logger.info({
|
|
7530
|
+
event: "serve.subscription_login_transition",
|
|
7531
|
+
operationId: login.operationId,
|
|
7532
|
+
providerId: login.providerId,
|
|
7533
|
+
transition: "authorization_ready"
|
|
7534
|
+
}, "Subscription authorization URL ready");
|
|
7535
|
+
login.infoArrived();
|
|
7536
|
+
},
|
|
7537
|
+
onDeviceCode: (info) => {
|
|
7538
|
+
login.userCode = info.userCode;
|
|
7539
|
+
login.verificationUri = info.verificationUri;
|
|
7540
|
+
logger.info({
|
|
7541
|
+
event: "serve.subscription_login_transition",
|
|
7542
|
+
operationId: login.operationId,
|
|
7543
|
+
providerId: login.providerId,
|
|
7544
|
+
transition: "device_code_ready"
|
|
7545
|
+
}, "Subscription device code ready");
|
|
7546
|
+
login.infoArrived();
|
|
7547
|
+
},
|
|
7548
|
+
onPrompt: () => Promise.reject(new ServeSubscriptionError("login_unsupported_prompt", "This provider flow needs an interactive prompt; run `pi /login` in a terminal instead")),
|
|
7549
|
+
onSelect: (prompt) => Promise.resolve((prompt.options.find((option) => /device/i.test(option.id)) ?? prompt.options[0])?.id),
|
|
7550
|
+
signal: login.abort.signal
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
function publicLoginError(error) {
|
|
7554
|
+
if (error instanceof ServeSubscriptionError) return error.message;
|
|
7555
|
+
return "Subscription sign-in failed. Start again to retry.";
|
|
7556
|
+
}
|
|
7557
|
+
function safeLoginError(error) {
|
|
7558
|
+
const result = { errorType: error instanceof Error ? error.name : typeof error };
|
|
7559
|
+
const code = error?.code;
|
|
7560
|
+
if (typeof code === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(code)) result["applicationCode"] = code;
|
|
7561
|
+
return result;
|
|
7562
|
+
}
|
|
7563
|
+
function snapshot(login) {
|
|
7564
|
+
const { providerId, status, authUrl, instructions, userCode, verificationUri, error } = login;
|
|
7565
|
+
return {
|
|
7566
|
+
providerId,
|
|
7567
|
+
status,
|
|
7568
|
+
...authUrl ? { authUrl } : {},
|
|
7569
|
+
...instructions ? { instructions } : {},
|
|
7570
|
+
...userCode ? { userCode } : {},
|
|
7571
|
+
...verificationUri ? { verificationUri } : {},
|
|
7572
|
+
...error ? { error } : {}
|
|
7573
|
+
};
|
|
7574
|
+
}
|
|
7575
|
+
var NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
7576
|
+
var PROVIDER_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
7577
|
+
function assertStoreName(kind, value) {
|
|
7578
|
+
if (!NAME_RE.test(value)) throw new ServeStoreError("invalid_name", `${kind} must match ${NAME_RE.source}`);
|
|
7579
|
+
return value;
|
|
7580
|
+
}
|
|
7581
|
+
function assertProviderId(value) {
|
|
7582
|
+
if (!PROVIDER_ID_RE.test(value)) throw new ServeStoreError("invalid_name", `provider id must match ${PROVIDER_ID_RE.source}`);
|
|
7583
|
+
return value;
|
|
7584
|
+
}
|
|
7585
|
+
var ServeStoreError = class extends Error {
|
|
7586
|
+
name = "ServeStoreError";
|
|
7587
|
+
constructor(code, message, options) {
|
|
7588
|
+
super(message, options);
|
|
7589
|
+
this.code = code;
|
|
7590
|
+
}
|
|
7591
|
+
};
|
|
7592
|
+
/** `MOLTNET_SERVE_ROOT` override, else `$XDG_CONFIG_HOME/moltnet`, else `~/.config/moltnet`. */
|
|
7593
|
+
function resolveServeRoot(input) {
|
|
7594
|
+
const override = input.root?.trim();
|
|
7595
|
+
if (override) return override;
|
|
7596
|
+
const xdg = input.xdgConfigHome?.trim();
|
|
7597
|
+
return join(xdg || join(homedir(), ".config"), "moltnet");
|
|
7598
|
+
}
|
|
7599
|
+
function providerEnvName(providerId) {
|
|
7600
|
+
return `MOLTNET_PROVIDER_${assertProviderId(providerId).replaceAll("-", "_").toUpperCase()}_API_KEY`;
|
|
7601
|
+
}
|
|
7602
|
+
function assertProviderEnvName(providerId, value) {
|
|
7603
|
+
const expected = providerEnvName(providerId);
|
|
7604
|
+
if (value !== expected) throw new ServeStoreError("invalid_state", `provider envName must be ${expected}`);
|
|
7605
|
+
return value;
|
|
7606
|
+
}
|
|
7607
|
+
function readJson(path) {
|
|
7608
|
+
let raw;
|
|
7609
|
+
try {
|
|
7610
|
+
raw = readFileSync(path, "utf8");
|
|
7611
|
+
} catch (cause) {
|
|
7612
|
+
if (cause.code === "ENOENT") return null;
|
|
7613
|
+
throw new ServeStoreError("io_error", `could not read state at ${path}`, { cause });
|
|
7614
|
+
}
|
|
7615
|
+
try {
|
|
7616
|
+
return JSON.parse(raw);
|
|
7617
|
+
} catch (cause) {
|
|
7618
|
+
throw new ServeStoreError("invalid_state", `corrupt JSON at ${path}`, { cause });
|
|
7619
|
+
}
|
|
7620
|
+
}
|
|
7621
|
+
function writeJsonAtomic(path, value) {
|
|
7622
|
+
const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
7623
|
+
try {
|
|
7624
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
7625
|
+
renameSync(temp, path);
|
|
7626
|
+
} catch (cause) {
|
|
7627
|
+
try {
|
|
7628
|
+
rmSync(temp, { force: true });
|
|
7629
|
+
} catch {}
|
|
7630
|
+
throw cause;
|
|
7631
|
+
}
|
|
7632
|
+
}
|
|
7633
|
+
var ServeStore = class {
|
|
7634
|
+
root;
|
|
7635
|
+
agentsDir;
|
|
7636
|
+
runsDir;
|
|
7637
|
+
secretsDir;
|
|
7638
|
+
/** Shared Pi credential dir; `auth.json` inside is pi-managed (lockfiled). */
|
|
7639
|
+
piDir;
|
|
7640
|
+
constructor(root) {
|
|
7641
|
+
this.root = root;
|
|
7642
|
+
this.agentsDir = join(root, "agents");
|
|
7643
|
+
this.runsDir = join(root, "runs");
|
|
7644
|
+
this.secretsDir = join(root, "secrets");
|
|
7645
|
+
this.piDir = join(root, "pi");
|
|
7646
|
+
}
|
|
7647
|
+
get piAuthJsonPath() {
|
|
7648
|
+
return join(this.piDir, "auth.json");
|
|
7649
|
+
}
|
|
7650
|
+
/** Create the directory layout (0700) if missing. Idempotent. */
|
|
7651
|
+
ensure() {
|
|
7652
|
+
for (const dir of [
|
|
7653
|
+
this.root,
|
|
7654
|
+
this.agentsDir,
|
|
7655
|
+
this.runsDir,
|
|
7656
|
+
this.secretsDir
|
|
7657
|
+
]) mkdirSync(dir, {
|
|
7658
|
+
recursive: true,
|
|
7659
|
+
mode: 448
|
|
7660
|
+
});
|
|
7661
|
+
return this;
|
|
7662
|
+
}
|
|
7663
|
+
get servePath() {
|
|
7664
|
+
return join(this.root, "serve.json");
|
|
7665
|
+
}
|
|
7666
|
+
readServeState() {
|
|
7667
|
+
const state = readJson(this.servePath);
|
|
7668
|
+
if (!state) return {
|
|
7669
|
+
version: 1,
|
|
7670
|
+
pendingRegistrations: {},
|
|
7671
|
+
activations: {}
|
|
7672
|
+
};
|
|
7673
|
+
if (!isRecord$1(state) || state.version !== 1) throw new ServeStoreError("invalid_state", `serve.json version ${String(isRecord$1(state) ? state.version : void 0)} is not supported`);
|
|
7674
|
+
if ("pairedOrigins" in state) throw new ServeStoreError("invalid_state", "serve.json uses the obsolete pairing format; clear the unreleased serve store and reconfigure it");
|
|
7675
|
+
if (!isRecord$1(state.pendingRegistrations) || !isRecord$1(state.activations)) throw new ServeStoreError("invalid_state", "serve.json is missing the version 1 activation map; clear the unreleased serve store and reconfigure it");
|
|
7676
|
+
for (const [alias, activation] of Object.entries(state.activations)) validateActivation(alias, activation);
|
|
7677
|
+
for (const [alias, registration] of Object.entries(state.pendingRegistrations)) {
|
|
7678
|
+
assertStoreName("agent name", alias);
|
|
7679
|
+
if (!isRecord$1(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`);
|
|
7680
|
+
}
|
|
7681
|
+
return {
|
|
7682
|
+
version: 1,
|
|
7683
|
+
pendingRegistrations: state.pendingRegistrations,
|
|
7684
|
+
activations: state.activations
|
|
7685
|
+
};
|
|
7686
|
+
}
|
|
7687
|
+
writeServeState(state) {
|
|
7688
|
+
writeJsonAtomic(this.servePath, state);
|
|
7689
|
+
}
|
|
7690
|
+
agentPath(name) {
|
|
7691
|
+
return storeChildPath(this.agentsDir, "agent name", name, ".json");
|
|
7692
|
+
}
|
|
7693
|
+
readAgentConfig(alias) {
|
|
7694
|
+
return readJson(this.agentPath(alias));
|
|
7695
|
+
}
|
|
7696
|
+
writeAgentConfig(alias, config) {
|
|
7697
|
+
writeJsonAtomic(this.agentPath(alias), config);
|
|
7698
|
+
}
|
|
7699
|
+
removeAgentConfig(alias) {
|
|
7700
|
+
rmSync(this.agentPath(alias), { force: true });
|
|
7701
|
+
}
|
|
7702
|
+
readActivation(alias) {
|
|
7703
|
+
return this.readServeState().activations[assertStoreName("agent name", alias)] ?? null;
|
|
7704
|
+
}
|
|
7705
|
+
hasPendingRegistration(alias) {
|
|
7706
|
+
return Boolean(this.readServeState().pendingRegistrations[assertStoreName("agent name", alias)]);
|
|
7707
|
+
}
|
|
7708
|
+
reserveRegistration(alias, apiUrl) {
|
|
7709
|
+
const name = assertStoreName("agent name", alias);
|
|
7710
|
+
const state = this.readServeState();
|
|
7711
|
+
if (state.activations[name] || state.pendingRegistrations[name]) throw new ServeStoreError("already_exists", `agent "${name}" already exists in the serve store`);
|
|
7712
|
+
state.pendingRegistrations[name] = {
|
|
7713
|
+
apiUrl,
|
|
7714
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7715
|
+
};
|
|
7716
|
+
this.writeServeState(state);
|
|
7717
|
+
}
|
|
7718
|
+
clearPendingRegistration(alias) {
|
|
7719
|
+
const name = assertStoreName("agent name", alias);
|
|
7720
|
+
const state = this.readServeState();
|
|
7721
|
+
delete state.pendingRegistrations[name];
|
|
7722
|
+
this.writeServeState(state);
|
|
7723
|
+
}
|
|
7724
|
+
writeActivation(activation) {
|
|
7725
|
+
const alias = assertStoreName("agent name", activation.alias);
|
|
7726
|
+
validateActivation(alias, activation);
|
|
7727
|
+
const state = this.readServeState();
|
|
7728
|
+
state.activations[alias] = activation;
|
|
7729
|
+
if (activation.source === "managed") delete state.pendingRegistrations[alias];
|
|
7730
|
+
this.writeServeState(state);
|
|
7731
|
+
}
|
|
7732
|
+
listActivations() {
|
|
7733
|
+
return Object.values(this.readServeState().activations).sort((a, b) => a.alias.localeCompare(b.alias));
|
|
7734
|
+
}
|
|
7735
|
+
get providersPath() {
|
|
7736
|
+
return join(this.root, "providers.json");
|
|
7737
|
+
}
|
|
7738
|
+
readProviders() {
|
|
7739
|
+
const state = readJson(this.providersPath) ?? {};
|
|
7740
|
+
this.validateProviders(state);
|
|
7741
|
+
return state;
|
|
7742
|
+
}
|
|
7743
|
+
writeProviders(state) {
|
|
7744
|
+
this.validateProviders(state);
|
|
7745
|
+
writeJsonAtomic(this.providersPath, state);
|
|
7746
|
+
}
|
|
7747
|
+
validateProviders(state) {
|
|
7748
|
+
for (const [id, provider] of Object.entries(state)) {
|
|
7749
|
+
assertProviderId(id);
|
|
7750
|
+
assertProviderEnvName(id, provider.envName);
|
|
7751
|
+
}
|
|
7752
|
+
}
|
|
7753
|
+
runDir(id) {
|
|
7754
|
+
return storeChildPath(this.runsDir, "run id", id);
|
|
7755
|
+
}
|
|
7756
|
+
resolveRunLogPath(id) {
|
|
7757
|
+
let root;
|
|
7758
|
+
let runDir;
|
|
7759
|
+
try {
|
|
7760
|
+
root = realpathSync(this.runsDir);
|
|
7761
|
+
runDir = realpathSync(this.runDir(id));
|
|
7762
|
+
} catch (cause) {
|
|
7763
|
+
throw new ServeStoreError("io_error", "could not resolve run directory", { cause });
|
|
7764
|
+
}
|
|
7765
|
+
if (!isStrictDescendant(root, runDir)) throw new ServeStoreError("invalid_state", "run directory escapes its store");
|
|
7766
|
+
const logPath = join(runDir, "daemon.log");
|
|
7767
|
+
try {
|
|
7768
|
+
if (lstatSync(logPath).isSymbolicLink()) throw new ServeStoreError("invalid_state", "run log must not be a symbolic link");
|
|
7769
|
+
const resolvedLog = realpathSync(logPath);
|
|
7770
|
+
if (!isStrictDescendant(runDir, resolvedLog)) throw new ServeStoreError("invalid_state", "run log escapes its store");
|
|
7771
|
+
} catch (cause) {
|
|
7772
|
+
if (cause instanceof ServeStoreError) throw cause;
|
|
7773
|
+
if (cause.code !== "ENOENT") throw new ServeStoreError("io_error", "could not resolve run log", { cause });
|
|
7774
|
+
}
|
|
7775
|
+
return logPath;
|
|
7776
|
+
}
|
|
7777
|
+
createRunDir(id) {
|
|
7778
|
+
const dir = this.runDir(id);
|
|
7779
|
+
const piDir = join(dir, "pi");
|
|
7780
|
+
mkdirSync(piDir, {
|
|
7781
|
+
recursive: true,
|
|
7782
|
+
mode: 448
|
|
7783
|
+
});
|
|
7784
|
+
return {
|
|
7785
|
+
dir,
|
|
7786
|
+
piDir,
|
|
7787
|
+
logPath: join(dir, "daemon.log")
|
|
7788
|
+
};
|
|
7789
|
+
}
|
|
7790
|
+
readRun(id) {
|
|
7791
|
+
return readJson(join(this.runDir(id), "run.json"));
|
|
7792
|
+
}
|
|
7793
|
+
writeRun(record) {
|
|
7794
|
+
writeJsonAtomic(join(this.runDir(record.id), "run.json"), record);
|
|
7795
|
+
}
|
|
7796
|
+
listRuns(limit = Number.POSITIVE_INFINITY) {
|
|
7797
|
+
let ids;
|
|
7798
|
+
try {
|
|
7799
|
+
ids = readdirSync(this.runsDir);
|
|
7800
|
+
} catch {
|
|
7801
|
+
return [];
|
|
7802
|
+
}
|
|
7803
|
+
const sortedIds = ids.filter((id) => NAME_RE.test(id)).sort().reverse();
|
|
7804
|
+
const selectedIds = Number.isFinite(limit) ? sortedIds.slice(0, Math.max(0, limit)) : sortedIds;
|
|
7805
|
+
const records = [];
|
|
7806
|
+
for (const id of selectedIds) {
|
|
7807
|
+
const record = this.readRun(id);
|
|
7808
|
+
if (record) records.push(record);
|
|
7809
|
+
}
|
|
7810
|
+
return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
7811
|
+
}
|
|
7812
|
+
/** Remove only completed run directories outside the configured budget. */
|
|
7813
|
+
pruneCompletedRuns(options) {
|
|
7814
|
+
const now = (options.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
7815
|
+
let retainedBytes = 0;
|
|
7816
|
+
let retainedCount = 0;
|
|
7817
|
+
const removed = [];
|
|
7818
|
+
for (const record of this.listRuns()) {
|
|
7819
|
+
if (record.status === "running") continue;
|
|
7820
|
+
const dir = this.runDir(record.id);
|
|
7821
|
+
const bytes = directoryBytes(dir);
|
|
7822
|
+
const endedAt = Date.parse(record.endedAt ?? record.startedAt);
|
|
7823
|
+
const expired = !Number.isFinite(endedAt) || now - endedAt > options.maxAgeMs;
|
|
7824
|
+
const overCount = retainedCount >= options.maxCount;
|
|
7825
|
+
const overBytes = retainedBytes + bytes > options.maxBytes;
|
|
7826
|
+
if (expired || overCount || overBytes) {
|
|
7827
|
+
rmSync(dir, {
|
|
7828
|
+
recursive: true,
|
|
7829
|
+
force: true
|
|
7830
|
+
});
|
|
7831
|
+
removed.push(record.id);
|
|
7832
|
+
continue;
|
|
7833
|
+
}
|
|
7834
|
+
retainedCount += 1;
|
|
7835
|
+
retainedBytes += bytes;
|
|
7836
|
+
}
|
|
7837
|
+
return removed;
|
|
7838
|
+
}
|
|
7839
|
+
};
|
|
7840
|
+
function directoryBytes(path) {
|
|
7841
|
+
let info;
|
|
7842
|
+
try {
|
|
7843
|
+
info = lstatSync(path);
|
|
7844
|
+
} catch {
|
|
7845
|
+
return 0;
|
|
7846
|
+
}
|
|
7847
|
+
if (info.isSymbolicLink()) return 0;
|
|
7848
|
+
if (!info.isDirectory()) return info.size;
|
|
7849
|
+
let total = 0;
|
|
7850
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) total += directoryBytes(join(path, entry.name));
|
|
7851
|
+
return total;
|
|
7852
|
+
}
|
|
7853
|
+
function isRecord$1(value) {
|
|
7854
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7855
|
+
}
|
|
7856
|
+
function storeChildPath(root, kind, value, suffix = "") {
|
|
7857
|
+
const name = assertStoreName(kind, value);
|
|
7858
|
+
const normalizedRoot = resolve(root);
|
|
7859
|
+
const candidate = resolve(normalizedRoot, `${name}${suffix}`);
|
|
7860
|
+
if (!isStrictDescendant(normalizedRoot, candidate)) throw new ServeStoreError("invalid_name", `${kind} escapes its store`);
|
|
7861
|
+
return candidate;
|
|
7862
|
+
}
|
|
7863
|
+
function isStrictDescendant(root, candidate) {
|
|
7864
|
+
const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
7865
|
+
return candidate !== root && candidate.startsWith(rootPrefix);
|
|
7866
|
+
}
|
|
7867
|
+
function validateActivation(alias, value) {
|
|
7868
|
+
const invalid = () => {
|
|
7869
|
+
throw new ServeStoreError("invalid_state", `activation "${alias}" is not a valid version 1 activation`);
|
|
7870
|
+
};
|
|
7871
|
+
if (!isRecord$1(value)) invalid();
|
|
7872
|
+
const activation = value;
|
|
7873
|
+
if (activation.alias !== alias) invalid();
|
|
7874
|
+
if (![
|
|
7875
|
+
"identityId",
|
|
7876
|
+
"publicKey",
|
|
7877
|
+
"fingerprint",
|
|
7878
|
+
"createdAt"
|
|
7879
|
+
].every((field) => typeof activation[field] === "string" && activation[field].length > 0)) invalid();
|
|
7880
|
+
if (activation.source === "managed") {
|
|
7881
|
+
if (typeof activation.apiUrl !== "string" || activation.apiUrl.length === 0 || activation.configPath !== void 0 || activation.configApiUrl !== void 0) invalid();
|
|
7882
|
+
return;
|
|
7883
|
+
}
|
|
7884
|
+
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();
|
|
7885
|
+
}
|
|
7886
|
+
//#endregion
|
|
7887
|
+
//#region src/lib/serve/identity.ts
|
|
7888
|
+
/**
|
|
7889
|
+
* Serve identity activation (#2061/#1834 boundary).
|
|
7890
|
+
*
|
|
7891
|
+
* Managed agent files are canonical `MoltNetConfig` documents. Alias,
|
|
7892
|
+
* provenance, pinned identity material, and external config paths live only
|
|
7893
|
+
* in the versioned activation map in `serve.json`. Every attach and run loads
|
|
7894
|
+
* the current config and verifies it with authenticated `whoami` before use.
|
|
7895
|
+
*/
|
|
7896
|
+
var MAX_CONFIG_BYTES = 64 * 1024;
|
|
7897
|
+
var IDENTITY_OPERATION_TIMEOUT_MS = 15e3;
|
|
7898
|
+
var pendingAliases = /* @__PURE__ */ new WeakMap();
|
|
7899
|
+
var ServeIdentityError = class extends Error {
|
|
7900
|
+
name = "ServeIdentityError";
|
|
7901
|
+
constructor(code, message, options) {
|
|
7902
|
+
super(message, options);
|
|
7903
|
+
this.code = code;
|
|
7904
|
+
}
|
|
7905
|
+
};
|
|
7906
|
+
function reserveAlias(store, alias) {
|
|
7907
|
+
let pending = pendingAliases.get(store);
|
|
7908
|
+
if (!pending) {
|
|
7909
|
+
pending = /* @__PURE__ */ new Set();
|
|
7910
|
+
pendingAliases.set(store, pending);
|
|
7911
|
+
}
|
|
7912
|
+
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`);
|
|
7913
|
+
pending.add(alias);
|
|
7914
|
+
return () => pending.delete(alias);
|
|
7915
|
+
}
|
|
7916
|
+
async function createManagedAgent(store, secrets, input, connectAgent = connect) {
|
|
7917
|
+
const alias = assertStoreName("agent name", input.name);
|
|
7918
|
+
if (!input.enrollmentToken.trim()) throw new ServeIdentityError("enrollment_required", "an enrollment token from the target team is required — a self-registered agent would be stranded in its own personal team");
|
|
7919
|
+
const releaseAlias = reserveAlias(store, alias);
|
|
7920
|
+
let registeredIdentityId;
|
|
7921
|
+
try {
|
|
7922
|
+
let apiUrl;
|
|
7923
|
+
try {
|
|
7924
|
+
apiUrl = requireSecureCredentialApiUrl(input.apiUrl);
|
|
7925
|
+
} catch (cause) {
|
|
7926
|
+
throw new ServeIdentityError("registration_failed", "registration API URL must use HTTPS or HTTP loopback", { cause });
|
|
7927
|
+
}
|
|
7928
|
+
store.reserveRegistration(alias, apiUrl);
|
|
7929
|
+
const result = await register({
|
|
7930
|
+
credentialType: "agent_key",
|
|
7931
|
+
apiUrl,
|
|
7932
|
+
enrollmentToken: input.enrollmentToken,
|
|
7933
|
+
signal: boundedIdentitySignal(input.signal)
|
|
7934
|
+
});
|
|
7935
|
+
if (result.credentials.type !== "agent_key") throw new ServeIdentityError("unsupported_credential", `registration returned credential type "${result.credentials.type}"; serve manages agent-key credentials only`);
|
|
7936
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7937
|
+
const { identityId, fingerprint, publicKey, privateKey } = result.identity;
|
|
7938
|
+
registeredIdentityId = identityId;
|
|
7939
|
+
const agentKeyReference = {
|
|
7940
|
+
provider: FILE_SECRET_PROVIDER,
|
|
7941
|
+
key: agentKeyKey(identityId)
|
|
7942
|
+
};
|
|
7943
|
+
const seedReference = {
|
|
7944
|
+
provider: FILE_SECRET_PROVIDER,
|
|
7945
|
+
key: identitySeedKey(fingerprint)
|
|
7946
|
+
};
|
|
7947
|
+
const config = {
|
|
7948
|
+
identity_id: identityId,
|
|
7949
|
+
registered_at: now,
|
|
7950
|
+
agent_key_ref: agentKeyReference,
|
|
7951
|
+
keys: {
|
|
7952
|
+
public_key: publicKey,
|
|
7953
|
+
fingerprint,
|
|
7954
|
+
private_key_ref: seedReference
|
|
7955
|
+
},
|
|
7956
|
+
endpoints: {
|
|
7957
|
+
api: result.apiUrl,
|
|
7958
|
+
mcp: deriveMcpUrl(result.apiUrl)
|
|
7959
|
+
}
|
|
7960
|
+
};
|
|
7961
|
+
store.writeAgentConfig(alias, config);
|
|
7962
|
+
await secrets.write(agentKeyReference.key, result.credentials.secret);
|
|
7963
|
+
await secrets.write(seedReference.key, privateKey);
|
|
7964
|
+
const whoami = await callWhoami(connectAgent, {
|
|
7965
|
+
agentKey: result.credentials.secret,
|
|
7966
|
+
apiUrl: result.apiUrl
|
|
7967
|
+
}, store.agentPath(alias), input.signal);
|
|
7968
|
+
assertIdentityMatches(whoami, {
|
|
7969
|
+
identityId,
|
|
7970
|
+
publicKey,
|
|
7971
|
+
fingerprint
|
|
7972
|
+
}, "authenticated whoami", `new managed agent "${alias}"`);
|
|
7973
|
+
const boundTeamId = boundTeamIdFromWhoami(whoami);
|
|
7974
|
+
const activation = {
|
|
7975
|
+
alias,
|
|
7976
|
+
source: "managed",
|
|
7977
|
+
identityId,
|
|
7978
|
+
publicKey,
|
|
7979
|
+
fingerprint,
|
|
7980
|
+
...boundTeamId ? { boundTeamId } : {},
|
|
7981
|
+
createdAt: now,
|
|
7982
|
+
apiUrl: result.apiUrl
|
|
7983
|
+
};
|
|
7984
|
+
store.writeActivation(activation);
|
|
7985
|
+
return {
|
|
7986
|
+
activation,
|
|
7987
|
+
config,
|
|
7988
|
+
...boundTeamId ? { boundTeamId } : {}
|
|
7989
|
+
};
|
|
7990
|
+
} catch (cause) {
|
|
7991
|
+
if (!registeredIdentityId && cause instanceof MoltNetError && cause.statusCode !== void 0 && cause.statusCode >= 400 && cause.statusCode < 500) {
|
|
7992
|
+
store.clearPendingRegistration(alias);
|
|
7993
|
+
throw new ServeIdentityError("registration_failed", `registration for "${alias}" was rejected`, { cause });
|
|
7994
|
+
}
|
|
7995
|
+
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 });
|
|
7996
|
+
throw cause;
|
|
7997
|
+
} finally {
|
|
7998
|
+
releaseAlias();
|
|
7999
|
+
}
|
|
8000
|
+
}
|
|
8001
|
+
/** Resume a fully persisted registration or explicitly abandon local recovery. */
|
|
8002
|
+
async function reconcileManagedRegistration(store, secrets, aliasInput, action, connectAgent = connect, signal) {
|
|
8003
|
+
const alias = assertStoreName("agent name", aliasInput);
|
|
8004
|
+
const activation = store.readActivation(alias);
|
|
8005
|
+
if (activation) {
|
|
8006
|
+
const config = store.readAgentConfig(alias);
|
|
8007
|
+
if (!config) throw new ServeIdentityError("registration_incomplete", `managed activation for "${alias}" is missing its canonical config`);
|
|
8008
|
+
return {
|
|
8009
|
+
activation,
|
|
8010
|
+
config
|
|
8011
|
+
};
|
|
8012
|
+
}
|
|
8013
|
+
if (!store.hasPendingRegistration(alias)) throw new ServeIdentityError("config_not_found", `agent "${alias}" has no pending registration to reconcile`);
|
|
8014
|
+
const config = store.readAgentConfig(alias);
|
|
8015
|
+
if (action === "abandon") {
|
|
8016
|
+
store.removeAgentConfig(alias);
|
|
8017
|
+
store.clearPendingRegistration(alias);
|
|
8018
|
+
return null;
|
|
8019
|
+
}
|
|
8020
|
+
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`);
|
|
8021
|
+
const [agentKey, privateKeyState] = await Promise.all([secrets.read(config.agent_key_ref.key), secrets.probe(config.keys.private_key_ref.key)]);
|
|
8022
|
+
if (!agentKey || privateKeyState !== "present") throw new ServeIdentityError("registration_incomplete", `pending registration for "${alias}" is missing persisted secret material`);
|
|
8023
|
+
const identity = identityFromConfig(config);
|
|
8024
|
+
const apiUrl = requireConfigApiUrl(config, store.agentPath(alias));
|
|
8025
|
+
const whoami = await callWhoami(connectAgent, {
|
|
8026
|
+
agentKey,
|
|
8027
|
+
apiUrl
|
|
8028
|
+
}, store.agentPath(alias), signal);
|
|
8029
|
+
assertIdentityMatches(whoami, identity, "authenticated whoami", `pending registration "${alias}" config`);
|
|
8030
|
+
const boundTeamId = boundTeamIdFromWhoami(whoami);
|
|
8031
|
+
const recovered = {
|
|
8032
|
+
alias,
|
|
8033
|
+
source: "managed",
|
|
8034
|
+
...identity,
|
|
8035
|
+
...boundTeamId ? { boundTeamId } : {},
|
|
8036
|
+
createdAt: config.registered_at,
|
|
8037
|
+
apiUrl
|
|
8038
|
+
};
|
|
8039
|
+
store.writeActivation(recovered);
|
|
8040
|
+
return {
|
|
8041
|
+
activation: recovered,
|
|
8042
|
+
config,
|
|
8043
|
+
...recovered.boundTeamId ? { boundTeamId: recovered.boundTeamId } : {}
|
|
8044
|
+
};
|
|
8045
|
+
}
|
|
8046
|
+
async function attachExternalAgent(store, secretProviders, input, connectAgent = connect) {
|
|
8047
|
+
const alias = assertStoreName("agent name", input.name);
|
|
8048
|
+
const releaseAlias = reserveAlias(store, alias);
|
|
8049
|
+
try {
|
|
8050
|
+
if (!isAbsolute(input.configDir)) throw new ServeIdentityError("config_not_found", "external configDir must be an absolute path");
|
|
8051
|
+
const configPath = join(input.configDir, "moltnet.json");
|
|
8052
|
+
externalAgentLocation(configPath);
|
|
8053
|
+
const config = await readCurrentConfig(configPath);
|
|
8054
|
+
const configApiUrl = requireTrustedConfigApiUrl(config, configPath);
|
|
8055
|
+
const effectiveApiUrl = requireTrustedApiOverride(input.apiUrl, configApiUrl, configPath);
|
|
8056
|
+
const whoami = await authenticateConfig(input.configDir, effectiveApiUrl, secretProviders, connectAgent, input.signal);
|
|
8057
|
+
const identity = identityFromConfig(config);
|
|
8058
|
+
assertIdentityMatches(identity, whoami, `external config ${configPath}`, "authenticated whoami");
|
|
8059
|
+
const boundTeamId = boundTeamIdFromWhoami(whoami);
|
|
8060
|
+
const activation = {
|
|
8061
|
+
alias,
|
|
8062
|
+
source: "external",
|
|
8063
|
+
...identity,
|
|
8064
|
+
...boundTeamId ? { boundTeamId } : {},
|
|
8065
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8066
|
+
configPath,
|
|
8067
|
+
configApiUrl,
|
|
8068
|
+
...input.apiUrl ? { apiUrl: input.apiUrl } : {}
|
|
8069
|
+
};
|
|
8070
|
+
store.writeActivation(activation);
|
|
8071
|
+
return {
|
|
8072
|
+
activation,
|
|
8073
|
+
config,
|
|
8074
|
+
...activation.boundTeamId ? { boundTeamId: activation.boundTeamId } : {}
|
|
8075
|
+
};
|
|
8076
|
+
} finally {
|
|
8077
|
+
releaseAlias();
|
|
8078
|
+
}
|
|
8079
|
+
}
|
|
8080
|
+
/** Load and authenticate the current config, then compare all pinned fields. */
|
|
8081
|
+
async function verifyAgentActivation(store, alias, managedSecretProviders, externalSecretProviders, connectAgent = connect, signal) {
|
|
8082
|
+
const activation = requireActivation(store, alias);
|
|
8083
|
+
const verified = activation.source === "managed" ? await verifyManagedActivation(store, activation, managedSecretProviders, connectAgent, signal) : await verifyExternalActivation(activation, externalSecretProviders, connectAgent, signal);
|
|
8084
|
+
assertIdentityMatches(verified.whoami, activation, "authenticated whoami", `agent "${activation.alias}" pinned activation`);
|
|
8085
|
+
const boundTeamId = boundTeamIdFromWhoami(verified.whoami);
|
|
8086
|
+
if (activation.boundTeamId !== boundTeamId) throw new ServeIdentityError("verification_failed", `authenticated whoami team binding does not match agent "${activation.alias}" pinned activation`);
|
|
8087
|
+
return {
|
|
8088
|
+
activation,
|
|
8089
|
+
config: verified.config,
|
|
8090
|
+
...boundTeamId ? { boundTeamId } : {}
|
|
8091
|
+
};
|
|
8092
|
+
}
|
|
8093
|
+
async function verifyManagedActivation(store, activation, secretProviders, connectAgent, signal) {
|
|
8094
|
+
const configPath = store.agentPath(activation.alias);
|
|
8095
|
+
const config = await readCurrentConfig(configPath);
|
|
8096
|
+
assertActivatedConfig(config, activation, configPath, requireConfigApiUrl(config, configPath), activation.apiUrl);
|
|
8097
|
+
let agentKey;
|
|
8098
|
+
try {
|
|
8099
|
+
agentKey = await resolveAgentKey(config, secretProviders);
|
|
8100
|
+
} catch (cause) {
|
|
8101
|
+
throw verificationError(`could not resolve the managed agent key for "${activation.alias}"`, cause);
|
|
8102
|
+
}
|
|
8103
|
+
if (!agentKey) throw new ServeIdentityError("verification_failed", `managed config for "${activation.alias}" has no agent_key_ref`);
|
|
8104
|
+
return {
|
|
8105
|
+
config,
|
|
8106
|
+
whoami: await callWhoami(connectAgent, {
|
|
8107
|
+
agentKey,
|
|
8108
|
+
apiUrl: activation.apiUrl
|
|
8109
|
+
}, configPath, signal)
|
|
8110
|
+
};
|
|
8111
|
+
}
|
|
8112
|
+
async function verifyExternalActivation(activation, secretProviders, connectAgent, signal) {
|
|
8113
|
+
externalAgentLocation(activation.configPath);
|
|
8114
|
+
assertTrustedConfigApiUrl(activation.configApiUrl);
|
|
8115
|
+
const config = await readCurrentConfig(activation.configPath);
|
|
8116
|
+
assertActivatedConfig(config, activation, activation.configPath, requireTrustedConfigApiUrl(config, activation.configPath), activation.configApiUrl);
|
|
8117
|
+
const effectiveApiUrl = requireTrustedApiOverride(activation.apiUrl, activation.configApiUrl, activation.configPath);
|
|
8118
|
+
return {
|
|
8119
|
+
config,
|
|
8120
|
+
whoami: await authenticateConfig(dirname(activation.configPath), effectiveApiUrl, secretProviders, connectAgent, signal)
|
|
8121
|
+
};
|
|
8122
|
+
}
|
|
8123
|
+
/** Never let request or activation metadata redirect persisted credentials. */
|
|
8124
|
+
function requireTrustedApiOverride(override, configApiUrl, configPath) {
|
|
8125
|
+
if (!override) return configApiUrl;
|
|
8126
|
+
if (override !== configApiUrl) throw new ServeIdentityError("verification_failed", `API override for ${configPath} does not match its configured endpoint`);
|
|
8127
|
+
return configApiUrl;
|
|
8128
|
+
}
|
|
8129
|
+
function assertActivatedConfig(config, activation, configPath, currentApiUrl, pinnedApiUrl) {
|
|
8130
|
+
if (currentApiUrl !== pinnedApiUrl) throw new ServeIdentityError("verification_failed", `agent config at ${configPath} API endpoint does not match its pinned activation`);
|
|
8131
|
+
assertIdentityMatches(identityFromConfig(config), activation, configPath, `agent "${activation.alias}" pinned activation`);
|
|
8132
|
+
}
|
|
8133
|
+
function requireConfigApiUrl(config, configPath) {
|
|
8134
|
+
const apiUrl = config?.endpoints?.api?.trim();
|
|
8135
|
+
if (!apiUrl) throw new ServeIdentityError("verification_failed", `agent config at ${configPath} is missing endpoints.api`);
|
|
8136
|
+
return apiUrl;
|
|
8137
|
+
}
|
|
8138
|
+
function requireTrustedConfigApiUrl(config, configPath) {
|
|
8139
|
+
const apiUrl = requireConfigApiUrl(config, configPath);
|
|
8140
|
+
try {
|
|
8141
|
+
assertTrustedConfigApiUrl(apiUrl);
|
|
8142
|
+
return apiUrl;
|
|
8143
|
+
} catch (cause) {
|
|
8144
|
+
throw verificationError(`agent config at ${configPath} has an untrusted endpoints.api`, cause);
|
|
8145
|
+
}
|
|
8146
|
+
}
|
|
8147
|
+
async function readCurrentConfig(configPath) {
|
|
8148
|
+
let file;
|
|
8149
|
+
try {
|
|
8150
|
+
file = await open(configPath, "r");
|
|
8151
|
+
} catch (cause) {
|
|
8152
|
+
throw new ServeIdentityError("config_not_found", `agent config is missing at ${configPath}`, { cause });
|
|
8153
|
+
}
|
|
8154
|
+
let raw;
|
|
8155
|
+
try {
|
|
8156
|
+
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`);
|
|
8157
|
+
const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
|
|
8158
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
8159
|
+
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`);
|
|
8160
|
+
raw = buffer.toString("utf8", 0, bytesRead);
|
|
8161
|
+
} finally {
|
|
8162
|
+
await file.close();
|
|
8163
|
+
}
|
|
8164
|
+
try {
|
|
8165
|
+
return JSON.parse(raw);
|
|
8166
|
+
} catch (cause) {
|
|
8167
|
+
throw verificationError(`agent config is not valid JSON at ${configPath}`, cause);
|
|
8168
|
+
}
|
|
8169
|
+
}
|
|
8170
|
+
/** Resolve the unchanged daemon CLI's root/name pair for an external config. */
|
|
8171
|
+
function externalAgentLocation(configPath) {
|
|
8172
|
+
const configDir = dirname(configPath);
|
|
8173
|
+
const moltnetDir = dirname(configDir);
|
|
8174
|
+
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`);
|
|
8175
|
+
return {
|
|
8176
|
+
agentName: assertStoreName("external config agent name", basename(configDir)),
|
|
8177
|
+
agentRoot: dirname(moltnetDir)
|
|
8178
|
+
};
|
|
8179
|
+
}
|
|
8180
|
+
async function authenticateConfig(configDir, apiUrl, secretProviders, connectAgent, signal) {
|
|
8181
|
+
return callWhoami(connectAgent, {
|
|
8182
|
+
configDir,
|
|
8183
|
+
...apiUrl ? { apiUrl } : {},
|
|
8184
|
+
secretProviders
|
|
8185
|
+
}, configDir, signal);
|
|
8186
|
+
}
|
|
8187
|
+
async function callWhoami(connectAgent, options, source, signal) {
|
|
8188
|
+
try {
|
|
8189
|
+
const boundedSignal = boundedIdentitySignal(signal);
|
|
8190
|
+
return await (await connectAgent({
|
|
8191
|
+
...options,
|
|
8192
|
+
signal: boundedSignal
|
|
8193
|
+
})).agents.whoami({ signal: boundedSignal });
|
|
8194
|
+
} catch (cause) {
|
|
8195
|
+
throw verificationError(`could not authenticate ${source} against the API`, cause);
|
|
8196
|
+
}
|
|
8197
|
+
}
|
|
8198
|
+
function boundedIdentitySignal(signal) {
|
|
8199
|
+
const timeout = AbortSignal.timeout(IDENTITY_OPERATION_TIMEOUT_MS);
|
|
8200
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
8201
|
+
}
|
|
8202
|
+
function identityFromConfig(config) {
|
|
8203
|
+
const identityId = config?.identity_id?.trim();
|
|
8204
|
+
const publicKey = config?.keys?.public_key?.trim();
|
|
8205
|
+
const fingerprint = config?.keys?.fingerprint?.trim();
|
|
8206
|
+
if (!identityId || !publicKey || !fingerprint) throw new ServeIdentityError("verification_failed", "agent config is missing canonical identity_id, keys.public_key, or keys.fingerprint");
|
|
8207
|
+
return {
|
|
8208
|
+
identityId,
|
|
8209
|
+
publicKey,
|
|
8210
|
+
fingerprint
|
|
8211
|
+
};
|
|
8212
|
+
}
|
|
8213
|
+
function assertIdentityMatches(current, expected, currentLabel, expectedLabel) {
|
|
8214
|
+
const assessment = assessIdentityPin(current, expected);
|
|
8215
|
+
if (!assessment.ok) throw new ServeIdentityError("verification_failed", `${currentLabel} ${assessment.label} does not match ${expectedLabel}`);
|
|
8216
|
+
}
|
|
8217
|
+
function verificationError(message, cause) {
|
|
8218
|
+
return new ServeIdentityError("verification_failed", message, { cause });
|
|
8219
|
+
}
|
|
8220
|
+
/** Non-secret projection preserving the existing `/v1` response shape. */
|
|
8221
|
+
function publicAgentView(store, activation) {
|
|
8222
|
+
if (activation.source === "managed") {
|
|
8223
|
+
const config = store.readAgentConfig(activation.alias);
|
|
8224
|
+
return {
|
|
8225
|
+
kind: "managed",
|
|
8226
|
+
agentName: activation.alias,
|
|
8227
|
+
identityId: activation.identityId,
|
|
8228
|
+
fingerprint: activation.fingerprint,
|
|
8229
|
+
...activation.boundTeamId ? { teamId: activation.boundTeamId } : {},
|
|
8230
|
+
apiUrl: activation.apiUrl,
|
|
8231
|
+
createdAt: activation.createdAt,
|
|
8232
|
+
hasAgentKey: Boolean(config?.agent_key_ref),
|
|
8233
|
+
hasPrivateKey: Boolean(config?.keys.private_key_ref)
|
|
8234
|
+
};
|
|
8235
|
+
}
|
|
8236
|
+
return {
|
|
8237
|
+
kind: "external",
|
|
8238
|
+
agentName: activation.alias,
|
|
8239
|
+
configDir: dirname(activation.configPath),
|
|
8240
|
+
...activation.apiUrl ? { apiUrl: activation.apiUrl } : {},
|
|
8241
|
+
identityId: activation.identityId,
|
|
8242
|
+
fingerprint: activation.fingerprint,
|
|
8243
|
+
...activation.boundTeamId ? { teamId: activation.boundTeamId } : {},
|
|
8244
|
+
createdAt: activation.createdAt
|
|
8245
|
+
};
|
|
8246
|
+
}
|
|
8247
|
+
function boundTeamIdFromWhoami(whoami) {
|
|
8248
|
+
return whoami.credentialBinding?.bindingScope === "team" ? whoami.credentialBinding.boundTeamId ?? void 0 : void 0;
|
|
8249
|
+
}
|
|
8250
|
+
function requireActivation(store, alias) {
|
|
8251
|
+
const activation = store.readActivation(alias);
|
|
8252
|
+
if (!activation) throw new ServeStoreError("not_found", `agent "${alias}" is not configured`);
|
|
8253
|
+
return activation;
|
|
8254
|
+
}
|
|
8255
|
+
//#endregion
|
|
8256
|
+
//#region src/lib/serve/runs.ts
|
|
8257
|
+
var STOP_GRACE_MS = 1e4;
|
|
8258
|
+
var STOP_FORCE_MS = 2e3;
|
|
8259
|
+
var DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
8260
|
+
var DEFAULT_MAX_COMPLETED_RUNS = 100;
|
|
8261
|
+
var DEFAULT_MAX_RUN_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
8262
|
+
var DEFAULT_MAX_RUN_STORAGE_BYTES = 512 * 1024 * 1024;
|
|
8263
|
+
var DEFAULT_MAX_ACTIVE_RUNS = 16;
|
|
8264
|
+
var DEFAULT_MAX_ACTIVE_RUNS_PER_AGENT = 4;
|
|
8265
|
+
var LOG_TRUNCATION_MARKER = Buffer.from("[truncated]\n");
|
|
8266
|
+
var INHERITED_ENV_NAMES = new Set([
|
|
8267
|
+
"COLORTERM",
|
|
8268
|
+
"FORCE_COLOR",
|
|
8269
|
+
"LANG",
|
|
8270
|
+
"LC_ALL",
|
|
8271
|
+
"LC_CTYPE",
|
|
8272
|
+
"LOG_LEVEL",
|
|
8273
|
+
"NO_COLOR",
|
|
8274
|
+
"PATH",
|
|
8275
|
+
"SSL_CERT_DIR",
|
|
8276
|
+
"SSL_CERT_FILE",
|
|
8277
|
+
"TEMP",
|
|
8278
|
+
"TERM",
|
|
8279
|
+
"TMP",
|
|
8280
|
+
"TMPDIR",
|
|
8281
|
+
"TZ"
|
|
8282
|
+
]);
|
|
8283
|
+
var INHERITED_MOLTNET_ENV_NAMES = new Set([
|
|
8284
|
+
"MOLTNET_CLI_LINUX_BINARY",
|
|
8285
|
+
"MOLTNET_CREDENTIAL_BINDINGS",
|
|
8286
|
+
"MOLTNET_CREDENTIAL_ENFORCEMENT",
|
|
8287
|
+
"MOLTNET_DIARY_ID",
|
|
8288
|
+
"MOLTNET_GIT_AUTHOR",
|
|
8289
|
+
"MOLTNET_OTEL_ENDPOINT",
|
|
8290
|
+
"MOLTNET_PI_VM_INTEGRATION",
|
|
8291
|
+
"MOLTNET_PROFILE_CREDENTIAL_REQUIREMENTS",
|
|
8292
|
+
"MOLTNET_SIGNER_URL",
|
|
8293
|
+
"MOLTNET_TRACE_IDLE_POLLING"
|
|
8294
|
+
]);
|
|
8295
|
+
var ServeRunError = class extends Error {
|
|
8296
|
+
name = "ServeRunError";
|
|
8297
|
+
constructor(code, message) {
|
|
8298
|
+
super(message);
|
|
8299
|
+
this.code = code;
|
|
8300
|
+
}
|
|
8301
|
+
};
|
|
8302
|
+
function validateRunSpec(spec) {
|
|
8303
|
+
if (spec.mode !== "poll" && spec.mode !== "drain") throw new ServeRunError("invalid_spec", "mode must be poll or drain");
|
|
8304
|
+
if (!spec.agent) throw new ServeRunError("invalid_spec", "agent is required");
|
|
8305
|
+
if (!spec.teamId) throw new ServeRunError("invalid_spec", "teamId is required");
|
|
8306
|
+
if (!Array.isArray(spec.profiles) || spec.profiles.length === 0) throw new ServeRunError("invalid_spec", "at least one profile is required");
|
|
8307
|
+
if (!Array.isArray(spec.taskTypes) || spec.taskTypes.length === 0) throw new ServeRunError("invalid_spec", "at least one task type is required");
|
|
8308
|
+
}
|
|
8309
|
+
var RunManager = class {
|
|
8310
|
+
active = /* @__PURE__ */ new Map();
|
|
8311
|
+
startingByAgent = /* @__PURE__ */ new Map();
|
|
8312
|
+
starting = 0;
|
|
8313
|
+
closing = false;
|
|
8314
|
+
constructor(options) {
|
|
8315
|
+
this.options = options;
|
|
8316
|
+
this.reconcileInterruptedRuns();
|
|
8317
|
+
this.pruneCompletedRuns();
|
|
8318
|
+
}
|
|
8319
|
+
get store() {
|
|
8320
|
+
return this.options.store;
|
|
8321
|
+
}
|
|
8322
|
+
entrypoint() {
|
|
8323
|
+
return this.options.entrypoint ?? {
|
|
8324
|
+
execPath: process.execPath,
|
|
8325
|
+
execArgv: process.execArgv,
|
|
8326
|
+
scriptPath: process.argv[1] ?? ""
|
|
8327
|
+
};
|
|
8328
|
+
}
|
|
8329
|
+
/** Assemble child env + args for a run. Exposed for tests. */
|
|
8330
|
+
async prepare(spec, agent, piDir, providers = this.store.readProviders()) {
|
|
8331
|
+
const { activation, config } = agent;
|
|
8332
|
+
const homeDir = join(dirname(piDir), "home");
|
|
8333
|
+
const env = {
|
|
8334
|
+
HOME: homeDir,
|
|
8335
|
+
PI_CODING_AGENT_DIR: piDir,
|
|
8336
|
+
XDG_CACHE_HOME: join(homeDir, ".cache"),
|
|
8337
|
+
XDG_CONFIG_HOME: join(homeDir, ".config"),
|
|
8338
|
+
XDG_DATA_HOME: join(homeDir, ".local", "share"),
|
|
8339
|
+
MOLTNET_TEAM_ID: spec.teamId
|
|
8340
|
+
};
|
|
8341
|
+
const target = activation.source === "managed" ? {
|
|
8342
|
+
agentName: activation.alias,
|
|
8343
|
+
cwd: dirname(piDir),
|
|
8344
|
+
extraArgs: []
|
|
8345
|
+
} : (() => {
|
|
8346
|
+
const { agentName, agentRoot } = externalAgentLocation(activation.configPath);
|
|
8347
|
+
return {
|
|
8348
|
+
agentName,
|
|
8349
|
+
cwd: agentRoot,
|
|
8350
|
+
extraArgs: ["--agent-root", agentRoot]
|
|
8351
|
+
};
|
|
8352
|
+
})();
|
|
8353
|
+
const args = [
|
|
8354
|
+
spec.mode,
|
|
8355
|
+
"--agent",
|
|
8356
|
+
target.agentName,
|
|
8357
|
+
"--team",
|
|
8358
|
+
spec.teamId,
|
|
8359
|
+
...spec.profiles.flatMap((profile) => ["--profile", profile]),
|
|
8360
|
+
"--task-types",
|
|
8361
|
+
spec.taskTypes.join(","),
|
|
8362
|
+
...target.extraArgs
|
|
8363
|
+
];
|
|
8364
|
+
if (activation.source === "managed") {
|
|
8365
|
+
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`);
|
|
8366
|
+
env["MOLTNET_API_URL"] = activation.apiUrl;
|
|
8367
|
+
env["MOLTNET_AGENT_KEY_REF"] = formatSecretReferenceString(config.agent_key_ref);
|
|
8368
|
+
env["MOLTNET_PRIVATE_KEY_REF"] = formatSecretReferenceString(config.keys.private_key_ref);
|
|
8369
|
+
env["MOLTNET_SECRET_ROOT"] = this.store.secretsDir;
|
|
8370
|
+
} else {
|
|
8371
|
+
env["MOLTNET_API_URL"] = activation.apiUrl ?? activation.configApiUrl;
|
|
8372
|
+
try {
|
|
8373
|
+
const agentKey = await resolveAgentKey(config, this.options.externalSecretProviders);
|
|
8374
|
+
if (agentKey) {
|
|
8375
|
+
env["MOLTNET_AGENT_KEY"] = agentKey;
|
|
8376
|
+
env["MOLTNET_PRIVATE_KEY"] = await resolveIdentitySeed(config, this.options.externalSecretProviders);
|
|
8377
|
+
} else if (config.oauth2?.client_secret_ref) {
|
|
8378
|
+
env["MOLTNET_CLIENT_ID"] = config.oauth2.client_id;
|
|
8379
|
+
env["MOLTNET_CLIENT_SECRET"] = await resolveOAuth2ClientSecret(config, this.options.externalSecretProviders);
|
|
8380
|
+
}
|
|
8381
|
+
if (!agentKey && config.keys.private_key_ref) env["MOLTNET_PRIVATE_KEY"] = await resolveIdentitySeed(config, this.options.externalSecretProviders);
|
|
8382
|
+
} catch {
|
|
8383
|
+
throw new ServeRunError("invalid_spec", `external credentials for "${activation.alias}" could not be projected`);
|
|
8384
|
+
}
|
|
8385
|
+
}
|
|
8386
|
+
env["MOLTNET_EXPECTED_IDENTITY_ID"] = activation.identityId;
|
|
8387
|
+
env["MOLTNET_EXPECTED_PUBLIC_KEY"] = activation.publicKey;
|
|
8388
|
+
env["MOLTNET_EXPECTED_FINGERPRINT"] = activation.fingerprint;
|
|
8389
|
+
env["MOLTNET_SUPERVISED_RUN"] = "1";
|
|
8390
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
8391
|
+
if (!provider.apiKeyRef) continue;
|
|
8392
|
+
let value;
|
|
8393
|
+
try {
|
|
8394
|
+
value = await this.options.secretProviders.resolve(parseSecretReferenceString(provider.apiKeyRef));
|
|
8395
|
+
} catch {
|
|
8396
|
+
throw new ServeRunError("invalid_spec", `provider "${providerId}" API key could not be resolved`);
|
|
8397
|
+
}
|
|
8398
|
+
env[provider.envName] = value;
|
|
8399
|
+
}
|
|
8400
|
+
return {
|
|
8401
|
+
args,
|
|
8402
|
+
env,
|
|
8403
|
+
cwd: target.cwd
|
|
8404
|
+
};
|
|
8405
|
+
}
|
|
8406
|
+
async start(spec, signal) {
|
|
8407
|
+
validateRunSpec(spec);
|
|
8408
|
+
const releaseStart = this.reserveStart(spec.agent);
|
|
8409
|
+
try {
|
|
8410
|
+
return await this.startReserved(spec, signal);
|
|
8411
|
+
} finally {
|
|
8412
|
+
releaseStart();
|
|
8413
|
+
}
|
|
8414
|
+
}
|
|
8415
|
+
async startReserved(spec, signal) {
|
|
8416
|
+
this.assertStartOpen(signal);
|
|
8417
|
+
const agent = await (this.options.verifyActivationImpl ?? verifyAgentActivation)(this.store, spec.agent, this.options.secretProviders, this.options.externalSecretProviders, void 0, signal);
|
|
8418
|
+
this.assertStartOpen(signal);
|
|
8419
|
+
if (agent.boundTeamId && agent.boundTeamId !== spec.teamId) throw new ServeRunError("invalid_spec", `agent "${spec.agent}" has a key bound to team ${agent.boundTeamId}; start the run with that team, or create a new agent with an enrollment token from team ${spec.teamId}`);
|
|
8420
|
+
const id = `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
8421
|
+
const runDir = this.store.runDir(id);
|
|
8422
|
+
const piDir = join(runDir, "pi");
|
|
8423
|
+
const providers = this.store.readProviders();
|
|
8424
|
+
const { args, env, cwd } = await this.prepare(spec, agent, piDir, providers);
|
|
8425
|
+
this.assertStartOpen(signal);
|
|
8426
|
+
let child;
|
|
8427
|
+
let logStream;
|
|
8428
|
+
let logLimiter;
|
|
8429
|
+
try {
|
|
8430
|
+
const { logPath } = this.store.createRunDir(id);
|
|
8431
|
+
for (const dir of [
|
|
8432
|
+
env.HOME,
|
|
8433
|
+
env.XDG_CACHE_HOME,
|
|
8434
|
+
env.XDG_CONFIG_HOME,
|
|
8435
|
+
env.XDG_DATA_HOME
|
|
8436
|
+
]) mkdirSync(dir, {
|
|
8437
|
+
recursive: true,
|
|
8438
|
+
mode: 448
|
|
8439
|
+
});
|
|
8440
|
+
writePiConfig({
|
|
8441
|
+
piDir,
|
|
8442
|
+
providers: Object.fromEntries(Object.entries(providers).map(([providerId, provider]) => [providerId, {
|
|
8443
|
+
api: provider.api,
|
|
8444
|
+
...provider.apiKeyRef ? { apiKeyEnvRef: `$${provider.envName}` } : {},
|
|
8445
|
+
baseUrl: provider.baseUrl,
|
|
8446
|
+
models: provider.models
|
|
8447
|
+
}]))
|
|
8448
|
+
});
|
|
8449
|
+
try {
|
|
8450
|
+
(this.options.symlinkImpl ?? symlinkSync)(this.store.piAuthJsonPath, join(piDir, "auth.json"));
|
|
8451
|
+
} catch (cause) {
|
|
8452
|
+
throw new ServeStoreError("io_error", "could not link subscription credentials into the run", { cause });
|
|
8453
|
+
}
|
|
8454
|
+
const entry = this.entrypoint();
|
|
8455
|
+
logStream = createWriteStream(logPath, {
|
|
8456
|
+
fd: openSync(logPath, "a", 384),
|
|
8457
|
+
autoClose: true
|
|
8458
|
+
});
|
|
8459
|
+
logLimiter = createByteLimitTransform(this.options.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES, () => this.log("warn", "serve run log output truncated", {
|
|
8460
|
+
...runContext(id, spec.agent, child),
|
|
8461
|
+
transition: "log_truncated"
|
|
8462
|
+
}));
|
|
8463
|
+
logLimiter.pipe(logStream);
|
|
8464
|
+
logStream.on("error", (error) => {
|
|
8465
|
+
this.log("error", "serve run log stream failed", {
|
|
8466
|
+
...runContext(id, spec.agent, child),
|
|
8467
|
+
transition: "log_failed",
|
|
8468
|
+
...safeRunError(error)
|
|
8469
|
+
});
|
|
8470
|
+
child?.kill("SIGKILL");
|
|
8471
|
+
});
|
|
8472
|
+
const spawnImpl = this.options.spawnImpl ?? spawn;
|
|
8473
|
+
this.assertStartOpen(signal);
|
|
8474
|
+
child = spawnImpl(entry.execPath, [
|
|
8475
|
+
...entry.execArgv,
|
|
8476
|
+
entry.scriptPath,
|
|
8477
|
+
...args
|
|
8478
|
+
], {
|
|
8479
|
+
cwd,
|
|
8480
|
+
env: {
|
|
8481
|
+
...sanitizeChildBaseEnv(this.options.baseEnv),
|
|
8482
|
+
...env
|
|
8483
|
+
},
|
|
8484
|
+
stdio: [
|
|
8485
|
+
"ignore",
|
|
8486
|
+
"pipe",
|
|
8487
|
+
"pipe",
|
|
8488
|
+
"ipc"
|
|
8489
|
+
],
|
|
8490
|
+
detached: false
|
|
8491
|
+
});
|
|
8492
|
+
this.active.set(id, {
|
|
8493
|
+
agent: spec.agent,
|
|
8494
|
+
child,
|
|
8495
|
+
stopRequested: false
|
|
8496
|
+
});
|
|
8497
|
+
child.stdout?.pipe(logLimiter, { end: false });
|
|
8498
|
+
child.stderr?.pipe(logLimiter, { end: false });
|
|
8499
|
+
const record = {
|
|
8500
|
+
...spec,
|
|
8501
|
+
id,
|
|
8502
|
+
status: "running",
|
|
8503
|
+
pid: child.pid,
|
|
8504
|
+
startedAt: (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
8505
|
+
};
|
|
8506
|
+
child.once("exit", (code, signal) => {
|
|
8507
|
+
const activeRun = this.active.get(id);
|
|
8508
|
+
this.active.delete(id);
|
|
8509
|
+
logLimiter?.end();
|
|
8510
|
+
const status = activeRun?.stopRequested ? "stopped" : code === 0 ? "exited" : "failed";
|
|
8511
|
+
this.log(status === "failed" ? "error" : "info", "serve run exited", {
|
|
8512
|
+
...runContext(id, spec.agent, child),
|
|
8513
|
+
transition: status,
|
|
8514
|
+
exitCode: code,
|
|
8515
|
+
signal
|
|
8516
|
+
});
|
|
8517
|
+
this.persistRunCompletion(id, spec.agent, {
|
|
8518
|
+
status,
|
|
8519
|
+
exitCode: code
|
|
8520
|
+
});
|
|
8521
|
+
});
|
|
8522
|
+
child.once("error", (error) => {
|
|
8523
|
+
this.active.delete(id);
|
|
8524
|
+
logLimiter?.end();
|
|
8525
|
+
this.log("error", "serve run child process failed", {
|
|
8526
|
+
...runContext(id, spec.agent, child),
|
|
8527
|
+
transition: "spawn_failed",
|
|
8528
|
+
...safeRunError(error)
|
|
8529
|
+
});
|
|
8530
|
+
this.persistRunCompletion(id, spec.agent, { status: "failed" });
|
|
8531
|
+
});
|
|
8532
|
+
this.store.writeRun(record);
|
|
8533
|
+
this.log("info", "serve run started", {
|
|
8534
|
+
...runContext(id, spec.agent, child),
|
|
8535
|
+
transition: "running"
|
|
8536
|
+
});
|
|
8537
|
+
return record;
|
|
8538
|
+
} catch (cause) {
|
|
8539
|
+
logLimiter?.destroy();
|
|
8540
|
+
logStream?.destroy();
|
|
8541
|
+
if (child && !await terminateChild(child)) throw new AggregateError([cause], `run "${id}" failed to start and its child did not exit after SIGKILL`);
|
|
8542
|
+
this.active.delete(id);
|
|
8543
|
+
rmSync(runDir, {
|
|
8544
|
+
recursive: true,
|
|
8545
|
+
force: true
|
|
8546
|
+
});
|
|
8547
|
+
this.log("error", "serve run failed to start", {
|
|
8548
|
+
...runContext(id, spec.agent, child),
|
|
8549
|
+
transition: "start_failed",
|
|
8550
|
+
...safeRunError(cause)
|
|
8551
|
+
});
|
|
8552
|
+
throw cause;
|
|
8553
|
+
}
|
|
8554
|
+
}
|
|
8555
|
+
stop(id) {
|
|
8556
|
+
const record = this.store.readRun(id);
|
|
8557
|
+
if (!record) throw new ServeRunError("run_not_found", `run "${id}" was not found`);
|
|
8558
|
+
const activeRun = this.active.get(id);
|
|
8559
|
+
if (!activeRun) throw new ServeRunError("run_not_active", `run "${id}" is not running`);
|
|
8560
|
+
activeRun.stopRequested = true;
|
|
8561
|
+
this.log("info", "serve run stop requested", {
|
|
8562
|
+
...runContext(id, record.agent, activeRun.child),
|
|
8563
|
+
transition: "stopping",
|
|
8564
|
+
signal: "SIGTERM"
|
|
8565
|
+
});
|
|
8566
|
+
activeRun.child.kill("SIGTERM");
|
|
8567
|
+
setTimeout(() => {
|
|
8568
|
+
if (this.active.has(id)) activeRun.child.kill("SIGKILL");
|
|
8569
|
+
}, STOP_GRACE_MS).unref();
|
|
8570
|
+
return record;
|
|
8571
|
+
}
|
|
8572
|
+
/** Live status merged over the persisted record. */
|
|
8573
|
+
status(id) {
|
|
8574
|
+
const record = this.store.readRun(id);
|
|
8575
|
+
if (!record) throw new ServeStoreError("not_found", `run "${id}" was not found`);
|
|
8576
|
+
return record;
|
|
8577
|
+
}
|
|
8578
|
+
list(limit = Number.POSITIVE_INFINITY) {
|
|
8579
|
+
if (!Number.isFinite(limit)) return this.store.listRuns();
|
|
8580
|
+
const active = [...this.active.keys()].map((id) => this.store.readRun(id)).filter((record) => record !== null).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
8581
|
+
const activeIds = new Set(active.map((record) => record.id));
|
|
8582
|
+
const history = this.store.listRuns(limit + active.length).filter((record) => !activeIds.has(record.id)).slice(0, limit);
|
|
8583
|
+
return [...active, ...history];
|
|
8584
|
+
}
|
|
8585
|
+
isActive(id) {
|
|
8586
|
+
return this.active.has(id);
|
|
8587
|
+
}
|
|
8588
|
+
async stopAll() {
|
|
8589
|
+
this.closing = true;
|
|
8590
|
+
const waits = [];
|
|
8591
|
+
for (const [id, activeRun] of this.active) {
|
|
8592
|
+
activeRun.stopRequested = true;
|
|
8593
|
+
waits.push(new Promise((resolvePromise, rejectPromise) => {
|
|
8594
|
+
let settled = false;
|
|
8595
|
+
const finish = () => {
|
|
8596
|
+
if (settled) return;
|
|
8597
|
+
settled = true;
|
|
8598
|
+
clearTimeout(killTimer);
|
|
8599
|
+
clearTimeout(forceTimer);
|
|
8600
|
+
resolvePromise();
|
|
8601
|
+
};
|
|
8602
|
+
activeRun.child.once("exit", finish);
|
|
8603
|
+
activeRun.child.kill("SIGTERM");
|
|
8604
|
+
const killTimer = setTimeout(() => {
|
|
8605
|
+
activeRun.child.kill("SIGKILL");
|
|
8606
|
+
}, STOP_GRACE_MS);
|
|
8607
|
+
killTimer.unref();
|
|
8608
|
+
const forceTimer = setTimeout(() => {
|
|
8609
|
+
if (settled) return;
|
|
8610
|
+
settled = true;
|
|
8611
|
+
clearTimeout(killTimer);
|
|
8612
|
+
rejectPromise(/* @__PURE__ */ new Error(`run "${id}" did not exit after SIGKILL`));
|
|
8613
|
+
}, STOP_GRACE_MS + STOP_FORCE_MS);
|
|
8614
|
+
forceTimer.unref();
|
|
8615
|
+
}));
|
|
8616
|
+
}
|
|
8617
|
+
await Promise.all(waits);
|
|
8618
|
+
}
|
|
8619
|
+
forceStopAll() {
|
|
8620
|
+
this.closing = true;
|
|
8621
|
+
for (const [id, activeRun] of this.active) {
|
|
8622
|
+
activeRun.stopRequested = true;
|
|
8623
|
+
this.log("warn", "serve run force stop requested", {
|
|
8624
|
+
...runContext(id, this.store.readRun(id)?.agent ?? "unknown", activeRun.child),
|
|
8625
|
+
transition: "force_stopping",
|
|
8626
|
+
signal: "SIGKILL"
|
|
8627
|
+
});
|
|
8628
|
+
activeRun.child.kill("SIGKILL");
|
|
8629
|
+
}
|
|
8630
|
+
}
|
|
8631
|
+
reconcileInterruptedRuns() {
|
|
8632
|
+
const endedAt = (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
8633
|
+
for (const record of this.store.listRuns()) {
|
|
8634
|
+
if (record.status !== "running") continue;
|
|
8635
|
+
this.store.writeRun({
|
|
8636
|
+
...record,
|
|
8637
|
+
status: "failed",
|
|
8638
|
+
exitCode: null,
|
|
8639
|
+
endedAt
|
|
8640
|
+
});
|
|
8641
|
+
this.log("warn", "serve run interrupted by supervisor replacement", {
|
|
8642
|
+
runId: record.id,
|
|
8643
|
+
agent: record.agent,
|
|
8644
|
+
pid: record.pid,
|
|
8645
|
+
transition: "interrupted"
|
|
8646
|
+
});
|
|
8647
|
+
}
|
|
8648
|
+
}
|
|
8649
|
+
reserveStart(agent) {
|
|
8650
|
+
if (this.closing) throw new ServeRunError("invalid_spec", "serve is shutting down");
|
|
8651
|
+
const agentStarting = this.startingByAgent.get(agent) ?? 0;
|
|
8652
|
+
const agentActive = [...this.active.values()].filter((run) => run.agent === agent).length;
|
|
8653
|
+
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}"`);
|
|
8654
|
+
this.starting += 1;
|
|
8655
|
+
this.startingByAgent.set(agent, agentStarting + 1);
|
|
8656
|
+
return () => {
|
|
8657
|
+
this.starting -= 1;
|
|
8658
|
+
const remaining = (this.startingByAgent.get(agent) ?? 1) - 1;
|
|
8659
|
+
if (remaining > 0) this.startingByAgent.set(agent, remaining);
|
|
8660
|
+
else this.startingByAgent.delete(agent);
|
|
8661
|
+
};
|
|
8662
|
+
}
|
|
8663
|
+
assertStartOpen(signal) {
|
|
8664
|
+
if (this.closing || signal?.aborted) throw new ServeRunError("invalid_spec", "serve is shutting down");
|
|
8665
|
+
}
|
|
8666
|
+
pruneCompletedRuns() {
|
|
8667
|
+
const removed = this.store.pruneCompletedRuns({
|
|
8668
|
+
maxCount: this.options.maxCompletedRuns ?? DEFAULT_MAX_COMPLETED_RUNS,
|
|
8669
|
+
maxAgeMs: this.options.maxRunAgeMs ?? DEFAULT_MAX_RUN_AGE_MS,
|
|
8670
|
+
maxBytes: this.options.maxRunStorageBytes ?? DEFAULT_MAX_RUN_STORAGE_BYTES,
|
|
8671
|
+
now: this.options.now?.() ?? /* @__PURE__ */ new Date()
|
|
8672
|
+
});
|
|
8673
|
+
if (removed.length > 0) this.log("info", "serve run retention removed completed artifacts", {
|
|
8674
|
+
transition: "pruned",
|
|
8675
|
+
removedCount: removed.length
|
|
8676
|
+
});
|
|
8677
|
+
}
|
|
8678
|
+
persistRunCompletion(id, agent, update) {
|
|
8679
|
+
try {
|
|
8680
|
+
const current = this.store.readRun(id);
|
|
8681
|
+
if (current) this.store.writeRun({
|
|
8682
|
+
...current,
|
|
8683
|
+
...update,
|
|
8684
|
+
endedAt: (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
8685
|
+
});
|
|
8686
|
+
this.pruneCompletedRuns();
|
|
8687
|
+
} catch (error) {
|
|
8688
|
+
this.log("error", "serve run completion persistence failed", {
|
|
8689
|
+
runId: id,
|
|
8690
|
+
agent,
|
|
8691
|
+
transition: "persistence_failed",
|
|
8692
|
+
...safeRunError(error)
|
|
8693
|
+
});
|
|
8694
|
+
}
|
|
8695
|
+
}
|
|
8696
|
+
log(level, message, context) {
|
|
8697
|
+
this.options.logger?.[level](context, message);
|
|
8698
|
+
}
|
|
8699
|
+
};
|
|
8700
|
+
function createByteLimitTransform(limit, onTruncated) {
|
|
8701
|
+
const byteLimit = Math.max(0, Math.floor(limit));
|
|
8702
|
+
const contentLimit = Math.max(0, byteLimit - LOG_TRUNCATION_MARKER.length);
|
|
8703
|
+
let emitted = 0;
|
|
8704
|
+
let truncated = false;
|
|
8705
|
+
return new Transform({ transform(chunk, _encoding, callback) {
|
|
8706
|
+
const remaining = Math.max(0, contentLimit - emitted);
|
|
8707
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
8708
|
+
if (remaining > 0) {
|
|
8709
|
+
const selected = bytes.subarray(0, remaining);
|
|
8710
|
+
emitted += selected.length;
|
|
8711
|
+
this.push(selected);
|
|
8712
|
+
}
|
|
8713
|
+
if (!truncated && bytes.length > remaining) {
|
|
8714
|
+
truncated = true;
|
|
8715
|
+
const marker = LOG_TRUNCATION_MARKER.subarray(0, byteLimit - emitted);
|
|
8716
|
+
emitted += marker.length;
|
|
8717
|
+
this.push(marker);
|
|
8718
|
+
onTruncated();
|
|
8719
|
+
}
|
|
8720
|
+
callback();
|
|
8721
|
+
} });
|
|
8722
|
+
}
|
|
8723
|
+
function runContext(runId, agent, child) {
|
|
8724
|
+
return {
|
|
8725
|
+
runId,
|
|
8726
|
+
agent,
|
|
8727
|
+
pid: child?.pid
|
|
8728
|
+
};
|
|
8729
|
+
}
|
|
8730
|
+
function safeRunError(error) {
|
|
8731
|
+
const code = error?.code;
|
|
8732
|
+
return {
|
|
8733
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
8734
|
+
...typeof code === "string" ? { errorCode: code } : {}
|
|
8735
|
+
};
|
|
8736
|
+
}
|
|
8737
|
+
function sanitizeChildBaseEnv(baseEnv) {
|
|
8738
|
+
return Object.fromEntries(Object.entries(baseEnv).filter(([name]) => INHERITED_ENV_NAMES.has(name) || INHERITED_MOLTNET_ENV_NAMES.has(name)));
|
|
8739
|
+
}
|
|
8740
|
+
async function terminateChild(child) {
|
|
8741
|
+
if (child.exitCode !== null && child.exitCode !== void 0) return true;
|
|
8742
|
+
return new Promise((resolvePromise) => {
|
|
8743
|
+
const timer = setTimeout(() => resolvePromise(false), STOP_FORCE_MS);
|
|
8744
|
+
timer.unref();
|
|
8745
|
+
child.once("exit", () => {
|
|
8746
|
+
clearTimeout(timer);
|
|
8747
|
+
resolvePromise(true);
|
|
8748
|
+
});
|
|
8749
|
+
child.kill("SIGKILL");
|
|
8750
|
+
});
|
|
8751
|
+
}
|
|
8752
|
+
//#endregion
|
|
8753
|
+
//#region src/lib/serve/serve-lock.ts
|
|
8754
|
+
var ServeLockError = class extends Error {
|
|
8755
|
+
name = "ServeLockError";
|
|
8756
|
+
constructor(code, message, options) {
|
|
8757
|
+
super(message, options);
|
|
8758
|
+
this.code = code;
|
|
8759
|
+
}
|
|
8760
|
+
};
|
|
8761
|
+
/**
|
|
8762
|
+
* Acquire the per-root singleton lock. `proper-lockfile` uses an atomic lock
|
|
8763
|
+
* directory at exactly `<root>/serve.lock`, recovers stale owners, and keeps
|
|
8764
|
+
* the mtime fresh while the supervisor is alive.
|
|
8765
|
+
*/
|
|
8766
|
+
async function acquireServeLock(root, options = {}) {
|
|
8767
|
+
const path = join(root, "serve.lock");
|
|
8768
|
+
let releaseLock;
|
|
8769
|
+
try {
|
|
8770
|
+
releaseLock = await lock(root, {
|
|
8771
|
+
lockfilePath: path,
|
|
8772
|
+
realpath: false,
|
|
8773
|
+
retries: 0,
|
|
8774
|
+
...options.staleMs === void 0 ? {} : { stale: options.staleMs },
|
|
8775
|
+
...options.updateMs === void 0 ? {} : { update: options.updateMs },
|
|
8776
|
+
onCompromised: (cause) => {
|
|
8777
|
+
const error = new ServeLockError("compromised", `serve lock ${path} was compromised: ${cause.message}`, { cause });
|
|
8778
|
+
if (options.onCompromised) {
|
|
8779
|
+
options.onCompromised(error);
|
|
8780
|
+
return;
|
|
8781
|
+
}
|
|
8782
|
+
throw error;
|
|
8783
|
+
}
|
|
8784
|
+
});
|
|
8785
|
+
} catch (cause) {
|
|
8786
|
+
if (cause.code === "ELOCKED") throw new ServeLockError("held", `another moltnet-agent serve process already owns ${path}`, { cause });
|
|
8787
|
+
throw new ServeLockError("failed", `could not acquire serve lock ${path}: ${cause.message}`, { cause });
|
|
8788
|
+
}
|
|
8789
|
+
let released = false;
|
|
8790
|
+
return {
|
|
8791
|
+
path,
|
|
8792
|
+
async release() {
|
|
8793
|
+
if (released) return;
|
|
8794
|
+
released = true;
|
|
8795
|
+
await releaseLock();
|
|
8796
|
+
}
|
|
8797
|
+
};
|
|
8798
|
+
}
|
|
8799
|
+
/** Always release after normal completion or startup/runtime failure. */
|
|
8800
|
+
async function withServeLock(root, work, options) {
|
|
8801
|
+
const held = await acquireServeLock(root, options);
|
|
8802
|
+
try {
|
|
8803
|
+
return await work();
|
|
8804
|
+
} finally {
|
|
8805
|
+
await held.release();
|
|
8806
|
+
}
|
|
8807
|
+
}
|
|
8808
|
+
var ServeModelDiscoveryError = class extends Error {
|
|
8809
|
+
name = "ServeModelDiscoveryError";
|
|
8810
|
+
constructor(code, message, statusCode, options) {
|
|
8811
|
+
super(message, options);
|
|
8812
|
+
this.code = code;
|
|
8813
|
+
this.statusCode = statusCode;
|
|
8814
|
+
}
|
|
8815
|
+
};
|
|
8816
|
+
var ModelDiscoveryCollector = class {
|
|
8817
|
+
models = /* @__PURE__ */ new Set();
|
|
8818
|
+
addOpenAiResponse(value) {
|
|
8819
|
+
if (!isRecord(value) || !Array.isArray(value["data"])) return;
|
|
8820
|
+
for (const candidate of value["data"]) {
|
|
8821
|
+
if (!isRecord(candidate)) continue;
|
|
8822
|
+
const id = candidate["id"];
|
|
8823
|
+
if (typeof id === "string" && id.length > 0) this.models.add(id);
|
|
8824
|
+
}
|
|
8825
|
+
}
|
|
8826
|
+
addOllamaResponse(value) {
|
|
8827
|
+
if (!isRecord(value) || !Array.isArray(value["models"])) return;
|
|
8828
|
+
for (const candidate of value["models"]) {
|
|
8829
|
+
if (!isRecord(candidate)) continue;
|
|
8830
|
+
const name = candidate["name"];
|
|
8831
|
+
if (typeof name === "string" && name.length > 0) this.models.add(name);
|
|
8832
|
+
}
|
|
8833
|
+
}
|
|
8834
|
+
get size() {
|
|
8835
|
+
return this.models.size;
|
|
8836
|
+
}
|
|
8837
|
+
result(providerId, failures) {
|
|
8838
|
+
if (this.models.size === 0) throw discoveryFailure(providerId, failures);
|
|
8839
|
+
return {
|
|
8840
|
+
models: [...this.models].sort().slice(0, 500),
|
|
8841
|
+
discoveredCount: this.models.size
|
|
8842
|
+
};
|
|
8843
|
+
}
|
|
8844
|
+
};
|
|
8845
|
+
function parseProviderBaseUrl(value, providerId) {
|
|
8846
|
+
let parsed;
|
|
8847
|
+
try {
|
|
8848
|
+
parsed = new URL(value);
|
|
8849
|
+
} catch (cause) {
|
|
8850
|
+
throw new ServeModelDiscoveryError("invalid_provider", `provider "${providerId}" has an invalid base URL`, 400, { cause });
|
|
8851
|
+
}
|
|
8852
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new ServeModelDiscoveryError("invalid_provider", `provider "${providerId}" base URL must be HTTP(S) without credentials, query, or fragment`, 400);
|
|
8853
|
+
return parsed;
|
|
8854
|
+
}
|
|
8855
|
+
function discoveryFailure(providerId, failures) {
|
|
8856
|
+
if (failures.some((failure) => failure.kind === "http" && (failure.status === 401 || failure.status === 403))) return new ServeModelDiscoveryError("discovery_unauthorized", `provider "${providerId}" rejected model discovery; check its API key`, 502);
|
|
8857
|
+
if (failures.some((failure) => failure.kind === "network")) return new ServeModelDiscoveryError("discovery_unavailable", `provider "${providerId}" could not be reached for model discovery`, 502);
|
|
8858
|
+
if (failures.some((failure) => failure.kind === "invalid_response")) return new ServeModelDiscoveryError("discovery_invalid_response", `provider "${providerId}" returned an invalid model response`, 502);
|
|
8859
|
+
return new ServeModelDiscoveryError("discovery_failed", `no models discovered for provider "${providerId}"`, 502);
|
|
8860
|
+
}
|
|
8861
|
+
function isRecord(value) {
|
|
8862
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8863
|
+
}
|
|
8864
|
+
//#endregion
|
|
8865
|
+
//#region src/lib/serve/server.ts
|
|
8866
|
+
/**
|
|
8867
|
+
* HTTP surface of `moltnet-agent serve` (#2061), built on the shared
|
|
8868
|
+
* loopback-companion security profile (#2066): loopback Host enforcement,
|
|
8869
|
+
* exact-origin CORS, Fetch-Metadata guards, strict JSON parsing.
|
|
8870
|
+
*
|
|
8871
|
+
* Everything under `/v1` except the pairing bootstrap requires a paired
|
|
8872
|
+
* origin: the `x-moltnet-serve-token` header must verify against the
|
|
8873
|
+
* origin-bound token issued by the one-click pairing ceremony.
|
|
8874
|
+
*/
|
|
8875
|
+
var SERVE_TOKEN_HEADER = "x-moltnet-serve-token";
|
|
8876
|
+
var BODY_LIMIT = 64 * 1024;
|
|
8877
|
+
var LOG_POLL_INTERVAL_MS = 500;
|
|
8878
|
+
var LOG_STREAM_MAX_DURATION_MS = 3600 * 1e3;
|
|
8879
|
+
var MAX_LOG_STREAMS = 32;
|
|
8880
|
+
var LOG_READ_LIMIT_BYTES = 256 * 1024;
|
|
8881
|
+
var RUN_HISTORY_LIMIT = 100;
|
|
8882
|
+
var RATE_LIMIT_MAX = 120;
|
|
8883
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
8884
|
+
async function readServeLogDelta(handle, state, limit = LOG_READ_LIMIT_BYTES) {
|
|
8885
|
+
const info = await handle.stat();
|
|
8886
|
+
if (!info.isFile() || info.size <= state.offset) return {
|
|
8887
|
+
lines: [],
|
|
8888
|
+
omitted: false
|
|
8889
|
+
};
|
|
8890
|
+
const size = info.size;
|
|
8891
|
+
const start = Math.max(state.offset, size - limit);
|
|
8892
|
+
let omitted = start > state.offset;
|
|
8893
|
+
const buffer = Buffer.alloc(size - start);
|
|
8894
|
+
let totalRead = 0;
|
|
8895
|
+
while (totalRead < buffer.length) {
|
|
8896
|
+
const { bytesRead } = await handle.read(buffer, totalRead, buffer.length - totalRead, start + totalRead);
|
|
8897
|
+
if (bytesRead === 0) break;
|
|
8898
|
+
totalRead += bytesRead;
|
|
8899
|
+
}
|
|
8900
|
+
state.offset = start + totalRead;
|
|
8901
|
+
if (omitted) {
|
|
8902
|
+
state.fragment = "";
|
|
8903
|
+
state.decoder = new StringDecoder("utf8");
|
|
8904
|
+
state.discardingLine = true;
|
|
8905
|
+
}
|
|
8906
|
+
state.decoder ??= new StringDecoder("utf8");
|
|
8907
|
+
let text = state.fragment + state.decoder.write(buffer.subarray(0, totalRead));
|
|
8908
|
+
state.fragment = "";
|
|
8909
|
+
if (state.discardingLine) {
|
|
8910
|
+
const boundary = text.indexOf("\n");
|
|
8911
|
+
if (boundary === -1) return {
|
|
8912
|
+
lines: [],
|
|
8913
|
+
omitted
|
|
8914
|
+
};
|
|
8915
|
+
text = text.slice(boundary + 1);
|
|
8916
|
+
state.discardingLine = false;
|
|
8917
|
+
}
|
|
8918
|
+
const parts = text.split("\n");
|
|
8919
|
+
const fragment = parts.pop() ?? "";
|
|
8920
|
+
const lines = [];
|
|
8921
|
+
for (const line of parts) if (line.length > limit) omitted = true;
|
|
8922
|
+
else if (line.length > 0) lines.push(line);
|
|
8923
|
+
if (fragment.length > limit) {
|
|
8924
|
+
state.discardingLine = true;
|
|
8925
|
+
omitted = true;
|
|
8926
|
+
} else state.fragment = fragment;
|
|
8927
|
+
return {
|
|
8928
|
+
lines,
|
|
8929
|
+
omitted
|
|
8930
|
+
};
|
|
8931
|
+
}
|
|
8932
|
+
var ServeHttpError = class extends Error {
|
|
8933
|
+
name = "ServeHttpError";
|
|
8934
|
+
constructor(statusCode, code, message) {
|
|
8935
|
+
super(message);
|
|
8936
|
+
this.statusCode = statusCode;
|
|
8937
|
+
this.code = code;
|
|
8938
|
+
}
|
|
8939
|
+
};
|
|
8940
|
+
function requireBody(request) {
|
|
8941
|
+
const body = request.body;
|
|
8942
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new ServeHttpError(400, "invalid_body", "JSON object body required");
|
|
8943
|
+
return body;
|
|
8944
|
+
}
|
|
8945
|
+
function requireString(body, field) {
|
|
8946
|
+
const value = body[field];
|
|
8947
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new ServeHttpError(400, "invalid_body", `"${field}" must be a non-empty string`);
|
|
8948
|
+
return value.trim();
|
|
8949
|
+
}
|
|
8950
|
+
function optionalString(body, field) {
|
|
8951
|
+
const value = body[field];
|
|
8952
|
+
if (value === void 0 || value === null) return void 0;
|
|
8953
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new ServeHttpError(400, "invalid_body", `"${field}" must be a non-empty string when present`);
|
|
8954
|
+
return value.trim();
|
|
8955
|
+
}
|
|
8956
|
+
function stringArray(body, field, options = {}) {
|
|
8957
|
+
const value = body[field];
|
|
8958
|
+
if (!Array.isArray(value) || !options.allowEmpty && value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new ServeHttpError(400, "invalid_body", `"${field}" must be ${options.allowEmpty ? "a" : "a non-empty"} string array`);
|
|
8959
|
+
return value;
|
|
8960
|
+
}
|
|
8961
|
+
function requestOperationSignal(request, shutdownSignal) {
|
|
8962
|
+
const disconnected = new AbortController();
|
|
8963
|
+
if (request.raw.aborted) disconnected.abort();
|
|
8964
|
+
else request.raw.once("aborted", () => disconnected.abort());
|
|
8965
|
+
return shutdownSignal ? AbortSignal.any([disconnected.signal, shutdownSignal]) : disconnected.signal;
|
|
8966
|
+
}
|
|
8967
|
+
function buildServeServer(options) {
|
|
8968
|
+
const { pairing } = options;
|
|
8969
|
+
const app = options.logger ? Fastify({
|
|
8970
|
+
bodyLimit: BODY_LIMIT,
|
|
8971
|
+
loggerInstance: options.logger
|
|
8972
|
+
}) : Fastify({ bodyLimit: BODY_LIMIT });
|
|
8973
|
+
registerLoopbackSecurity(app, {
|
|
8974
|
+
allowedOrigins: options.allowedOrigins,
|
|
8975
|
+
...options.selfOrigin ? { selfOrigins: [options.selfOrigin] } : {},
|
|
8976
|
+
allowedHeaders: [SERVE_TOKEN_HEADER],
|
|
8977
|
+
methods: [
|
|
8978
|
+
"GET",
|
|
8979
|
+
"POST",
|
|
8980
|
+
"PUT",
|
|
8981
|
+
"DELETE",
|
|
8982
|
+
"OPTIONS"
|
|
8983
|
+
]
|
|
8984
|
+
});
|
|
8985
|
+
app.register(rateLimit, {
|
|
8986
|
+
global: false,
|
|
8987
|
+
max: options.rateLimitMax ?? RATE_LIMIT_MAX,
|
|
8988
|
+
timeWindow: RATE_LIMIT_WINDOW_MS,
|
|
8989
|
+
errorResponseBuilder: () => new ServeHttpError(429, "rate_limited", "Too many requests"),
|
|
8990
|
+
keyGenerator: (request) => {
|
|
8991
|
+
const origin = request.headers.origin;
|
|
8992
|
+
return isConfiguredOrigin(origin, options) ? `origin:${origin}` : `ip:${request.ip}`;
|
|
8993
|
+
}
|
|
8994
|
+
});
|
|
8995
|
+
app.after(() => app.addHook("onRequest", app.rateLimit()));
|
|
8996
|
+
app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "buffer" }, (_request, body, done) => {
|
|
8997
|
+
try {
|
|
8998
|
+
const form = new TextDecoder("utf-8", { fatal: true }).decode(typeof body === "string" ? Buffer.from(body) : body);
|
|
8999
|
+
done(null, new URLSearchParams(form));
|
|
9000
|
+
} catch {
|
|
9001
|
+
done(new ServeHttpError(400, "invalid_body", "Form body is invalid"), void 0);
|
|
9002
|
+
}
|
|
9003
|
+
});
|
|
9004
|
+
const requirePairedOrigin = (request) => {
|
|
9005
|
+
const origin = requireOriginHeader(request.headers);
|
|
9006
|
+
const token = request.headers[SERVE_TOKEN_HEADER];
|
|
9007
|
+
if (typeof token !== "string" || token.length === 0) throw new ServeHttpError(401, "pairing_required", "Pairing token is required");
|
|
9008
|
+
pairing.verify(origin, token);
|
|
9009
|
+
return origin;
|
|
9010
|
+
};
|
|
9011
|
+
app.get("/health", async () => ({ status: "ok" }));
|
|
9012
|
+
registerPairingRoutes(app, pairing);
|
|
9013
|
+
registerStatusRoute(app, options, requirePairedOrigin);
|
|
9014
|
+
registerAgentRoutes(app, options, requirePairedOrigin);
|
|
9015
|
+
registerProviderRoutes(app, options, requirePairedOrigin);
|
|
9016
|
+
registerSubscriptionRoutes(app, options, requirePairedOrigin);
|
|
9017
|
+
registerRunRoutes(app, options, requirePairedOrigin);
|
|
9018
|
+
app.addHook("onClose", () => {
|
|
9019
|
+
options.subscriptions.close();
|
|
9020
|
+
});
|
|
9021
|
+
app.setNotFoundHandler(async (_request, reply) => reply.code(404).send({
|
|
9022
|
+
code: "not_found",
|
|
9023
|
+
message: "Route is not available"
|
|
9024
|
+
}));
|
|
9025
|
+
app.setErrorHandler(async (error, request, reply) => {
|
|
9026
|
+
const { statusCode, code, message } = normalizeServeError(error);
|
|
9027
|
+
if (statusCode === 500) request.log.error({
|
|
9028
|
+
...safeErrorContext(error),
|
|
9029
|
+
code: "serve_request_failed",
|
|
9030
|
+
method: request.method,
|
|
9031
|
+
route: request.routeOptions.url
|
|
9032
|
+
}, "Serve request failed");
|
|
9033
|
+
return reply.code(statusCode).send({
|
|
9034
|
+
code,
|
|
9035
|
+
message
|
|
9036
|
+
});
|
|
9037
|
+
});
|
|
9038
|
+
return app;
|
|
9039
|
+
}
|
|
9040
|
+
function registerPairingRoutes(app, pairing) {
|
|
9041
|
+
app.post("/v1/pairings", async (request, reply) => {
|
|
9042
|
+
const origin = requireOriginHeader(request.headers);
|
|
9043
|
+
return reply.code(201).send(pairing.start(origin));
|
|
9044
|
+
});
|
|
9045
|
+
app.get("/pairings/:pairingId", async (request, reply) => {
|
|
9046
|
+
assertNavigationRequest(request.headers);
|
|
9047
|
+
const { pairingId } = request.params;
|
|
9048
|
+
const approval = pairing.approval(pairingId);
|
|
9049
|
+
return reply.type("text/html; charset=utf-8").send(renderPairingApprovalPage({
|
|
9050
|
+
pairingId,
|
|
9051
|
+
origin: approval.origin,
|
|
9052
|
+
confirmToken: approval.confirmToken
|
|
9053
|
+
}));
|
|
9054
|
+
});
|
|
9055
|
+
app.post("/pairings/:pairingId/confirm", async (request, reply) => {
|
|
9056
|
+
rejectExplicitCrossSite(request.headers);
|
|
9057
|
+
const { pairingId } = request.params;
|
|
9058
|
+
if (!(request.body instanceof URLSearchParams)) throw new ServeHttpError(400, "invalid_body", "Confirmation form is invalid");
|
|
9059
|
+
const { origin } = pairing.confirm(pairingId, request.body.get("confirmToken") ?? "");
|
|
9060
|
+
return reply.type("text/html; charset=utf-8").send(renderPairingResultPage({
|
|
9061
|
+
title: "Connection approved",
|
|
9062
|
+
message: `${origin} can now manage local MoltNet agents on this machine.`
|
|
9063
|
+
}));
|
|
9064
|
+
});
|
|
9065
|
+
app.post("/v1/pairings/:pairingId/claim", async (request) => {
|
|
9066
|
+
const origin = requireOriginHeader(request.headers);
|
|
9067
|
+
const { pairingId } = request.params;
|
|
9068
|
+
return pairing.claim(pairingId, origin);
|
|
9069
|
+
});
|
|
9070
|
+
}
|
|
9071
|
+
function registerStatusRoute(app, options, requirePairedOrigin) {
|
|
9072
|
+
const { store, runs } = options;
|
|
9073
|
+
app.get("/v1/status", async (request) => {
|
|
9074
|
+
requirePairedOrigin(request);
|
|
9075
|
+
return {
|
|
9076
|
+
version: options.version,
|
|
9077
|
+
platform: process.platform,
|
|
9078
|
+
subscriptions: options.subscriptions.list(),
|
|
9079
|
+
agents: store.listActivations().map((activation) => publicAgentView(store, activation)),
|
|
9080
|
+
providers: Object.fromEntries(Object.entries(store.readProviders()).map(([id, provider]) => [id, providerView(provider)])),
|
|
9081
|
+
runs: runViews(runs)
|
|
9082
|
+
};
|
|
9083
|
+
});
|
|
9084
|
+
}
|
|
9085
|
+
function registerAgentRoutes(app, options, requirePairedOrigin) {
|
|
9086
|
+
const { store } = options;
|
|
9087
|
+
app.get("/v1/agents", async (request) => {
|
|
9088
|
+
requirePairedOrigin(request);
|
|
9089
|
+
return store.listActivations().map((activation) => publicAgentView(store, activation));
|
|
9090
|
+
});
|
|
9091
|
+
app.post("/v1/agents", async (request, reply) => {
|
|
9092
|
+
requirePairedOrigin(request);
|
|
9093
|
+
const body = requireBody(request);
|
|
9094
|
+
const signal = requestOperationSignal(request, options.shutdownSignal);
|
|
9095
|
+
const kind = requireString(body, "kind");
|
|
9096
|
+
if (kind === "managed") {
|
|
9097
|
+
if (body["apiUrl"] !== void 0) throw new ServeHttpError(400, "invalid_body", "managed agent registration uses the configured MoltNet API URL; apiUrl cannot be overridden");
|
|
9098
|
+
const entry = await createManagedAgent(store, options.secrets, {
|
|
9099
|
+
name: requireString(body, "name"),
|
|
9100
|
+
apiUrl: options.defaultApiUrl,
|
|
9101
|
+
enrollmentToken: requireString(body, "enrollmentToken"),
|
|
9102
|
+
signal
|
|
9103
|
+
});
|
|
9104
|
+
return reply.code(201).send(publicAgentView(store, entry.activation));
|
|
9105
|
+
}
|
|
9106
|
+
if (kind === "external") {
|
|
9107
|
+
const apiUrl = optionalString(body, "apiUrl");
|
|
9108
|
+
const entry = await attachExternalAgent(store, options.externalSecretProviders, {
|
|
9109
|
+
name: requireString(body, "name"),
|
|
9110
|
+
configDir: requireString(body, "configDir"),
|
|
9111
|
+
...apiUrl ? { apiUrl } : {},
|
|
9112
|
+
signal
|
|
9113
|
+
});
|
|
9114
|
+
return reply.code(201).send(publicAgentView(store, entry.activation));
|
|
9115
|
+
}
|
|
9116
|
+
throw new ServeHttpError(400, "invalid_body", "\"kind\" must be \"managed\" or \"external\"");
|
|
9117
|
+
});
|
|
9118
|
+
app.post("/v1/agents/:agentName/reconcile", async (request) => {
|
|
9119
|
+
requirePairedOrigin(request);
|
|
9120
|
+
const { agentName } = request.params;
|
|
9121
|
+
const action = requireString(requireBody(request), "action");
|
|
9122
|
+
if (action !== "resume" && action !== "abandon") throw new ServeHttpError(400, "invalid_body", "\"action\" must be \"resume\" or \"abandon\"");
|
|
9123
|
+
const reconciled = await reconcileManagedRegistration(store, options.secrets, agentName, action, void 0, requestOperationSignal(request, options.shutdownSignal));
|
|
9124
|
+
return reconciled ? publicAgentView(store, reconciled.activation) : { abandoned: true };
|
|
9125
|
+
});
|
|
9126
|
+
}
|
|
9127
|
+
function registerProviderRoutes(app, options, requirePairedOrigin) {
|
|
9128
|
+
const { store } = options;
|
|
9129
|
+
let mutationQueue = Promise.resolve();
|
|
9130
|
+
const serialize = (mutation) => {
|
|
9131
|
+
const result = mutationQueue.then(mutation, mutation);
|
|
9132
|
+
mutationQueue = result.then(() => void 0, () => void 0);
|
|
9133
|
+
return result;
|
|
9134
|
+
};
|
|
9135
|
+
app.get("/v1/providers", async (request) => {
|
|
9136
|
+
requirePairedOrigin(request);
|
|
9137
|
+
return Object.fromEntries(Object.entries(store.readProviders()).map(([id, provider]) => [id, providerView(provider)]));
|
|
9138
|
+
});
|
|
9139
|
+
app.post("/v1/providers/:providerId/discover-models", async (request) => {
|
|
9140
|
+
requirePairedOrigin(request);
|
|
9141
|
+
const { providerId: rawProviderId } = request.params;
|
|
9142
|
+
const providerId = assertProviderId(rawProviderId);
|
|
9143
|
+
const provider = store.readProviders()[providerId];
|
|
9144
|
+
if (!provider) throw new ServeHttpError(404, "provider_not_found", `provider "${providerId}" was not found`);
|
|
9145
|
+
const parsed = parseProviderBaseUrl(provider.baseUrl, providerId);
|
|
9146
|
+
const baseUrl = parsed.href.replace(/\/$/u, "");
|
|
9147
|
+
let apiKey;
|
|
9148
|
+
if (provider.apiKeyRef) try {
|
|
9149
|
+
apiKey = await options.secretProviders.resolve(parseSecretReferenceString(provider.apiKeyRef));
|
|
9150
|
+
} catch (error) {
|
|
9151
|
+
request.log.warn({
|
|
9152
|
+
...safeErrorContext(error),
|
|
9153
|
+
code: "serve_provider_secret_unavailable",
|
|
9154
|
+
providerId
|
|
9155
|
+
}, "Provider API key could not be resolved for model discovery");
|
|
9156
|
+
throw new ServeHttpError(400, "provider_secret_unavailable", `provider "${providerId}" API key could not be resolved`);
|
|
9157
|
+
}
|
|
9158
|
+
const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
9159
|
+
const fetchImpl = options.discoverFetch ?? fetch;
|
|
9160
|
+
const failures = [];
|
|
9161
|
+
const collector = new ModelDiscoveryCollector();
|
|
9162
|
+
const tryJson = async (endpoint, url) => {
|
|
9163
|
+
let response;
|
|
9164
|
+
try {
|
|
9165
|
+
response = await fetchImpl(url, {
|
|
9166
|
+
headers,
|
|
9167
|
+
redirect: "error",
|
|
9168
|
+
signal: AbortSignal.timeout(1e4)
|
|
9169
|
+
});
|
|
9170
|
+
} catch (error) {
|
|
9171
|
+
const errorType = error instanceof Error ? error.name : typeof error;
|
|
9172
|
+
failures.push({
|
|
9173
|
+
kind: "network",
|
|
9174
|
+
errorType
|
|
9175
|
+
});
|
|
9176
|
+
request.log.warn({
|
|
9177
|
+
code: "serve_provider_discovery_request_failed",
|
|
9178
|
+
endpoint,
|
|
9179
|
+
errorType,
|
|
9180
|
+
providerId
|
|
9181
|
+
}, "Provider model discovery request failed");
|
|
9182
|
+
return null;
|
|
9183
|
+
}
|
|
9184
|
+
if (!response.ok) {
|
|
9185
|
+
failures.push({
|
|
9186
|
+
kind: "http",
|
|
9187
|
+
status: response.status
|
|
9188
|
+
});
|
|
9189
|
+
const context = {
|
|
9190
|
+
code: "serve_provider_discovery_upstream_error",
|
|
9191
|
+
endpoint,
|
|
9192
|
+
providerId,
|
|
9193
|
+
statusCode: response.status
|
|
9194
|
+
};
|
|
9195
|
+
if (response.status >= 500 || response.status === 401 || response.status === 403) request.log.warn(context, "Provider model discovery was rejected");
|
|
9196
|
+
else request.log.info(context, "Provider model discovery endpoint unavailable");
|
|
9197
|
+
return null;
|
|
9198
|
+
}
|
|
9199
|
+
try {
|
|
9200
|
+
return await response.json();
|
|
9201
|
+
} catch {
|
|
9202
|
+
failures.push({ kind: "invalid_response" });
|
|
9203
|
+
request.log.warn({
|
|
9204
|
+
code: "serve_provider_discovery_invalid_json",
|
|
9205
|
+
endpoint,
|
|
9206
|
+
providerId
|
|
9207
|
+
}, "Provider model discovery returned invalid JSON");
|
|
9208
|
+
return null;
|
|
9209
|
+
}
|
|
9210
|
+
};
|
|
9211
|
+
collector.addOpenAiResponse(await tryJson("openai_models", `${baseUrl}/models`));
|
|
9212
|
+
if (collector.size === 0) collector.addOllamaResponse(await tryJson("ollama_tags", `${parsed.origin}/api/tags`));
|
|
9213
|
+
const result = collector.result(providerId, failures);
|
|
9214
|
+
if (result.discoveredCount > result.models.length) request.log.warn({
|
|
9215
|
+
code: "serve_provider_discovery_truncated",
|
|
9216
|
+
discoveredCount: result.discoveredCount,
|
|
9217
|
+
providerId,
|
|
9218
|
+
returnedCount: 500
|
|
9219
|
+
}, "Provider model discovery result was truncated");
|
|
9220
|
+
request.log.info({
|
|
9221
|
+
code: "serve_provider_discovery_completed",
|
|
9222
|
+
modelCount: result.models.length,
|
|
9223
|
+
providerId
|
|
9224
|
+
}, "Provider model discovery completed");
|
|
9225
|
+
return { models: result.models };
|
|
9226
|
+
});
|
|
9227
|
+
app.put("/v1/providers/:providerId", async (request, reply) => {
|
|
9228
|
+
requirePairedOrigin(request);
|
|
9229
|
+
const { providerId: rawProviderId } = request.params;
|
|
9230
|
+
const providerId = assertProviderId(rawProviderId);
|
|
9231
|
+
const body = requireBody(request);
|
|
9232
|
+
const baseUrl = requireString(body, "baseUrl");
|
|
9233
|
+
parseProviderBaseUrl(baseUrl, providerId);
|
|
9234
|
+
const entry = {
|
|
9235
|
+
api: requireString(body, "api"),
|
|
9236
|
+
baseUrl,
|
|
9237
|
+
envName: assertProviderEnvName(providerId, requireString(body, "envName")),
|
|
9238
|
+
models: stringArray(body, "models", { allowEmpty: true })
|
|
9239
|
+
};
|
|
9240
|
+
const apiKey = optionalString(body, "apiKey");
|
|
9241
|
+
await serialize(async () => {
|
|
9242
|
+
const providers = store.readProviders();
|
|
9243
|
+
if (apiKey) {
|
|
9244
|
+
const key = `pi-provider/${providerId}`;
|
|
9245
|
+
await options.secrets.write(key, apiKey);
|
|
9246
|
+
entry.apiKeyRef = formatSecretReferenceString({
|
|
9247
|
+
provider: FILE_SECRET_PROVIDER,
|
|
9248
|
+
key
|
|
9249
|
+
});
|
|
9250
|
+
} else if (providers[providerId]?.apiKeyRef && providers[providerId].baseUrl === entry.baseUrl) entry.apiKeyRef = providers[providerId].apiKeyRef;
|
|
9251
|
+
providers[providerId] = entry;
|
|
9252
|
+
store.writeProviders(providers);
|
|
9253
|
+
});
|
|
9254
|
+
return reply.code(200).send(providerView(entry));
|
|
9255
|
+
});
|
|
9256
|
+
}
|
|
9257
|
+
function runViews(runs) {
|
|
9258
|
+
return runs.list(RUN_HISTORY_LIMIT).map((record) => ({
|
|
9259
|
+
...record,
|
|
9260
|
+
active: runs.isActive(record.id)
|
|
9261
|
+
}));
|
|
9262
|
+
}
|
|
9263
|
+
function registerSubscriptionRoutes(app, options, requirePairedOrigin) {
|
|
9264
|
+
app.get("/v1/subscriptions", async (request) => {
|
|
9265
|
+
requirePairedOrigin(request);
|
|
9266
|
+
return options.subscriptions.list();
|
|
9267
|
+
});
|
|
9268
|
+
app.post("/v1/subscriptions/:providerId/login", async (request, reply) => {
|
|
9269
|
+
requirePairedOrigin(request);
|
|
9270
|
+
const { providerId } = request.params;
|
|
9271
|
+
const login = await options.subscriptions.start(providerId);
|
|
9272
|
+
return reply.code(201).send(login);
|
|
9273
|
+
});
|
|
9274
|
+
app.get("/v1/subscriptions/:providerId/login", async (request) => {
|
|
9275
|
+
requirePairedOrigin(request);
|
|
9276
|
+
const { providerId } = request.params;
|
|
9277
|
+
return options.subscriptions.status(providerId);
|
|
9278
|
+
});
|
|
9279
|
+
app.delete("/v1/subscriptions/:providerId/login", async (request) => {
|
|
9280
|
+
requirePairedOrigin(request);
|
|
9281
|
+
const { providerId } = request.params;
|
|
9282
|
+
return options.subscriptions.cancel(providerId);
|
|
9283
|
+
});
|
|
9284
|
+
}
|
|
9285
|
+
function registerRunRoutes(app, options, requirePairedOrigin) {
|
|
9286
|
+
const { runs } = options;
|
|
9287
|
+
app.get("/v1/runs", async (request) => {
|
|
9288
|
+
requirePairedOrigin(request);
|
|
9289
|
+
return runViews(runs);
|
|
9290
|
+
});
|
|
9291
|
+
app.post("/v1/runs", async (request, reply) => {
|
|
9292
|
+
requirePairedOrigin(request);
|
|
9293
|
+
const body = requireBody(request);
|
|
9294
|
+
const record = await runs.start({
|
|
9295
|
+
agent: requireString(body, "agent"),
|
|
9296
|
+
teamId: requireString(body, "teamId"),
|
|
9297
|
+
profiles: stringArray(body, "profiles"),
|
|
9298
|
+
taskTypes: stringArray(body, "taskTypes"),
|
|
9299
|
+
mode: requireString(body, "mode")
|
|
9300
|
+
}, requestOperationSignal(request, options.shutdownSignal));
|
|
9301
|
+
return reply.code(201).send(record);
|
|
9302
|
+
});
|
|
9303
|
+
app.delete("/v1/runs/:runId", async (request) => {
|
|
9304
|
+
requirePairedOrigin(request);
|
|
9305
|
+
const { runId } = request.params;
|
|
9306
|
+
return runs.stop(runId);
|
|
9307
|
+
});
|
|
9308
|
+
registerRunLogRoute(app, options, requirePairedOrigin);
|
|
9309
|
+
}
|
|
9310
|
+
function registerRunLogRoute(app, options, requirePairedOrigin) {
|
|
9311
|
+
const { runs, store } = options;
|
|
9312
|
+
let openStreams = 0;
|
|
9313
|
+
app.get("/v1/runs/:runId/logs", async (request, reply) => {
|
|
9314
|
+
requirePairedOrigin(request);
|
|
9315
|
+
const { runId } = request.params;
|
|
9316
|
+
const record = runs.status(runId);
|
|
9317
|
+
store.resolveRunLogPath(record.id);
|
|
9318
|
+
if (openStreams >= MAX_LOG_STREAMS) throw new ServeHttpError(429, "rate_limited", "Too many concurrent log streams");
|
|
9319
|
+
openStreams += 1;
|
|
9320
|
+
reply.raw.writeHead(200, {
|
|
9321
|
+
"content-type": "text/event-stream",
|
|
9322
|
+
"cache-control": "no-store",
|
|
9323
|
+
connection: "keep-alive",
|
|
9324
|
+
...corsHeadersFor(request, options)
|
|
9325
|
+
});
|
|
9326
|
+
const readState = {
|
|
9327
|
+
offset: 0,
|
|
9328
|
+
fragment: ""
|
|
9329
|
+
};
|
|
9330
|
+
let closed = false;
|
|
9331
|
+
let pollTimer;
|
|
9332
|
+
const durationTimer = setTimeout(() => finish(), LOG_STREAM_MAX_DURATION_MS);
|
|
9333
|
+
durationTimer.unref();
|
|
9334
|
+
function finish(destroy = false) {
|
|
9335
|
+
if (closed) return;
|
|
9336
|
+
closed = true;
|
|
9337
|
+
openStreams -= 1;
|
|
9338
|
+
if (pollTimer) clearTimeout(pollTimer);
|
|
9339
|
+
clearTimeout(durationTimer);
|
|
9340
|
+
if (destroy) reply.raw.destroy();
|
|
9341
|
+
else reply.raw.end();
|
|
9342
|
+
}
|
|
9343
|
+
const writeData = async (line) => {
|
|
9344
|
+
if (closed || reply.raw.write(`data: ${line}\n\n`)) return;
|
|
9345
|
+
await new Promise((resolvePromise) => {
|
|
9346
|
+
const done = () => {
|
|
9347
|
+
reply.raw.off("drain", done);
|
|
9348
|
+
reply.raw.off("close", done);
|
|
9349
|
+
resolvePromise();
|
|
9350
|
+
};
|
|
9351
|
+
reply.raw.once("drain", done);
|
|
9352
|
+
reply.raw.once("close", done);
|
|
9353
|
+
});
|
|
9354
|
+
};
|
|
9355
|
+
const push = async () => {
|
|
9356
|
+
const handle = await open(store.resolveRunLogPath(record.id), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
9357
|
+
try {
|
|
9358
|
+
const { lines, omitted } = await readServeLogDelta(handle, readState);
|
|
9359
|
+
if (omitted) await writeData("[older log output omitted]");
|
|
9360
|
+
for (const line of lines) await writeData(line);
|
|
9361
|
+
} finally {
|
|
9362
|
+
await handle.close();
|
|
9363
|
+
}
|
|
9364
|
+
};
|
|
9365
|
+
const fail = (error) => {
|
|
9366
|
+
if (closed) return;
|
|
9367
|
+
request.log.warn({
|
|
9368
|
+
...safeErrorContext(error),
|
|
9369
|
+
code: "serve_log_tail_failed",
|
|
9370
|
+
runId
|
|
9371
|
+
}, "serve log tail failed");
|
|
9372
|
+
finish(true);
|
|
9373
|
+
};
|
|
9374
|
+
const schedule = () => {
|
|
9375
|
+
if (closed) return;
|
|
9376
|
+
pollTimer = setTimeout(() => {
|
|
9377
|
+
push().then(() => {
|
|
9378
|
+
if (runs.isActive(record.id)) schedule();
|
|
9379
|
+
else finish();
|
|
9380
|
+
}).catch(fail);
|
|
9381
|
+
}, LOG_POLL_INTERVAL_MS);
|
|
9382
|
+
pollTimer.unref();
|
|
9383
|
+
};
|
|
9384
|
+
request.raw.on("close", () => finish());
|
|
9385
|
+
try {
|
|
9386
|
+
await push();
|
|
9387
|
+
if (runs.isActive(record.id)) schedule();
|
|
9388
|
+
else finish();
|
|
9389
|
+
} catch (error) {
|
|
9390
|
+
fail(error);
|
|
9391
|
+
}
|
|
9392
|
+
return reply;
|
|
9393
|
+
});
|
|
9394
|
+
}
|
|
9395
|
+
function safeErrorContext(error) {
|
|
9396
|
+
const context = { errorType: error instanceof Error ? error.name : typeof error };
|
|
9397
|
+
const applicationCode = safeErrorToken(error?.code);
|
|
9398
|
+
if (applicationCode) context["applicationCode"] = applicationCode;
|
|
9399
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
9400
|
+
const fsCode = safeErrorToken(cause?.code);
|
|
9401
|
+
const syscall = safeErrorToken(cause?.syscall);
|
|
9402
|
+
if (fsCode) context["fsCode"] = fsCode;
|
|
9403
|
+
if (syscall) context["syscall"] = syscall;
|
|
9404
|
+
return context;
|
|
9405
|
+
}
|
|
9406
|
+
function safeErrorToken(value) {
|
|
9407
|
+
return typeof value === "string" && /^[a-z0-9_:-]{1,64}$/iu.test(value) ? value : void 0;
|
|
9408
|
+
}
|
|
9409
|
+
function providerView(provider) {
|
|
9410
|
+
return {
|
|
9411
|
+
api: provider.api,
|
|
9412
|
+
baseUrl: provider.baseUrl,
|
|
9413
|
+
envName: provider.envName,
|
|
9414
|
+
models: provider.models,
|
|
9415
|
+
hasApiKey: Boolean(provider.apiKeyRef)
|
|
9416
|
+
};
|
|
9417
|
+
}
|
|
9418
|
+
function corsHeadersFor(request, options) {
|
|
9419
|
+
const origin = request.headers.origin;
|
|
9420
|
+
if (isConfiguredOrigin(origin, options)) return {
|
|
9421
|
+
"access-control-allow-origin": origin,
|
|
9422
|
+
vary: "origin"
|
|
9423
|
+
};
|
|
9424
|
+
return {};
|
|
9425
|
+
}
|
|
9426
|
+
function isConfiguredOrigin(origin, options) {
|
|
9427
|
+
return typeof origin === "string" && (options.allowedOrigins.includes(origin) || origin === options.selfOrigin);
|
|
9428
|
+
}
|
|
9429
|
+
function normalizeServeError(error) {
|
|
9430
|
+
if (error instanceof ServeHttpError) return {
|
|
9431
|
+
statusCode: error.statusCode,
|
|
9432
|
+
code: error.code,
|
|
9433
|
+
message: error.message
|
|
9434
|
+
};
|
|
9435
|
+
if (isLoopbackViolation(error)) return {
|
|
9436
|
+
statusCode: error.kind === "origin_required" || error.kind === "origin_invalid" || error.kind === "origin_not_allowed" ? 403 : 400,
|
|
9437
|
+
code: error.kind,
|
|
9438
|
+
message: error.message
|
|
9439
|
+
};
|
|
9440
|
+
if (error instanceof ServePairingError) return {
|
|
9441
|
+
statusCode: error.code === "pairing_not_found" ? 404 : error.code === "pairing_token_invalid" || error.code === "pairing_not_approved" ? 401 : 403,
|
|
9442
|
+
code: error.code,
|
|
9443
|
+
message: error.message
|
|
9444
|
+
};
|
|
9445
|
+
if (error instanceof ServeStoreError) return {
|
|
9446
|
+
statusCode: error.code === "not_found" ? 404 : error.code === "io_error" ? 500 : 400,
|
|
9447
|
+
code: error.code,
|
|
9448
|
+
message: error.message
|
|
9449
|
+
};
|
|
9450
|
+
if (error instanceof ServeRunError) return {
|
|
9451
|
+
statusCode: error.code === "run_not_found" ? 404 : 400,
|
|
9452
|
+
code: error.code,
|
|
9453
|
+
message: error.message
|
|
9454
|
+
};
|
|
9455
|
+
if (error instanceof ServeSubscriptionError) return {
|
|
9456
|
+
statusCode: error.code === "login_not_found" ? 404 : error.code === "provider_unknown" ? 404 : 400,
|
|
9457
|
+
code: error.code,
|
|
9458
|
+
message: error.message
|
|
9459
|
+
};
|
|
9460
|
+
if (error instanceof ServeModelDiscoveryError) return {
|
|
9461
|
+
statusCode: error.statusCode,
|
|
9462
|
+
code: error.code,
|
|
9463
|
+
message: error.message
|
|
9464
|
+
};
|
|
9465
|
+
if (error instanceof ServeIdentityError) return {
|
|
9466
|
+
statusCode: error.code === "agent_exists" ? 409 : 400,
|
|
9467
|
+
code: error.code,
|
|
9468
|
+
message: error.message
|
|
9469
|
+
};
|
|
9470
|
+
return {
|
|
9471
|
+
statusCode: 500,
|
|
9472
|
+
code: "internal_error",
|
|
9473
|
+
message: "The local supervisor could not complete the request."
|
|
9474
|
+
};
|
|
9475
|
+
}
|
|
9476
|
+
//#endregion
|
|
9477
|
+
//#region src/cli/serve.ts
|
|
9478
|
+
/**
|
|
9479
|
+
* `moltnet-agent serve` — per-user loopback supervisor (#2061).
|
|
9480
|
+
*
|
|
9481
|
+
* Starts nothing on its own: it binds 127.0.0.1 and waits for a paired
|
|
9482
|
+
* Console origin to configure agents/providers and start/stop runs.
|
|
9483
|
+
*/
|
|
9484
|
+
var DEFAULT_PORT = 17374;
|
|
9485
|
+
var DEFAULT_ALLOWED_ORIGINS = "https://console.themolt.net";
|
|
9486
|
+
var DEFAULT_API_URL = "https://api.themolt.net";
|
|
9487
|
+
var SHUTDOWN_TIMEOUT_MS = 15e3;
|
|
9488
|
+
async function runServe(argv) {
|
|
9489
|
+
if (isHelpFlag(argv)) {
|
|
9490
|
+
console.log(SERVE_HELP);
|
|
9491
|
+
return 0;
|
|
9492
|
+
}
|
|
9493
|
+
const envConfig = loadServeEnvConfig();
|
|
9494
|
+
const { values } = parseArgs({
|
|
9495
|
+
args: argv,
|
|
9496
|
+
options: {
|
|
9497
|
+
port: { type: "string" },
|
|
9498
|
+
"allowed-origins": { type: "string" },
|
|
9499
|
+
root: { type: "string" },
|
|
9500
|
+
"api-url": { type: "string" }
|
|
9501
|
+
}
|
|
9502
|
+
});
|
|
9503
|
+
const port = Number.parseInt(values.port ?? (envConfig.port || `${DEFAULT_PORT}`), 10);
|
|
9504
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
9505
|
+
console.error(`Invalid --port: ${String(values.port)}`);
|
|
9506
|
+
return 1;
|
|
9507
|
+
}
|
|
9508
|
+
const allowedOrigins = parseAllowedOrigins(values["allowed-origins"] ?? (envConfig.allowedOrigins || DEFAULT_ALLOWED_ORIGINS));
|
|
9509
|
+
const root = values.root ?? resolveServeRoot({
|
|
9510
|
+
root: envConfig.root,
|
|
9511
|
+
xdgConfigHome: envConfig.xdgConfigHome
|
|
9512
|
+
});
|
|
9513
|
+
const defaultApiUrl = values["api-url"] ?? (envConfig.apiUrl || DEFAULT_API_URL);
|
|
9514
|
+
const store = new ServeStore(root).ensure();
|
|
9515
|
+
const { logger, shutdown: shutdownLogger } = createRootLogger({
|
|
9516
|
+
name: "agent-daemon.serve",
|
|
9517
|
+
level: envConfig.logLevel || "info"
|
|
9518
|
+
});
|
|
9519
|
+
try {
|
|
9520
|
+
try {
|
|
9521
|
+
return await withServeLock(root, async () => {
|
|
9522
|
+
const secrets = new FileSecretProvider({
|
|
9523
|
+
root: store.secretsDir,
|
|
9524
|
+
writable: true
|
|
9525
|
+
});
|
|
9526
|
+
const secretProviders = createNodeSecretProviderRegistry().register(secrets);
|
|
9527
|
+
const externalSecretProviders = createNodeSecretProviderRegistry();
|
|
9528
|
+
const pairing = new PairingService();
|
|
9529
|
+
const shutdownController = new AbortController();
|
|
9530
|
+
const subscriptions = new ProviderLoginService({
|
|
9531
|
+
authPath: store.piAuthJsonPath,
|
|
9532
|
+
logger
|
|
9533
|
+
});
|
|
9534
|
+
const runs = new RunManager({
|
|
9535
|
+
store,
|
|
9536
|
+
secretProviders,
|
|
9537
|
+
externalSecretProviders,
|
|
9538
|
+
baseEnv: processEnvSnapshot(),
|
|
9539
|
+
logger
|
|
9540
|
+
});
|
|
9541
|
+
const app = buildServeServer({
|
|
9542
|
+
store,
|
|
9543
|
+
secrets,
|
|
9544
|
+
secretProviders,
|
|
9545
|
+
externalSecretProviders,
|
|
9546
|
+
pairing,
|
|
9547
|
+
runs,
|
|
9548
|
+
subscriptions,
|
|
9549
|
+
allowedOrigins,
|
|
9550
|
+
selfOrigin: `http://127.0.0.1:${port}`,
|
|
9551
|
+
defaultApiUrl,
|
|
9552
|
+
version: "dev",
|
|
9553
|
+
logger,
|
|
9554
|
+
shutdownSignal: shutdownController.signal
|
|
9555
|
+
});
|
|
9556
|
+
try {
|
|
9557
|
+
const address = await app.listen({
|
|
9558
|
+
host: "127.0.0.1",
|
|
9559
|
+
port
|
|
9560
|
+
});
|
|
9561
|
+
console.error(`moltnet-agent serve listening on ${address}`);
|
|
9562
|
+
console.error(`config root: ${root}`);
|
|
9563
|
+
console.error(`allowed origins: ${allowedOrigins.join(", ")}`);
|
|
9564
|
+
console.error("Pair from the Console \"Local runtime\" page; approve the one-click prompt this server opens.");
|
|
9565
|
+
return await waitForServeShutdown(runs, app, shutdownController);
|
|
9566
|
+
} catch (cause) {
|
|
9567
|
+
await app.close().catch(() => void 0);
|
|
9568
|
+
throw cause;
|
|
9569
|
+
}
|
|
9570
|
+
}, { onCompromised: (error) => {
|
|
9571
|
+
console.error(error.message);
|
|
9572
|
+
process.exitCode = 1;
|
|
9573
|
+
process.kill(process.pid, "SIGTERM");
|
|
9574
|
+
} });
|
|
9575
|
+
} catch (cause) {
|
|
9576
|
+
if (cause instanceof ServeLockError) {
|
|
9577
|
+
console.error(cause.message);
|
|
9578
|
+
return 1;
|
|
9579
|
+
}
|
|
9580
|
+
throw cause;
|
|
9581
|
+
}
|
|
9582
|
+
} finally {
|
|
9583
|
+
await shutdownLogger();
|
|
9584
|
+
}
|
|
9585
|
+
}
|
|
9586
|
+
function waitForServeShutdown(runs, app, shutdownController) {
|
|
9587
|
+
return new Promise((resolvePromise) => {
|
|
9588
|
+
let shuttingDown = false;
|
|
9589
|
+
const shutdown = () => {
|
|
9590
|
+
if (shuttingDown) return;
|
|
9591
|
+
shuttingDown = true;
|
|
9592
|
+
shutdownController.abort();
|
|
9593
|
+
(async () => {
|
|
9594
|
+
app.server.closeAllConnections();
|
|
9595
|
+
const cleanupPromise = Promise.allSettled([runs.stopAll(), app.close()]);
|
|
9596
|
+
let timedOut = false;
|
|
9597
|
+
let deadlineTimer;
|
|
9598
|
+
await Promise.race([cleanupPromise, new Promise((resolveDeadline) => {
|
|
9599
|
+
deadlineTimer = setTimeout(() => {
|
|
9600
|
+
timedOut = true;
|
|
9601
|
+
resolveDeadline();
|
|
9602
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
9603
|
+
})]);
|
|
9604
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
9605
|
+
let forcedExitTimer;
|
|
9606
|
+
if (timedOut) {
|
|
9607
|
+
console.error("shutdown cleanup exceeded its 15 second deadline; force-stopping runs");
|
|
9608
|
+
runs.forceStopAll();
|
|
9609
|
+
app.server.closeAllConnections();
|
|
9610
|
+
forcedExitTimer = setTimeout(() => process.exit(1), 2e3);
|
|
9611
|
+
}
|
|
9612
|
+
const results = await cleanupPromise;
|
|
9613
|
+
if (forcedExitTimer) clearTimeout(forcedExitTimer);
|
|
9614
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
9615
|
+
for (const failure of failures) console.error(`shutdown cleanup failed: ${failure.reason.message}`);
|
|
9616
|
+
handlers.dispose();
|
|
9617
|
+
const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
|
|
9618
|
+
resolvePromise(failures.length > 0 ? 1 : exitCode);
|
|
9619
|
+
})();
|
|
9620
|
+
};
|
|
9621
|
+
const handlers = installShutdownSignalHandlers({
|
|
9622
|
+
logDrain: () => console.error("shutting down: stopping runs…"),
|
|
9623
|
+
drain: shutdown
|
|
9624
|
+
});
|
|
9625
|
+
});
|
|
9626
|
+
}
|
|
9627
|
+
//#endregion
|
|
6857
9628
|
//#region src/lib/runtime-session-sync.ts
|
|
6858
9629
|
async function syncRuntimeSessions(deps, input) {
|
|
6859
9630
|
const result = {
|
|
@@ -7037,6 +9808,7 @@ async function runAgentDaemonCli(options) {
|
|
|
7037
9808
|
case "poll": return runPoll(rest, options.runtime);
|
|
7038
9809
|
case "once": return runOnce(rest, options.runtime);
|
|
7039
9810
|
case "drain": return runDrain(rest, options.runtime);
|
|
9811
|
+
case "serve": return runServe(rest);
|
|
7040
9812
|
case "sync-sessions": return runSyncSessions(rest);
|
|
7041
9813
|
case "-h":
|
|
7042
9814
|
case "--help":
|