svamp-cli 0.2.304 → 0.2.306
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/{adminCommands-DlD78CYk.mjs → adminCommands-Cx_Rp-TR.mjs} +1 -1
- package/dist/{agentCommands-B3FTfsg-.mjs → agentCommands-CUcyA_KE.mjs} +5 -5
- package/dist/{auth-2-M7wMHL.mjs → auth-BBjbREk1.mjs} +1 -1
- package/dist/{cli-CU9Xku8r.mjs → cli-Jj5S5UOL.mjs} +76 -70
- package/dist/cli.mjs +2 -2
- package/dist/{commands-CaeLSCaX.mjs → commands-3UnC4Lnb.mjs} +1 -1
- package/dist/{commands-CfSAnB1T.mjs → commands-BRQBNTsJ.mjs} +2 -2
- package/dist/{commands-DG0V3dvG.mjs → commands-BeD9b-Eh.mjs} +2 -2
- package/dist/{commands-CG6AF59l.mjs → commands-ByP8QKuO.mjs} +7 -7
- package/dist/{commands--WPYI2sm.mjs → commands-CvwLQ3MM.mjs} +1 -1
- package/dist/{commands-BO-oHKFw.mjs → commands-DJt9EFd7.mjs} +2 -2
- package/dist/{commands-CjZKWU9a.mjs → commands-DkKdjnPE.mjs} +2 -2
- package/dist/{commands-mm9jZg5L.mjs → commands-DlyOpWwF.mjs} +1 -1
- package/dist/{fleet-GklB5DQn.mjs → fleet-cBng1VMn.mjs} +1 -1
- package/dist/{frpc-Dsada5ox.mjs → frpc-C1jS23Sn.mjs} +30 -6
- package/dist/{headlessCli-B2IWICNI.mjs → headlessCli-CsnDECCN.mjs} +2 -2
- package/dist/{httpServer-CSMZTpoh.mjs → httpServer-1XjB2h3K.mjs} +15 -2
- package/dist/index.mjs +1 -1
- package/dist/{notifyCommands-BzCPoBy8.mjs → notifyCommands-D7Ja6442.mjs} +1 -1
- package/dist/{package-TCz4K-zx.mjs → package-DUF-safU.mjs} +2 -2
- package/dist/{rpc-DOVHyr_6.mjs → rpc-BxmbyDga.mjs} +2 -1
- package/dist/{rpc--eK356Xz.mjs → rpc-iNafaHoh.mjs} +1 -1
- package/dist/{run-BtVKqoF4.mjs → run-Bd57zohL.mjs} +784 -345
- package/dist/{run-IqHyd8bA.mjs → run-D351s_uv.mjs} +1 -1
- package/dist/{scheduler-DAkzJVgr.mjs → scheduler-CZYOanXt.mjs} +1 -1
- package/dist/{serveCommands-AEtGwfIU.mjs → serveCommands-DotLju8m.mjs} +6 -6
- package/dist/{sideband-CSwHDjzp.mjs → sideband-DM1-IAV7.mjs} +1 -1
- package/package.json +2 -2
|
@@ -8,8 +8,8 @@ import { fileURLToPath } from 'url';
|
|
|
8
8
|
import { execFile, spawn, execSync, exec as exec$1 } from 'child_process';
|
|
9
9
|
import * as crypto from 'crypto';
|
|
10
10
|
import { randomUUID as randomUUID$1 } from 'crypto';
|
|
11
|
-
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
12
|
-
import { existsSync, readFileSync, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1, chmodSync, rmSync as rmSync$1, cpSync, realpathSync, readdirSync,
|
|
11
|
+
import { randomUUID, randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { existsSync, readFileSync, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1, chmodSync, rmSync as rmSync$1, cpSync, statSync, realpathSync, readdirSync, renameSync, appendFileSync, unlinkSync } from 'node:fs';
|
|
13
13
|
import { exec, spawn as spawn$1, execSync as execSync$1, execFile as execFile$1, execFileSync } from 'node:child_process';
|
|
14
14
|
import { promisify } from 'util';
|
|
15
15
|
import * as http from 'http';
|
|
@@ -19,7 +19,7 @@ import { join as join$1, extname, resolve, sep, basename, dirname } from 'node:p
|
|
|
19
19
|
import { EventEmitter } from 'node:events';
|
|
20
20
|
import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk';
|
|
21
21
|
import { createInterface } from 'node:readline';
|
|
22
|
-
import { mkdir, rm, chmod, access, mkdtemp, copyFile, writeFile, readdir, stat, readFile as readFile$1 } from 'node:fs/promises';
|
|
22
|
+
import { mkdir, rm, chmod, access, mkdtemp, copyFile, writeFile, readdir, stat, readFile as readFile$1, rename as rename$1 } from 'node:fs/promises';
|
|
23
23
|
import { promisify as promisify$1 } from 'node:util';
|
|
24
24
|
import { parse, stringify } from 'yaml';
|
|
25
25
|
|
|
@@ -1280,6 +1280,14 @@ const MIME = {
|
|
|
1280
1280
|
function contentTypeFor(filePath) {
|
|
1281
1281
|
return MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
1282
1282
|
}
|
|
1283
|
+
function hasBlockedDotSegment(relPath) {
|
|
1284
|
+
const decoded = safeDecode(relPath.replace(/^\/+/, ""));
|
|
1285
|
+
for (const seg of decoded.split("/")) {
|
|
1286
|
+
if (!seg || seg === "." || seg === "..") continue;
|
|
1287
|
+
if (seg.startsWith(".") && seg !== ".well-known") return true;
|
|
1288
|
+
}
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1283
1291
|
function containedPath(rootDir, relPath) {
|
|
1284
1292
|
const base = path.resolve(rootDir);
|
|
1285
1293
|
const decoded = safeDecode(relPath);
|
|
@@ -1368,6 +1376,11 @@ function serveStaticMount(req, res, opts) {
|
|
|
1368
1376
|
sendFile(req, res, rootDir, rootStat);
|
|
1369
1377
|
return;
|
|
1370
1378
|
}
|
|
1379
|
+
if (hasBlockedDotSegment(relPath)) {
|
|
1380
|
+
res.writeHead(404, CORS);
|
|
1381
|
+
res.end("Not Found");
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1371
1384
|
const lexTarget = containedPath(rootDir, relPath.replace(/^\/+/, ""));
|
|
1372
1385
|
if (!lexTarget) {
|
|
1373
1386
|
res.writeHead(403, { ...CORS, "Content-Type": "text/plain" });
|
|
@@ -1496,6 +1509,7 @@ var staticFileServer = /*#__PURE__*/Object.freeze({
|
|
|
1496
1509
|
containedPath: containedPath,
|
|
1497
1510
|
contentTypeFor: contentTypeFor,
|
|
1498
1511
|
directoryListingHtml: directoryListingHtml,
|
|
1512
|
+
hasBlockedDotSegment: hasBlockedDotSegment,
|
|
1499
1513
|
parseRange: parseRange,
|
|
1500
1514
|
realContainedPath: realContainedPath,
|
|
1501
1515
|
serveStaticMount: serveStaticMount
|
|
@@ -1504,6 +1518,28 @@ var staticFileServer = /*#__PURE__*/Object.freeze({
|
|
|
1504
1518
|
const AUTH0_NAMESPACES = ["https://api.imjoy.io/", "https://amun.ai/"];
|
|
1505
1519
|
const COOKIE_NAME = "svamp_serve_token";
|
|
1506
1520
|
const SVAMP_SERVE_TOKEN_PARAM = "__svamp_serve_token";
|
|
1521
|
+
function stripServeAuthCookie(cookieHeader) {
|
|
1522
|
+
if (!cookieHeader) return cookieHeader;
|
|
1523
|
+
const kept = cookieHeader.split(";").map((s) => s.trim()).filter((s) => s && !s.toLowerCase().startsWith(COOKIE_NAME + "="));
|
|
1524
|
+
return kept.length ? kept.join("; ") : void 0;
|
|
1525
|
+
}
|
|
1526
|
+
function stripServeTokenParam(pathWithQuery) {
|
|
1527
|
+
const qIdx = pathWithQuery.indexOf("?");
|
|
1528
|
+
if (qIdx < 0) return pathWithQuery;
|
|
1529
|
+
const base = pathWithQuery.slice(0, qIdx);
|
|
1530
|
+
const params = new URLSearchParams(pathWithQuery.slice(qIdx + 1));
|
|
1531
|
+
params.delete(SVAMP_SERVE_TOKEN_PARAM);
|
|
1532
|
+
const qs = params.toString();
|
|
1533
|
+
return qs ? `${base}?${qs}` : base;
|
|
1534
|
+
}
|
|
1535
|
+
function sanitizeForwardHeaders(headers) {
|
|
1536
|
+
const out = { ...headers };
|
|
1537
|
+
const cleaned = stripServeAuthCookie(typeof out.cookie === "string" ? out.cookie : void 0);
|
|
1538
|
+
if (cleaned === void 0) delete out.cookie;
|
|
1539
|
+
else out.cookie = cleaned;
|
|
1540
|
+
delete out.authorization;
|
|
1541
|
+
return out;
|
|
1542
|
+
}
|
|
1507
1543
|
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
1508
1544
|
const DEFAULT_CACHE_MAX_SIZE = 1e3;
|
|
1509
1545
|
function jsStringLiteral(value) {
|
|
@@ -2081,9 +2117,10 @@ function assertNonAdminMountDirSafe(directory, homeDir) {
|
|
|
2081
2117
|
}
|
|
2082
2118
|
function sanitizeMountForRole(mount, isAdmin) {
|
|
2083
2119
|
if (isAdmin) return mount;
|
|
2084
|
-
const { linkToken: _lt, directory: _dir, process: proc, ...rest } = mount;
|
|
2120
|
+
const { linkToken: _lt, directory: _dir, process: proc, url, ...rest } = mount;
|
|
2085
2121
|
const safeProcess = proc ? { ...proc, env: proc.env ? Object.fromEntries(Object.keys(proc.env).map((k) => [k, "***"])) : proc.env } : proc;
|
|
2086
|
-
|
|
2122
|
+
const safeUrl = mount.access === "link" ? void 0 : url;
|
|
2123
|
+
return { ...rest, process: safeProcess, ...safeUrl !== void 0 ? { url: safeUrl } : {} };
|
|
2087
2124
|
}
|
|
2088
2125
|
const DNS_LABEL_MAX = 63;
|
|
2089
2126
|
function buildLinkSubdomain(subdomainSafe, linkToken) {
|
|
@@ -2286,6 +2323,10 @@ class ServeManager {
|
|
|
2286
2323
|
}
|
|
2287
2324
|
return all;
|
|
2288
2325
|
}
|
|
2326
|
+
/** #0649: look up a single mount by name (for ownership checks before remove/replace). */
|
|
2327
|
+
getMount(name) {
|
|
2328
|
+
return this.mounts.get(name);
|
|
2329
|
+
}
|
|
2289
2330
|
/**
|
|
2290
2331
|
* Get server info — each mount has its own URL (per-mount subdomain).
|
|
2291
2332
|
*/
|
|
@@ -2437,9 +2478,9 @@ class ServeManager {
|
|
|
2437
2478
|
});
|
|
2438
2479
|
const warmupPath = cfg.warmupPath ?? "/";
|
|
2439
2480
|
const warmupTimeoutMs = cfg.warmupTimeoutMs ?? 3e4;
|
|
2440
|
-
const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs).then(() => {
|
|
2481
|
+
const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs, child).then(() => {
|
|
2441
2482
|
const h = this.managedProcs.get(name);
|
|
2442
|
-
if (h) {
|
|
2483
|
+
if (h && h.child === child) {
|
|
2443
2484
|
h.warmupPromise = null;
|
|
2444
2485
|
h.lastRequestAt = Date.now();
|
|
2445
2486
|
}
|
|
@@ -2447,7 +2488,8 @@ class ServeManager {
|
|
|
2447
2488
|
}).catch((err) => {
|
|
2448
2489
|
this.log(`Managed process '${name}' warmup failed: ${err.message}`);
|
|
2449
2490
|
this.killManagedTree(child, "SIGTERM");
|
|
2450
|
-
this.managedProcs.
|
|
2491
|
+
const h = this.managedProcs.get(name);
|
|
2492
|
+
if (h && h.child === child) this.managedProcs.delete(name);
|
|
2451
2493
|
throw err;
|
|
2452
2494
|
});
|
|
2453
2495
|
const newHandle = {
|
|
@@ -2482,10 +2524,13 @@ class ServeManager {
|
|
|
2482
2524
|
}
|
|
2483
2525
|
}
|
|
2484
2526
|
/** Poll a URL until it returns <500 or the deadline passes. */
|
|
2485
|
-
async warmupProbe(url, timeoutMs) {
|
|
2527
|
+
async warmupProbe(url, timeoutMs, child) {
|
|
2486
2528
|
const deadline = Date.now() + timeoutMs;
|
|
2487
2529
|
let lastErr;
|
|
2488
2530
|
while (Date.now() < deadline) {
|
|
2531
|
+
if (child && child.exitCode !== null) {
|
|
2532
|
+
throw new Error(`managed process exited (code=${child.exitCode}) before warmup completed`);
|
|
2533
|
+
}
|
|
2489
2534
|
try {
|
|
2490
2535
|
const ctrl = new AbortController();
|
|
2491
2536
|
const t = setTimeout(() => ctrl.abort(), 2e3);
|
|
@@ -2572,8 +2617,12 @@ class ServeManager {
|
|
|
2572
2617
|
};
|
|
2573
2618
|
try {
|
|
2574
2619
|
const tmp = `${this.persistFile}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
2575
|
-
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
2620
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
|
|
2576
2621
|
fs.renameSync(tmp, this.persistFile);
|
|
2622
|
+
try {
|
|
2623
|
+
fs.chmodSync(this.persistFile, 384);
|
|
2624
|
+
} catch {
|
|
2625
|
+
}
|
|
2577
2626
|
} catch (err) {
|
|
2578
2627
|
this.log(`Error persisting serve state: ${err.message}`);
|
|
2579
2628
|
}
|
|
@@ -2603,218 +2652,234 @@ a{color:#0969da;text-decoration:none;font-weight:500}a:hover{text-decoration:und
|
|
|
2603
2652
|
startAuthProxy() {
|
|
2604
2653
|
return new Promise((resolve, reject) => {
|
|
2605
2654
|
const server = http.createServer(async (req, res) => {
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
return;
|
|
2644
|
-
}
|
|
2645
|
-
if (mount && mount.access !== "public" && mount.access !== "link") {
|
|
2646
|
-
const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
|
|
2647
|
-
if (mount.access === "owner" && !mount.ownerEmail) {
|
|
2648
|
-
this.log(`Auth DENY '${mountName}': owner-only mount but ownerEmail is empty (misconfigured \u2014 the daemon's token has no email claim). Set access to an explicit email allowlist. requester=${userEmail || "anonymous"}`);
|
|
2649
|
-
const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, misconfigured: true, returnUrl: req.url || "/" }) : "Access denied (misconfigured owner mount).";
|
|
2650
|
-
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
2655
|
+
try {
|
|
2656
|
+
const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
|
|
2657
|
+
const incomingHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
2658
|
+
const hostMount = this.hostToMount.get(incomingHost);
|
|
2659
|
+
let mountName;
|
|
2660
|
+
let mountResolvedByHost = false;
|
|
2661
|
+
let basePath;
|
|
2662
|
+
if (hostMount && this.mounts.has(hostMount)) {
|
|
2663
|
+
mountName = hostMount;
|
|
2664
|
+
mountResolvedByHost = true;
|
|
2665
|
+
basePath = url.pathname;
|
|
2666
|
+
} else {
|
|
2667
|
+
mountName = url.pathname.split("/").filter(Boolean)[0];
|
|
2668
|
+
basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
|
|
2669
|
+
}
|
|
2670
|
+
const mount = mountName ? this.mounts.get(mountName) : void 0;
|
|
2671
|
+
if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
|
|
2672
|
+
res.writeHead(200, {
|
|
2673
|
+
"Content-Type": "application/json",
|
|
2674
|
+
"Cache-Control": "no-store"
|
|
2675
|
+
});
|
|
2676
|
+
res.end(JSON.stringify({
|
|
2677
|
+
ok: true,
|
|
2678
|
+
mount: mountName || null,
|
|
2679
|
+
ts: Date.now()
|
|
2680
|
+
}));
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
if (basePath === "/__login__" || url.pathname === "/__login__") {
|
|
2684
|
+
const returnUrl = url.searchParams.get("return") || "/";
|
|
2685
|
+
const isSameOriginPath = returnUrl.startsWith("/") && !returnUrl.startsWith("//") && !returnUrl.startsWith("/__login__");
|
|
2686
|
+
const safeReturn = isSameOriginPath ? returnUrl : "/";
|
|
2687
|
+
const html = this.auth ? this.auth.getLoginPageHtml(safeReturn) : "<h1>Auth not configured</h1>";
|
|
2688
|
+
res.writeHead(200, {
|
|
2689
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
2690
|
+
"Cache-Control": "no-store"
|
|
2691
|
+
});
|
|
2651
2692
|
res.end(html);
|
|
2652
2693
|
return;
|
|
2653
2694
|
}
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
if (!
|
|
2657
|
-
this.log(`Auth
|
|
2658
|
-
const
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2695
|
+
if (mount && mount.access !== "public" && mount.access !== "link") {
|
|
2696
|
+
const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
|
|
2697
|
+
if (mount.access === "owner" && !mount.ownerEmail) {
|
|
2698
|
+
this.log(`Auth DENY '${mountName}': owner-only mount but ownerEmail is empty (misconfigured \u2014 the daemon's token has no email claim). Set access to an explicit email allowlist. requester=${userEmail || "anonymous"}`);
|
|
2699
|
+
const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, misconfigured: true, returnUrl: req.url || "/" }) : "Access denied (misconfigured owner mount).";
|
|
2700
|
+
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
2701
|
+
res.end(html);
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
const allowed = this.auth ? this.auth.isAuthorized(userEmail, mount.access, mount.ownerEmail) : false;
|
|
2705
|
+
if (!allowed) {
|
|
2706
|
+
if (!userEmail) {
|
|
2707
|
+
this.log(`Auth: '${mountName}' requires sign-in (no valid token) \u2014 redirecting to /__login__`);
|
|
2708
|
+
const loginUrl = `/__login__?return=${encodeURIComponent(req.url || "/")}`;
|
|
2709
|
+
const headers = { Location: loginUrl };
|
|
2710
|
+
if (hasCookieToken(req)) {
|
|
2711
|
+
headers["Set-Cookie"] = "svamp_serve_token=; Path=/; Max-Age=0; SameSite=Lax";
|
|
2712
|
+
}
|
|
2713
|
+
res.writeHead(302, headers);
|
|
2714
|
+
res.end();
|
|
2715
|
+
return;
|
|
2662
2716
|
}
|
|
2663
|
-
|
|
2664
|
-
|
|
2717
|
+
const need = mount.access === "owner" ? `owner (${mount.ownerEmail || "unset"})` : `one of [${mount.access.join(", ")}]`;
|
|
2718
|
+
this.log(`Auth DENY '${mountName}': '${userEmail}' is not authorized (requires ${need})`);
|
|
2719
|
+
const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, returnUrl: req.url || "/" }) : `Access denied for ${userEmail}.`;
|
|
2720
|
+
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
2721
|
+
res.end(html);
|
|
2665
2722
|
return;
|
|
2666
2723
|
}
|
|
2667
|
-
|
|
2668
|
-
this.log(`Auth DENY '${mountName}': '${userEmail}' is not authorized (requires ${need})`);
|
|
2669
|
-
const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, returnUrl: req.url || "/" }) : `Access denied for ${userEmail}.`;
|
|
2670
|
-
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
2671
|
-
res.end(html);
|
|
2672
|
-
return;
|
|
2724
|
+
this.log(`Auth OK '${mountName}': ${userEmail}`);
|
|
2673
2725
|
}
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2726
|
+
const resolveContained = () => {
|
|
2727
|
+
const base = path.resolve(mount.directory);
|
|
2728
|
+
const fp = path.resolve(path.join(base, basePath));
|
|
2729
|
+
if (fp !== base && !fp.startsWith(base + path.sep)) return null;
|
|
2730
|
+
try {
|
|
2731
|
+
const realRoot = fs.realpathSync(base);
|
|
2732
|
+
let ancestor = fp;
|
|
2733
|
+
while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) {
|
|
2734
|
+
ancestor = path.dirname(ancestor);
|
|
2735
|
+
}
|
|
2736
|
+
const realAncestor = fs.realpathSync(ancestor);
|
|
2737
|
+
if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) return null;
|
|
2738
|
+
if (fs.existsSync(fp) && fs.lstatSync(fp).isSymbolicLink()) {
|
|
2739
|
+
const realTarget = fs.realpathSync(fp);
|
|
2740
|
+
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) return null;
|
|
2741
|
+
}
|
|
2742
|
+
} catch {
|
|
2743
|
+
return null;
|
|
2685
2744
|
}
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2745
|
+
return fp;
|
|
2746
|
+
};
|
|
2747
|
+
if ((req.method === "PUT" || req.method === "DELETE") && mount && mount.directory) {
|
|
2748
|
+
if (mount.access === "public") {
|
|
2749
|
+
this.log(`Write DENY '${mountName}': ${req.method} not allowed on a public (unauthenticated) mount`);
|
|
2750
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
2751
|
+
res.end("Write access denied: public mounts are read-only.");
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const filePath = resolveContained();
|
|
2755
|
+
if (!filePath) {
|
|
2756
|
+
this.log(`Write DENY '${mountName}': path escapes mount root (${basePath})`);
|
|
2757
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
2758
|
+
res.end("Forbidden: path escapes mount root.");
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
if (req.method === "PUT") {
|
|
2762
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2763
|
+
const ws = fs.createWriteStream(filePath);
|
|
2764
|
+
let done = false;
|
|
2765
|
+
const cleanupPartial = () => {
|
|
2766
|
+
if (done) return;
|
|
2767
|
+
done = true;
|
|
2768
|
+
ws.destroy();
|
|
2769
|
+
fs.unlink(filePath, () => {
|
|
2770
|
+
});
|
|
2771
|
+
};
|
|
2772
|
+
req.on("aborted", cleanupPartial);
|
|
2773
|
+
req.on("close", () => {
|
|
2774
|
+
if (!ws.writableFinished && !req.complete) cleanupPartial();
|
|
2775
|
+
});
|
|
2776
|
+
req.pipe(ws);
|
|
2777
|
+
ws.on("finish", () => {
|
|
2778
|
+
done = true;
|
|
2779
|
+
res.writeHead(201);
|
|
2780
|
+
res.end();
|
|
2781
|
+
});
|
|
2782
|
+
ws.on("error", (err) => {
|
|
2783
|
+
if (done) return;
|
|
2784
|
+
done = true;
|
|
2785
|
+
res.writeHead(500);
|
|
2786
|
+
res.end(err.message);
|
|
2787
|
+
});
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
try {
|
|
2791
|
+
fs.unlinkSync(filePath);
|
|
2792
|
+
res.writeHead(204);
|
|
2793
|
+
res.end();
|
|
2794
|
+
} catch (err) {
|
|
2795
|
+
res.writeHead(err.code === "ENOENT" ? 404 : 500);
|
|
2796
|
+
res.end(err.message);
|
|
2691
2797
|
}
|
|
2692
|
-
} catch {
|
|
2693
|
-
return null;
|
|
2694
|
-
}
|
|
2695
|
-
return fp;
|
|
2696
|
-
};
|
|
2697
|
-
if ((req.method === "PUT" || req.method === "DELETE") && mount && mount.directory) {
|
|
2698
|
-
if (mount.access === "public") {
|
|
2699
|
-
this.log(`Write DENY '${mountName}': ${req.method} not allowed on a public (unauthenticated) mount`);
|
|
2700
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
2701
|
-
res.end("Write access denied: public mounts are read-only.");
|
|
2702
|
-
return;
|
|
2703
|
-
}
|
|
2704
|
-
const filePath = resolveContained();
|
|
2705
|
-
if (!filePath) {
|
|
2706
|
-
this.log(`Write DENY '${mountName}': path escapes mount root (${basePath})`);
|
|
2707
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
2708
|
-
res.end("Forbidden: path escapes mount root.");
|
|
2709
2798
|
return;
|
|
2710
2799
|
}
|
|
2711
|
-
if (
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2800
|
+
if (mount && mount.process) {
|
|
2801
|
+
const cfg = mount.process;
|
|
2802
|
+
if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
|
|
2803
|
+
try {
|
|
2804
|
+
await this.ensureManagedRunning(mount.name);
|
|
2805
|
+
} catch (err) {
|
|
2806
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
2807
|
+
res.end(`Backend not ready: ${err?.message || err}`);
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
const handle = this.managedProcs.get(mount.name);
|
|
2812
|
+
if (handle) {
|
|
2813
|
+
handle.lastRequestAt = Date.now();
|
|
2814
|
+
handle.activeConns++;
|
|
2815
|
+
let released = false;
|
|
2816
|
+
const release = () => {
|
|
2817
|
+
if (released) return;
|
|
2818
|
+
released = true;
|
|
2819
|
+
handle.activeConns = Math.max(0, handle.activeConns - 1);
|
|
2820
|
+
handle.lastRequestAt = Date.now();
|
|
2821
|
+
};
|
|
2822
|
+
res.on("close", release);
|
|
2823
|
+
}
|
|
2824
|
+
const targetPath = stripServeTokenParam(mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || ""));
|
|
2825
|
+
const proxyReq = http.request({
|
|
2826
|
+
hostname: "127.0.0.1",
|
|
2827
|
+
port: cfg.port,
|
|
2828
|
+
path: targetPath,
|
|
2829
|
+
method: req.method,
|
|
2830
|
+
headers: sanitizeForwardHeaders(req.headers)
|
|
2831
|
+
}, (proxyRes) => {
|
|
2832
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
2833
|
+
proxyRes.pipe(res);
|
|
2725
2834
|
});
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2835
|
+
proxyReq.on("error", (err) => {
|
|
2836
|
+
if (!res.headersSent) {
|
|
2837
|
+
res.writeHead(502);
|
|
2838
|
+
res.end(`Backend error: ${err.message}`);
|
|
2839
|
+
}
|
|
2731
2840
|
});
|
|
2732
|
-
|
|
2733
|
-
if (
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2841
|
+
const abortUpstream = () => {
|
|
2842
|
+
if (!proxyReq.destroyed) proxyReq.destroy();
|
|
2843
|
+
};
|
|
2844
|
+
req.on("aborted", abortUpstream);
|
|
2845
|
+
res.on("close", () => {
|
|
2846
|
+
if (!res.writableFinished) abortUpstream();
|
|
2737
2847
|
});
|
|
2848
|
+
proxyReq.setTimeout(12e4, abortUpstream);
|
|
2849
|
+
req.pipe(proxyReq);
|
|
2738
2850
|
return;
|
|
2739
2851
|
}
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2852
|
+
if (mount && mount.directory) {
|
|
2853
|
+
serveStaticMount(req, res, {
|
|
2854
|
+
rootDir: mount.directory,
|
|
2855
|
+
relPath: basePath || "/",
|
|
2856
|
+
// Path-based access is prefixed with /<mountName>; host-based has no prefix.
|
|
2857
|
+
mountUrlPrefix: mountResolvedByHost ? "" : `/${mountName}`,
|
|
2858
|
+
browse: true,
|
|
2859
|
+
log: (m) => this.log(m)
|
|
2860
|
+
});
|
|
2861
|
+
return;
|
|
2747
2862
|
}
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2863
|
+
const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "*" };
|
|
2864
|
+
if (url.pathname === "/" || basePath === "/") {
|
|
2865
|
+
res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
2866
|
+
res.end(this.mountListHtml());
|
|
2867
|
+
} else {
|
|
2868
|
+
res.writeHead(404, { ...cors, "Content-Type": "text/plain" });
|
|
2869
|
+
res.end("Not found");
|
|
2870
|
+
}
|
|
2871
|
+
} catch (err) {
|
|
2872
|
+
this.log(`Auth-proxy request error: ${err?.message || err}`);
|
|
2873
|
+
if (!res.headersSent) {
|
|
2753
2874
|
try {
|
|
2754
|
-
|
|
2755
|
-
} catch
|
|
2756
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
2757
|
-
res.end(`Backend not ready: ${err?.message || err}`);
|
|
2758
|
-
return;
|
|
2875
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
2876
|
+
} catch {
|
|
2759
2877
|
}
|
|
2760
2878
|
}
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
handle.activeConns++;
|
|
2765
|
-
let released = false;
|
|
2766
|
-
const release = () => {
|
|
2767
|
-
if (released) return;
|
|
2768
|
-
released = true;
|
|
2769
|
-
handle.activeConns = Math.max(0, handle.activeConns - 1);
|
|
2770
|
-
handle.lastRequestAt = Date.now();
|
|
2771
|
-
};
|
|
2772
|
-
res.on("close", release);
|
|
2879
|
+
try {
|
|
2880
|
+
res.end("Internal Server Error");
|
|
2881
|
+
} catch {
|
|
2773
2882
|
}
|
|
2774
|
-
const targetPath = mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || "");
|
|
2775
|
-
const proxyReq = http.request({
|
|
2776
|
-
hostname: "127.0.0.1",
|
|
2777
|
-
port: cfg.port,
|
|
2778
|
-
path: targetPath,
|
|
2779
|
-
method: req.method,
|
|
2780
|
-
headers: req.headers
|
|
2781
|
-
}, (proxyRes) => {
|
|
2782
|
-
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
2783
|
-
proxyRes.pipe(res);
|
|
2784
|
-
});
|
|
2785
|
-
proxyReq.on("error", (err) => {
|
|
2786
|
-
if (!res.headersSent) {
|
|
2787
|
-
res.writeHead(502);
|
|
2788
|
-
res.end(`Backend error: ${err.message}`);
|
|
2789
|
-
}
|
|
2790
|
-
});
|
|
2791
|
-
const abortUpstream = () => {
|
|
2792
|
-
if (!proxyReq.destroyed) proxyReq.destroy();
|
|
2793
|
-
};
|
|
2794
|
-
req.on("aborted", abortUpstream);
|
|
2795
|
-
res.on("close", () => {
|
|
2796
|
-
if (!res.writableFinished) abortUpstream();
|
|
2797
|
-
});
|
|
2798
|
-
proxyReq.setTimeout(12e4, abortUpstream);
|
|
2799
|
-
req.pipe(proxyReq);
|
|
2800
|
-
return;
|
|
2801
|
-
}
|
|
2802
|
-
if (mount && mount.directory) {
|
|
2803
|
-
serveStaticMount(req, res, {
|
|
2804
|
-
rootDir: mount.directory,
|
|
2805
|
-
relPath: basePath || "/",
|
|
2806
|
-
// Path-based access is prefixed with /<mountName>; host-based has no prefix.
|
|
2807
|
-
mountUrlPrefix: mountResolvedByHost ? "" : `/${mountName}`,
|
|
2808
|
-
browse: true});
|
|
2809
|
-
return;
|
|
2810
|
-
}
|
|
2811
|
-
const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "*" };
|
|
2812
|
-
if (url.pathname === "/" || basePath === "/") {
|
|
2813
|
-
res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
2814
|
-
res.end(this.mountListHtml());
|
|
2815
|
-
} else {
|
|
2816
|
-
res.writeHead(404, { ...cors, "Content-Type": "text/plain" });
|
|
2817
|
-
res.end("Not found");
|
|
2818
2883
|
}
|
|
2819
2884
|
});
|
|
2820
2885
|
server.on("upgrade", (req, clientSocket, head) => {
|
|
@@ -2882,11 +2947,20 @@ Connection: close\r
|
|
|
2882
2947
|
handle.lastRequestAt = Date.now();
|
|
2883
2948
|
});
|
|
2884
2949
|
}
|
|
2885
|
-
const targetPath = mountResolvedByHost ? req.url || "/" : (url.pathname.slice(`/${mountName}`.length) || "/") + (url.search || "");
|
|
2950
|
+
const targetPath = stripServeTokenParam(mountResolvedByHost ? req.url || "/" : (url.pathname.slice(`/${mountName}`.length) || "/") + (url.search || ""));
|
|
2886
2951
|
const upstream = net.connect(cfg.port, "127.0.0.1", () => {
|
|
2887
2952
|
const lines = [`${req.method} ${targetPath} HTTP/1.1`];
|
|
2888
2953
|
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
2889
|
-
|
|
2954
|
+
const name = req.rawHeaders[i];
|
|
2955
|
+
const lname = name.toLowerCase();
|
|
2956
|
+
let value = req.rawHeaders[i + 1];
|
|
2957
|
+
if (lname === "authorization") continue;
|
|
2958
|
+
if (lname === "cookie") {
|
|
2959
|
+
const cleaned = stripServeAuthCookie(value);
|
|
2960
|
+
if (cleaned === void 0) continue;
|
|
2961
|
+
value = cleaned;
|
|
2962
|
+
}
|
|
2963
|
+
lines.push(`${name}: ${value}`);
|
|
2890
2964
|
}
|
|
2891
2965
|
upstream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
2892
2966
|
if (head && head.length) upstream.write(head);
|
|
@@ -2973,7 +3047,7 @@ Connection: close\r
|
|
|
2973
3047
|
const mount = this.mounts.get(mountName);
|
|
2974
3048
|
const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
|
|
2975
3049
|
try {
|
|
2976
|
-
const { FrpcTunnel } = await import('./frpc-
|
|
3050
|
+
const { FrpcTunnel } = await import('./frpc-C1jS23Sn.mjs');
|
|
2977
3051
|
let tunnel;
|
|
2978
3052
|
tunnel = new FrpcTunnel({
|
|
2979
3053
|
name: tunnelName,
|
|
@@ -3910,6 +3984,46 @@ function renderTemplate(template, ctx) {
|
|
|
3910
3984
|
});
|
|
3911
3985
|
}
|
|
3912
3986
|
|
|
3987
|
+
const _sleepSab$1 = new Int32Array(new SharedArrayBuffer(4));
|
|
3988
|
+
function sleepSync$1(ms) {
|
|
3989
|
+
try {
|
|
3990
|
+
Atomics.wait(_sleepSab$1, 0, 0, ms);
|
|
3991
|
+
} catch {
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
function withFileLock(lockPath, fn, opts) {
|
|
3995
|
+
const deadlineMs = 50;
|
|
3996
|
+
const staleMs = 5e3;
|
|
3997
|
+
const deadline = Date.now() + deadlineMs;
|
|
3998
|
+
let held = false;
|
|
3999
|
+
while (Date.now() < deadline) {
|
|
4000
|
+
try {
|
|
4001
|
+
mkdirSync$1(lockPath);
|
|
4002
|
+
held = true;
|
|
4003
|
+
break;
|
|
4004
|
+
} catch {
|
|
4005
|
+
try {
|
|
4006
|
+
if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
|
|
4007
|
+
rmSync$1(lockPath, { recursive: true, force: true });
|
|
4008
|
+
continue;
|
|
4009
|
+
}
|
|
4010
|
+
} catch {
|
|
4011
|
+
}
|
|
4012
|
+
sleepSync$1(5);
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
try {
|
|
4016
|
+
return fn();
|
|
4017
|
+
} finally {
|
|
4018
|
+
if (held) {
|
|
4019
|
+
try {
|
|
4020
|
+
rmSync$1(lockPath, { recursive: true, force: true });
|
|
4021
|
+
} catch {
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
|
|
3913
4027
|
const HARD_MAX_BYTES = 25 * 1024 * 1024;
|
|
3914
4028
|
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
3915
4029
|
const DEFAULT_MAX_COUNT = 100;
|
|
@@ -4125,6 +4239,22 @@ class ChannelStore {
|
|
|
4125
4239
|
_path(id) {
|
|
4126
4240
|
return join$1(this.dir, `${id}.json`);
|
|
4127
4241
|
}
|
|
4242
|
+
_lock(id) {
|
|
4243
|
+
return join$1(this.dir, `${id}.json.lock`);
|
|
4244
|
+
}
|
|
4245
|
+
// #0679: the actual validate + atomic write, WITHOUT the lock, so a locked RMW mutator can
|
|
4246
|
+
// reuse it inside its own lock section (no re-entrant re-lock / deadlock).
|
|
4247
|
+
_writeChannel(channel) {
|
|
4248
|
+
const c = { enabled: true, bind: "dynamic", template: DEFAULT_TEMPLATE, last_calls: [], ...channel };
|
|
4249
|
+
if (!c.id) c.id = genId();
|
|
4250
|
+
const errs = validateChannel(c);
|
|
4251
|
+
if (errs.length) throw new Error("invalid channel: " + errs.join("; "));
|
|
4252
|
+
mkdirSync$1(this.dir, { recursive: true });
|
|
4253
|
+
const tmp = `${this._path(c.id)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
4254
|
+
writeFileSync$1(tmp, JSON.stringify(c, null, 2));
|
|
4255
|
+
renameSync(tmp, this._path(c.id));
|
|
4256
|
+
return c;
|
|
4257
|
+
}
|
|
4128
4258
|
list() {
|
|
4129
4259
|
if (!existsSync(this.dir)) return [];
|
|
4130
4260
|
return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => {
|
|
@@ -4143,15 +4273,8 @@ class ChannelStore {
|
|
|
4143
4273
|
}
|
|
4144
4274
|
}
|
|
4145
4275
|
save(channel) {
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
const errs = validateChannel(c);
|
|
4149
|
-
if (errs.length) throw new Error("invalid channel: " + errs.join("; "));
|
|
4150
|
-
mkdirSync$1(this.dir, { recursive: true });
|
|
4151
|
-
const tmp = `${this._path(c.id)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
4152
|
-
writeFileSync$1(tmp, JSON.stringify(c, null, 2));
|
|
4153
|
-
renameSync(tmp, this._path(c.id));
|
|
4154
|
-
return c;
|
|
4276
|
+
if (channel.id) return withFileLock(this._lock(channel.id), () => this._writeChannel(channel));
|
|
4277
|
+
return this._writeChannel(channel);
|
|
4155
4278
|
}
|
|
4156
4279
|
remove(id) {
|
|
4157
4280
|
const p = this._path(id);
|
|
@@ -4161,28 +4284,40 @@ class ChannelStore {
|
|
|
4161
4284
|
}
|
|
4162
4285
|
return false;
|
|
4163
4286
|
}
|
|
4287
|
+
// #0679: setEnabled/recordCall/addCaller are read-modify-write mutators — multiple ChannelStore
|
|
4288
|
+
// instances (one per session) point at the same .svamp/channels/<id>.json, so without
|
|
4289
|
+
// serialization two concurrent RMWs both read the same base and the second save() clobbers the
|
|
4290
|
+
// first (addCaller silently DROPS a freshly-generated caller key). Hold the bounded fail-open
|
|
4291
|
+
// per-channel lock across the whole read→mutate→write so the read sees the other's committed
|
|
4292
|
+
// change (mirrors inboxGuard's #0625 withAwaitLock). _writeChannel is the un-locked write.
|
|
4164
4293
|
setEnabled(id, enabled) {
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4294
|
+
return withFileLock(this._lock(id), () => {
|
|
4295
|
+
const c = this.get(id);
|
|
4296
|
+
if (!c) return null;
|
|
4297
|
+
c.enabled = enabled;
|
|
4298
|
+
return this._writeChannel(c);
|
|
4299
|
+
});
|
|
4169
4300
|
}
|
|
4170
4301
|
recordCall(id, entry) {
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4302
|
+
withFileLock(this._lock(id), () => {
|
|
4303
|
+
const c = this.get(id);
|
|
4304
|
+
if (!c) return;
|
|
4305
|
+
c.last_calls = c.last_calls || [];
|
|
4306
|
+
c.last_calls.unshift({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
4307
|
+
c.last_calls = c.last_calls.slice(0, 20);
|
|
4308
|
+
this._writeChannel(c);
|
|
4309
|
+
});
|
|
4177
4310
|
}
|
|
4178
4311
|
addCaller(id, name, kind = "agent") {
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4312
|
+
return withFileLock(this._lock(id), () => {
|
|
4313
|
+
const c = this.get(id);
|
|
4314
|
+
if (!c) return null;
|
|
4315
|
+
c.identity.callers = c.identity.callers || [];
|
|
4316
|
+
const caller = { name, kind, key: genKey() };
|
|
4317
|
+
c.identity.callers.push(caller);
|
|
4318
|
+
this._writeChannel(c);
|
|
4319
|
+
return caller;
|
|
4320
|
+
});
|
|
4186
4321
|
}
|
|
4187
4322
|
}
|
|
4188
4323
|
function routingSession(channel, ctx) {
|
|
@@ -4326,6 +4461,13 @@ curl -X POST "${sendUrl}" \\
|
|
|
4326
4461
|
Always-current copy of this skill (all ids filled in): ${skillUrl}${uploadSection}${queueSection}`;
|
|
4327
4462
|
}
|
|
4328
4463
|
|
|
4464
|
+
function keysMatch(a, b) {
|
|
4465
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
4466
|
+
const ba = Buffer.from(a);
|
|
4467
|
+
const bb = Buffer.from(b);
|
|
4468
|
+
if (ba.length !== bb.length) return false;
|
|
4469
|
+
return timingSafeEqual(ba, bb);
|
|
4470
|
+
}
|
|
4329
4471
|
function resolveSender(channel, input = {}) {
|
|
4330
4472
|
const { key, from, hyphaUser, hyphaWorkspace, hyphaAnonymous } = input;
|
|
4331
4473
|
const id = channel.identity || {};
|
|
@@ -4342,7 +4484,7 @@ function resolveSender(channel, input = {}) {
|
|
|
4342
4484
|
return { sender: { name: id.fixed.name, kind: id.fixed.kind, verified: true } };
|
|
4343
4485
|
}
|
|
4344
4486
|
if (id.mode === "per-key") {
|
|
4345
|
-
const caller = (id.callers || []).find((c) => c.key && c.key
|
|
4487
|
+
const caller = (id.callers || []).find((c) => c.key && keysMatch(c.key, key));
|
|
4346
4488
|
if (!caller) {
|
|
4347
4489
|
const acceptsHypha = Array.isArray(id.hypha_allow) && id.hypha_allow.length > 0;
|
|
4348
4490
|
return { error: acceptsHypha ? "invalid or missing key \u2014 supply a caller key issued by the channel owner, or call with an authenticated Hypha identity (this channel accepts hypha_allow callers; anonymous/keyless requests are rejected)" : "invalid or missing key \u2014 this channel requires a caller key issued by the channel owner" };
|
|
@@ -4350,7 +4492,7 @@ function resolveSender(channel, input = {}) {
|
|
|
4350
4492
|
return { sender: { name: caller.name, kind: caller.kind, verified: true } };
|
|
4351
4493
|
}
|
|
4352
4494
|
if (id.mode === "caller-supplied") {
|
|
4353
|
-
if (id.shared_key && key
|
|
4495
|
+
if (id.shared_key && !keysMatch(key, id.shared_key)) return { error: "invalid key" };
|
|
4354
4496
|
return { sender: { name: from || "anonymous", kind: "user", verified: false } };
|
|
4355
4497
|
}
|
|
4356
4498
|
return { error: "unsupported identity mode" };
|
|
@@ -4592,6 +4734,11 @@ class ChannelOutbox {
|
|
|
4592
4734
|
this.reload();
|
|
4593
4735
|
this._compact();
|
|
4594
4736
|
}
|
|
4737
|
+
{
|
|
4738
|
+
const sig = this._stat();
|
|
4739
|
+
this._lastMtimeMs = sig.mtimeMs;
|
|
4740
|
+
this._lastSize = sig.size;
|
|
4741
|
+
}
|
|
4595
4742
|
this.emitter.emit(channelId, reply);
|
|
4596
4743
|
return reply;
|
|
4597
4744
|
}
|
|
@@ -4618,6 +4765,7 @@ class ChannelOutbox {
|
|
|
4618
4765
|
* the next append (filtered to this channel + `to`) or `timeoutMs`, then return.
|
|
4619
4766
|
*/
|
|
4620
4767
|
wait(channelId, cursor, to, timeoutMs, correlationId) {
|
|
4768
|
+
this.reload();
|
|
4621
4769
|
const ready = this.since(channelId, cursor, to, correlationId);
|
|
4622
4770
|
if (ready.length) return Promise.resolve({ replies: ready, cursor: this.cursor(channelId) });
|
|
4623
4771
|
return new Promise((resolve) => {
|
|
@@ -4626,12 +4774,23 @@ class ChannelOutbox {
|
|
|
4626
4774
|
cleanup();
|
|
4627
4775
|
resolve({ replies: this.since(channelId, cursor, to, correlationId), cursor: this.cursor(channelId) });
|
|
4628
4776
|
};
|
|
4777
|
+
const diskPoll = setInterval(() => {
|
|
4778
|
+
if (this.reload()) {
|
|
4779
|
+
const more = this.since(channelId, cursor, to, correlationId);
|
|
4780
|
+
if (more.length) {
|
|
4781
|
+
cleanup();
|
|
4782
|
+
resolve({ replies: more, cursor: this.cursor(channelId) });
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
}, 1e3);
|
|
4786
|
+
diskPoll.unref?.();
|
|
4629
4787
|
const timer = setTimeout(() => {
|
|
4630
4788
|
cleanup();
|
|
4631
4789
|
resolve({ replies: [], cursor: this.cursor(channelId) });
|
|
4632
4790
|
}, Math.max(0, timeoutMs));
|
|
4633
4791
|
const cleanup = () => {
|
|
4634
4792
|
clearTimeout(timer);
|
|
4793
|
+
clearInterval(diskPoll);
|
|
4635
4794
|
this.emitter.off(channelId, onReply);
|
|
4636
4795
|
};
|
|
4637
4796
|
this.emitter.on(channelId, onReply);
|
|
@@ -4650,11 +4809,57 @@ class ChannelOutbox {
|
|
|
4650
4809
|
if (existsSync(this.file)) this.reload();
|
|
4651
4810
|
this.byChannel.delete(channelId);
|
|
4652
4811
|
this.seqByChannel.delete(channelId);
|
|
4812
|
+
if (this.highWater.delete(channelId)) this._persistHighWater();
|
|
4653
4813
|
if (!existsSync(this.file)) return;
|
|
4654
4814
|
this._compact();
|
|
4655
4815
|
}
|
|
4656
4816
|
}
|
|
4657
4817
|
|
|
4818
|
+
const SESSION_METHOD_MIN_ROLE = {
|
|
4819
|
+
// interact
|
|
4820
|
+
sendMessage: "interact",
|
|
4821
|
+
abort: "interact",
|
|
4822
|
+
permissionResponse: "interact",
|
|
4823
|
+
regenerateSummary: "interact",
|
|
4824
|
+
keepAlive: "interact",
|
|
4825
|
+
sessionEnd: "interact",
|
|
4826
|
+
markInboxRead: "interact",
|
|
4827
|
+
sendInboxMessage: "interact",
|
|
4828
|
+
// admin
|
|
4829
|
+
updateMetadata: "admin",
|
|
4830
|
+
updateConfig: "admin",
|
|
4831
|
+
saveChannel: "admin",
|
|
4832
|
+
removeChannel: "admin",
|
|
4833
|
+
setChannelEnabled: "admin",
|
|
4834
|
+
addChannelCaller: "admin",
|
|
4835
|
+
outpostConnectInfo: "admin",
|
|
4836
|
+
outpostExec: "admin",
|
|
4837
|
+
channelReply: "admin",
|
|
4838
|
+
updateAgentState: "admin",
|
|
4839
|
+
switchMode: "admin",
|
|
4840
|
+
restartClaude: "admin",
|
|
4841
|
+
archiveSession: "admin",
|
|
4842
|
+
readFile: "admin",
|
|
4843
|
+
writeFile: "admin",
|
|
4844
|
+
listDirectory: "admin",
|
|
4845
|
+
bash: "admin",
|
|
4846
|
+
issue: "admin",
|
|
4847
|
+
workflow: "admin",
|
|
4848
|
+
ripgrep: "admin",
|
|
4849
|
+
getDirectoryTree: "admin",
|
|
4850
|
+
updateSharing: "admin",
|
|
4851
|
+
updateSecurityContext: "admin",
|
|
4852
|
+
setClaudeChrome: "admin",
|
|
4853
|
+
applySystemPrompt: "admin",
|
|
4854
|
+
clearInbox: "admin",
|
|
4855
|
+
btw: "admin",
|
|
4856
|
+
editMessage: "admin",
|
|
4857
|
+
refineLastReply: "admin",
|
|
4858
|
+
undoLastEdit: "admin"
|
|
4859
|
+
};
|
|
4860
|
+
function sessionMethodMinRole(method) {
|
|
4861
|
+
return SESSION_METHOD_MIN_ROLE[method] ?? "view";
|
|
4862
|
+
}
|
|
4658
4863
|
function getParamNames(fn) {
|
|
4659
4864
|
const src = fn.toString();
|
|
4660
4865
|
const match = src.match(/^(?:async\s+)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
@@ -4909,9 +5114,10 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
4909
5114
|
} catch {
|
|
4910
5115
|
}
|
|
4911
5116
|
if (hasMachineAccess) {
|
|
5117
|
+
const { securityContextConfig: _sc2, ...safeMetadata2 } = currentMetadata;
|
|
4912
5118
|
return {
|
|
4913
5119
|
machineId,
|
|
4914
|
-
metadata:
|
|
5120
|
+
metadata: safeMetadata2,
|
|
4915
5121
|
metadataVersion,
|
|
4916
5122
|
daemonState: currentDaemonState,
|
|
4917
5123
|
daemonStateVersion
|
|
@@ -4922,11 +5128,9 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
4922
5128
|
for (const sid of sessionIds) {
|
|
4923
5129
|
const rpc = handlers.getSessionRPCHandlers?.(sid);
|
|
4924
5130
|
if (!rpc) continue;
|
|
4925
|
-
|
|
4926
|
-
await rpc.getMetadata(context);
|
|
5131
|
+
if (await hasExplicitSessionAccess(rpc, "view", context)) {
|
|
4927
5132
|
hasSessionAccess = true;
|
|
4928
5133
|
break;
|
|
4929
|
-
} catch {
|
|
4930
5134
|
}
|
|
4931
5135
|
}
|
|
4932
5136
|
if (!hasSessionAccess) {
|
|
@@ -5031,10 +5235,11 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
5031
5235
|
if (!rpc) {
|
|
5032
5236
|
throw new Error(`Session ${sessionId} not found on this machine`);
|
|
5033
5237
|
}
|
|
5238
|
+
const requiredRole = sessionMethodMinRole(method);
|
|
5034
5239
|
try {
|
|
5035
|
-
authorizeRequest(context, currentMetadata.sharing,
|
|
5240
|
+
authorizeRequest(context, currentMetadata.sharing, requiredRole);
|
|
5036
5241
|
} catch (machineErr) {
|
|
5037
|
-
if (!await hasExplicitSessionAccess(rpc,
|
|
5242
|
+
if (!await hasExplicitSessionAccess(rpc, requiredRole, context)) throw machineErr;
|
|
5038
5243
|
}
|
|
5039
5244
|
const handler = rpc[method];
|
|
5040
5245
|
if (typeof handler !== "function") {
|
|
@@ -5883,7 +6088,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
5883
6088
|
const tunnels = handlers.tunnels;
|
|
5884
6089
|
if (!tunnels) throw new Error("Tunnel management not available");
|
|
5885
6090
|
if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
|
|
5886
|
-
const { FrpcTunnel } = await import('./frpc-
|
|
6091
|
+
const { FrpcTunnel } = await import('./frpc-C1jS23Sn.mjs');
|
|
5887
6092
|
const tunnel = new FrpcTunnel({
|
|
5888
6093
|
name: params.name,
|
|
5889
6094
|
ports: params.ports,
|
|
@@ -5998,6 +6203,14 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
5998
6203
|
const { homedir } = await import('os');
|
|
5999
6204
|
assertNonAdminMountDirSafe(params.directory, homedir());
|
|
6000
6205
|
}
|
|
6206
|
+
const existingMount = sm.getMount(params.name);
|
|
6207
|
+
if (existingMount && !serveCallerTrusted(context)) {
|
|
6208
|
+
const callerEmail = (context?.user?.email || "").toLowerCase();
|
|
6209
|
+
const mountOwner = (existingMount.ownerEmail || "").toLowerCase();
|
|
6210
|
+
if (!callerEmail || !mountOwner || callerEmail !== mountOwner) {
|
|
6211
|
+
throw new Error(`Not authorized to replace mount '${params.name}' (owned by another user)`);
|
|
6212
|
+
}
|
|
6213
|
+
}
|
|
6001
6214
|
const ownerEmail2 = params.ownerEmail || context?.user?.email || currentMetadata.sharing?.owner || process.env.SVAMP_OWNER_EMAIL || void 0;
|
|
6002
6215
|
const access = params.access ?? "owner";
|
|
6003
6216
|
return sm.applyMount({
|
|
@@ -6014,6 +6227,14 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
6014
6227
|
authorizeRequest(context, currentMetadata.sharing, "interact");
|
|
6015
6228
|
const sm = handlers.serveManager;
|
|
6016
6229
|
if (!sm) throw new Error("Serve manager not available");
|
|
6230
|
+
const target = sm.getMount(params.name);
|
|
6231
|
+
if (target && !serveCallerTrusted(context)) {
|
|
6232
|
+
const callerEmail = (context?.user?.email || "").toLowerCase();
|
|
6233
|
+
const mountOwner = (target.ownerEmail || "").toLowerCase();
|
|
6234
|
+
if (!callerEmail || !mountOwner || callerEmail !== mountOwner) {
|
|
6235
|
+
throw new Error(`Not authorized to remove mount '${params.name}' (owned by another user)`);
|
|
6236
|
+
}
|
|
6237
|
+
}
|
|
6017
6238
|
await sm.removeMount(params.name);
|
|
6018
6239
|
return { removed: true };
|
|
6019
6240
|
},
|
|
@@ -6033,7 +6254,9 @@ async function registerMachineService(server, machineId, metadata, daemonState,
|
|
|
6033
6254
|
if (!sm) throw new Error("Serve manager not available");
|
|
6034
6255
|
const info = sm.getInfo();
|
|
6035
6256
|
const isAdmin = serveCallerTrusted(context);
|
|
6036
|
-
|
|
6257
|
+
const mounts = (info.mounts || []).map((m) => sanitizeMountForRole(m, isAdmin));
|
|
6258
|
+
const topUrl = isAdmin ? info.url : mounts[0]?.url ?? null;
|
|
6259
|
+
return { ...info, url: topUrl, mounts };
|
|
6037
6260
|
},
|
|
6038
6261
|
/**
|
|
6039
6262
|
* Aggregate frpc tunnel health for all serve mounts.
|
|
@@ -6359,7 +6582,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
|
|
|
6359
6582
|
}
|
|
6360
6583
|
const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
|
|
6361
6584
|
const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
|
|
6362
|
-
const { toolsForRole } = await import('./sideband-
|
|
6585
|
+
const { toolsForRole } = await import('./sideband-DM1-IAV7.mjs');
|
|
6363
6586
|
const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
|
|
6364
6587
|
return fmt(r2);
|
|
6365
6588
|
}
|
|
@@ -6464,7 +6687,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
|
|
|
6464
6687
|
return { ok: false, call_id: callId, status: "busy", error: "channel is busy (too many concurrent requests) \u2014 retry shortly" };
|
|
6465
6688
|
}
|
|
6466
6689
|
const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
|
|
6467
|
-
const { queryCore } = await import('./commands
|
|
6690
|
+
const { queryCore } = await import('./commands-CvwLQ3MM.mjs');
|
|
6468
6691
|
const timeout = c.reply?.timeout_sec || 120;
|
|
6469
6692
|
let result;
|
|
6470
6693
|
try {
|
|
@@ -6742,6 +6965,48 @@ const AWAIT_MAX = num("SVAMP_INBOX_AWAIT_MAX", 200);
|
|
|
6742
6965
|
function awaitPath(sessionId) {
|
|
6743
6966
|
return join$1(SVAMP_HOME$2, "awaiting", `${sessionId}.json`);
|
|
6744
6967
|
}
|
|
6968
|
+
const _sleepSab = new Int32Array(new SharedArrayBuffer(4));
|
|
6969
|
+
function sleepSync(ms) {
|
|
6970
|
+
try {
|
|
6971
|
+
Atomics.wait(_sleepSab, 0, 0, ms);
|
|
6972
|
+
} catch {
|
|
6973
|
+
}
|
|
6974
|
+
}
|
|
6975
|
+
function withAwaitLock(sessionId, fn) {
|
|
6976
|
+
const lock = awaitPath(sessionId) + ".lock";
|
|
6977
|
+
try {
|
|
6978
|
+
mkdirSync$1(join$1(SVAMP_HOME$2, "awaiting"), { recursive: true });
|
|
6979
|
+
} catch {
|
|
6980
|
+
}
|
|
6981
|
+
const deadline = Date.now() + 50;
|
|
6982
|
+
let held = false;
|
|
6983
|
+
while (Date.now() < deadline) {
|
|
6984
|
+
try {
|
|
6985
|
+
mkdirSync$1(lock);
|
|
6986
|
+
held = true;
|
|
6987
|
+
break;
|
|
6988
|
+
} catch {
|
|
6989
|
+
try {
|
|
6990
|
+
if (Date.now() - statSync(lock).mtimeMs > 5e3) {
|
|
6991
|
+
rmSync$1(lock, { recursive: true, force: true });
|
|
6992
|
+
continue;
|
|
6993
|
+
}
|
|
6994
|
+
} catch {
|
|
6995
|
+
}
|
|
6996
|
+
sleepSync(5);
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
try {
|
|
7000
|
+
return fn();
|
|
7001
|
+
} finally {
|
|
7002
|
+
if (held) {
|
|
7003
|
+
try {
|
|
7004
|
+
rmSync$1(lock, { recursive: true, force: true });
|
|
7005
|
+
} catch {
|
|
7006
|
+
}
|
|
7007
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
}
|
|
6745
7010
|
function readAwaiting(sessionId) {
|
|
6746
7011
|
try {
|
|
6747
7012
|
const p = awaitPath(sessionId);
|
|
@@ -6764,14 +7029,16 @@ function writeAwaiting(sessionId, map) {
|
|
|
6764
7029
|
}
|
|
6765
7030
|
function registerAwaitingReply(sessionId, threadId, now = Date.now()) {
|
|
6766
7031
|
if (!sessionId || !threadId) return;
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
7032
|
+
withAwaitLock(sessionId, () => {
|
|
7033
|
+
const map = readAwaiting(sessionId);
|
|
7034
|
+
for (const k of Object.keys(map)) if (now - map[k] > AWAIT_TTL_MS) delete map[k];
|
|
7035
|
+
map[threadId] = now;
|
|
7036
|
+
const keys = Object.keys(map);
|
|
7037
|
+
if (keys.length > AWAIT_MAX) {
|
|
7038
|
+
keys.sort((a, b) => map[a] - map[b]).slice(0, keys.length - AWAIT_MAX).forEach((k) => delete map[k]);
|
|
7039
|
+
}
|
|
7040
|
+
writeAwaiting(sessionId, map);
|
|
7041
|
+
});
|
|
6775
7042
|
}
|
|
6776
7043
|
function peekAwaitingReply(sessionId, threadId, now = Date.now()) {
|
|
6777
7044
|
if (!sessionId || !threadId) return false;
|
|
@@ -6782,10 +7049,12 @@ function peekAwaitingReply(sessionId, threadId, now = Date.now()) {
|
|
|
6782
7049
|
}
|
|
6783
7050
|
function consumeAwaitingReply(sessionId, threadId) {
|
|
6784
7051
|
if (!sessionId || !threadId) return;
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
7052
|
+
withAwaitLock(sessionId, () => {
|
|
7053
|
+
const map = readAwaiting(sessionId);
|
|
7054
|
+
if (!(threadId in map)) return;
|
|
7055
|
+
delete map[threadId];
|
|
7056
|
+
writeAwaiting(sessionId, map);
|
|
7057
|
+
});
|
|
6789
7058
|
}
|
|
6790
7059
|
function computeOutboundHop(sessionId) {
|
|
6791
7060
|
if (!sessionId) return { hopCount: 0, fromInboxTurn: false };
|
|
@@ -6908,7 +7177,7 @@ function saveInbox(projectDir, sessionId, inbox) {
|
|
|
6908
7177
|
try {
|
|
6909
7178
|
const p = inboxFilePath(projectDir, sessionId);
|
|
6910
7179
|
mkdirSync$1(dirname(p), { recursive: true });
|
|
6911
|
-
const tmp = `${p}.tmp-${process.pid}`;
|
|
7180
|
+
const tmp = `${p}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
6912
7181
|
writeFileSync$1(tmp, JSON.stringify(inbox));
|
|
6913
7182
|
renameSync(tmp, p);
|
|
6914
7183
|
} catch {
|
|
@@ -7188,6 +7457,7 @@ class OutpostCoordinator {
|
|
|
7188
7457
|
clearTimer;
|
|
7189
7458
|
execTimeoutMs;
|
|
7190
7459
|
staleMs;
|
|
7460
|
+
sweeper = null;
|
|
7191
7461
|
constructor(opts = {}) {
|
|
7192
7462
|
this.now = opts.now || (() => Date.now());
|
|
7193
7463
|
this.setTimer = opts.setTimer || ((fn, ms) => setTimeout(fn, ms));
|
|
@@ -7208,6 +7478,35 @@ class OutpostCoordinator {
|
|
|
7208
7478
|
}
|
|
7209
7479
|
}
|
|
7210
7480
|
}
|
|
7481
|
+
/** Public sweep — prune stale connections and fail their in-flight execs. Safe to call anytime. */
|
|
7482
|
+
sweepStale() {
|
|
7483
|
+
this.prune();
|
|
7484
|
+
}
|
|
7485
|
+
/**
|
|
7486
|
+
* Start a periodic background sweep so an in-flight exec to a machine that STOPS polling is
|
|
7487
|
+
* failed FAST (#0612) — the header's "rejected immediately with a clear disconnected error"
|
|
7488
|
+
* promise. Without this driver, prune() only ran on-demand (register/list/isConnected), so an
|
|
7489
|
+
* already-enqueued exec waited the full OUTPOST_EXEC_TIMEOUT_MS backstop. Opt-in (the daemon
|
|
7490
|
+
* enables it; the pure unit tests drive time via injected timers and never call this). Unref'd
|
|
7491
|
+
* so it never keeps the process alive; idempotent.
|
|
7492
|
+
*/
|
|
7493
|
+
startSweeper(intervalMs = Math.max(5e3, Math.floor(this.staleMs / 3))) {
|
|
7494
|
+
if (this.sweeper) return;
|
|
7495
|
+
this.sweeper = setInterval(() => {
|
|
7496
|
+
try {
|
|
7497
|
+
this.prune();
|
|
7498
|
+
} catch {
|
|
7499
|
+
}
|
|
7500
|
+
}, intervalMs);
|
|
7501
|
+
this.sweeper.unref?.();
|
|
7502
|
+
}
|
|
7503
|
+
/** Stop the background sweep (e.g. on session teardown). Idempotent. */
|
|
7504
|
+
dispose() {
|
|
7505
|
+
if (this.sweeper) {
|
|
7506
|
+
clearInterval(this.sweeper);
|
|
7507
|
+
this.sweeper = null;
|
|
7508
|
+
}
|
|
7509
|
+
}
|
|
7211
7510
|
/** Reject every pending exec on a machine with a synthetic disconnect result (exitCode -1). */
|
|
7212
7511
|
failMachine(machine, reason) {
|
|
7213
7512
|
for (const [id, p] of [...this.pending]) {
|
|
@@ -7309,6 +7608,7 @@ class OutpostCoordinator {
|
|
|
7309
7608
|
resolveResult(result) {
|
|
7310
7609
|
const p = this.pending.get(result.id);
|
|
7311
7610
|
if (!p) return false;
|
|
7611
|
+
if (result.machine && result.machine !== p.machine) return false;
|
|
7312
7612
|
this.clearTimer(p.timer);
|
|
7313
7613
|
this.pending.delete(result.id);
|
|
7314
7614
|
this.touch(p.machine);
|
|
@@ -7734,6 +8034,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
|
|
|
7734
8034
|
const meetingActivity = [];
|
|
7735
8035
|
const channelOutbox = new ChannelOutbox(initialMetadata.path);
|
|
7736
8036
|
const outpostCoordinator = new OutpostCoordinator();
|
|
8037
|
+
outpostCoordinator.startSweeper();
|
|
7737
8038
|
const outpostProjectDir = () => metadata.path || process.cwd();
|
|
7738
8039
|
const announceOutpost = (conn) => {
|
|
7739
8040
|
const who = conn.label || conn.machine;
|
|
@@ -7804,7 +8105,10 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
|
|
|
7804
8105
|
const ensureParticipantsChannel = (sharing) => {
|
|
7805
8106
|
try {
|
|
7806
8107
|
if (sharing?.enabled) channelStore.save(buildParticipantsChannel(sharing));
|
|
7807
|
-
else
|
|
8108
|
+
else {
|
|
8109
|
+
channelStore.remove(PARTICIPANTS_CHANNEL_ID);
|
|
8110
|
+
channelOutbox.purge(PARTICIPANTS_CHANNEL_ID);
|
|
8111
|
+
}
|
|
7808
8112
|
} catch {
|
|
7809
8113
|
}
|
|
7810
8114
|
};
|
|
@@ -8228,7 +8532,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
|
|
|
8228
8532
|
const rec = loadOutpostToken(outpostProjectDir(), sessionId);
|
|
8229
8533
|
if (!outpostTokenMatches(rec, params?.token)) return { error: "unauthorized" };
|
|
8230
8534
|
if (!params?.id) return { error: "missing result id" };
|
|
8231
|
-
const ok = outpostCoordinator.resolveResult({ id: params.id, stdout: params.stdout, stderr: params.stderr, exitCode: params.exitCode });
|
|
8535
|
+
const ok = outpostCoordinator.resolveResult({ id: params.id, stdout: params.stdout, stderr: params.stderr, exitCode: params.exitCode, machine: params.machine });
|
|
8232
8536
|
return { ok };
|
|
8233
8537
|
},
|
|
8234
8538
|
outpostExec: async (params, context) => {
|
|
@@ -8743,7 +9047,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
|
|
|
8743
9047
|
// ── Inbox ──
|
|
8744
9048
|
sendInboxMessage: async (message, context) => {
|
|
8745
9049
|
authorizeRequest(context, metadata.sharing, "interact");
|
|
8746
|
-
const trusted = getEffectiveRole(context, metadata.sharing) === "admin";
|
|
9050
|
+
const trusted = !context?.user || isSameOwnerWorkspace(context) || !!metadata.sharing && getEffectiveRole(context, metadata.sharing) === "admin";
|
|
8747
9051
|
const msg = sanitizeInboundInboxMessage(message, { trusted, callerEmail: context?.user?.email });
|
|
8748
9052
|
inbox.push(msg);
|
|
8749
9053
|
while (inbox.length > INBOX_MAX) inbox.shift();
|
|
@@ -13517,7 +13821,13 @@ const MAX_RESTART_DELAY_S = 300;
|
|
|
13517
13821
|
const BACKOFF_RESET_WINDOW_MS = 6e4;
|
|
13518
13822
|
const MAX_LOG_LINES = 300;
|
|
13519
13823
|
const CRASH_LOOP_FAILURE_THRESHOLD = 10;
|
|
13520
|
-
|
|
13824
|
+
function accumulateCrashLoopFailure(prev, healthy, now) {
|
|
13825
|
+
if (healthy) return { consecutiveFailures: 0, failureWindowStart: void 0 };
|
|
13826
|
+
return {
|
|
13827
|
+
consecutiveFailures: prev.consecutiveFailures + 1,
|
|
13828
|
+
failureWindowStart: prev.failureWindowStart ?? now
|
|
13829
|
+
};
|
|
13830
|
+
}
|
|
13521
13831
|
class ProcessSupervisor {
|
|
13522
13832
|
entries = /* @__PURE__ */ new Map();
|
|
13523
13833
|
persistDir;
|
|
@@ -13924,17 +14234,13 @@ class ProcessSupervisor {
|
|
|
13924
14234
|
if (!spec.keepAlive) return;
|
|
13925
14235
|
const uptime = state.startedAt ? Date.now() - state.startedAt : 0;
|
|
13926
14236
|
const now = Date.now();
|
|
13927
|
-
|
|
13928
|
-
state.consecutiveFailures
|
|
13929
|
-
|
|
13930
|
-
|
|
13931
|
-
|
|
13932
|
-
|
|
13933
|
-
|
|
13934
|
-
} else {
|
|
13935
|
-
state.consecutiveFailures++;
|
|
13936
|
-
}
|
|
13937
|
-
}
|
|
14237
|
+
const acct = accumulateCrashLoopFailure(
|
|
14238
|
+
{ consecutiveFailures: state.consecutiveFailures, failureWindowStart: entry.failureWindowStart },
|
|
14239
|
+
uptime > BACKOFF_RESET_WINDOW_MS,
|
|
14240
|
+
now
|
|
14241
|
+
);
|
|
14242
|
+
state.consecutiveFailures = acct.consecutiveFailures;
|
|
14243
|
+
entry.failureWindowStart = acct.failureWindowStart;
|
|
13938
14244
|
if (state.consecutiveFailures >= CRASH_LOOP_FAILURE_THRESHOLD) {
|
|
13939
14245
|
const windowS = Math.round((now - (entry.failureWindowStart ?? now)) / 1e3);
|
|
13940
14246
|
state.status = "crash-loop-backoff";
|
|
@@ -14193,22 +14499,29 @@ function parseIssue(content) {
|
|
|
14193
14499
|
body: (m[2] || "").trim() || void 0
|
|
14194
14500
|
};
|
|
14195
14501
|
}
|
|
14196
|
-
function
|
|
14502
|
+
function readDirWithMtime(dir) {
|
|
14197
14503
|
if (!existsSync(dir)) return [];
|
|
14198
14504
|
const out = [];
|
|
14199
14505
|
for (const name of readdirSync(dir)) {
|
|
14200
14506
|
if (!name.endsWith(".md")) continue;
|
|
14201
14507
|
try {
|
|
14202
|
-
const
|
|
14203
|
-
|
|
14508
|
+
const p = join$1(dir, name);
|
|
14509
|
+
const issue = parseIssue(readFileSync(p, "utf-8"));
|
|
14510
|
+
if (issue) out.push({ issue, mtimeMs: statSync(p).mtimeMs });
|
|
14204
14511
|
} catch {
|
|
14205
14512
|
}
|
|
14206
14513
|
}
|
|
14207
14514
|
return out;
|
|
14208
14515
|
}
|
|
14209
14516
|
function listIssues(projectRoot, opts = {}) {
|
|
14210
|
-
|
|
14211
|
-
if (opts.includeArchived)
|
|
14517
|
+
const entries = readDirWithMtime(issuesDir(projectRoot));
|
|
14518
|
+
if (opts.includeArchived) entries.push(...readDirWithMtime(archiveDir(projectRoot)));
|
|
14519
|
+
const byId = /* @__PURE__ */ new Map();
|
|
14520
|
+
for (const e of entries) {
|
|
14521
|
+
const prev = byId.get(e.issue.id);
|
|
14522
|
+
if (!prev || e.mtimeMs >= prev.mtimeMs) byId.set(e.issue.id, e);
|
|
14523
|
+
}
|
|
14524
|
+
let items = Array.from(byId.values()).map((e) => e.issue);
|
|
14212
14525
|
if (opts.status) items = items.filter((i) => i.status === opts.status);
|
|
14213
14526
|
if (opts.label) items = items.filter((i) => i.labels.includes(opts.label));
|
|
14214
14527
|
if (opts.scope) items = items.filter((i) => i.scope === opts.scope);
|
|
@@ -14223,20 +14536,22 @@ function isVisibleTo(issue, session) {
|
|
|
14223
14536
|
}
|
|
14224
14537
|
function getIssue(projectRoot, id) {
|
|
14225
14538
|
const padded = /^\d+$/.test(id) ? id.padStart(4, "0") : id;
|
|
14539
|
+
let best = null;
|
|
14226
14540
|
for (const p of [issuePath(projectRoot, padded), issuePath(projectRoot, padded, true)]) {
|
|
14227
|
-
if (existsSync(p))
|
|
14228
|
-
|
|
14229
|
-
|
|
14230
|
-
|
|
14231
|
-
|
|
14232
|
-
}
|
|
14541
|
+
if (!existsSync(p)) continue;
|
|
14542
|
+
try {
|
|
14543
|
+
const issue = parseIssue(readFileSync(p, "utf-8"));
|
|
14544
|
+
if (!issue) continue;
|
|
14545
|
+
const mtimeMs = statSync(p).mtimeMs;
|
|
14546
|
+
if (!best || mtimeMs >= best.mtimeMs) best = { issue, mtimeMs };
|
|
14547
|
+
} catch {
|
|
14233
14548
|
}
|
|
14234
14549
|
}
|
|
14235
|
-
return null;
|
|
14550
|
+
return best ? best.issue : null;
|
|
14236
14551
|
}
|
|
14237
14552
|
function atomicWrite(path, content) {
|
|
14238
14553
|
mkdirSync$1(dirname(path), { recursive: true });
|
|
14239
|
-
const tmp = `${path}.tmp-${process.pid}`;
|
|
14554
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
14240
14555
|
writeFileSync$1(tmp, content);
|
|
14241
14556
|
renameSync(tmp, path);
|
|
14242
14557
|
}
|
|
@@ -14280,7 +14595,18 @@ function addIssue(projectRoot, fields) {
|
|
|
14280
14595
|
}
|
|
14281
14596
|
throw new Error("addIssue: could not allocate a free issue id");
|
|
14282
14597
|
}
|
|
14283
|
-
function
|
|
14598
|
+
function issueLockPath(projectRoot, id) {
|
|
14599
|
+
const padded = /^\d+$/.test(id) ? id.padStart(4, "0") : id;
|
|
14600
|
+
return join$1(issuesDir(projectRoot), `${padded}.lock`);
|
|
14601
|
+
}
|
|
14602
|
+
function withIssueLock(projectRoot, id, fn) {
|
|
14603
|
+
try {
|
|
14604
|
+
mkdirSync$1(issuesDir(projectRoot), { recursive: true });
|
|
14605
|
+
} catch {
|
|
14606
|
+
}
|
|
14607
|
+
return withFileLock(issueLockPath(projectRoot, id), fn);
|
|
14608
|
+
}
|
|
14609
|
+
function _updateIssueUnlocked(projectRoot, id, patch) {
|
|
14284
14610
|
const cur = getIssue(projectRoot, id);
|
|
14285
14611
|
if (!cur) return null;
|
|
14286
14612
|
const wasArchived = cur.status === "archived";
|
|
@@ -14299,17 +14625,23 @@ function updateIssue(projectRoot, id, patch) {
|
|
|
14299
14625
|
}
|
|
14300
14626
|
return next;
|
|
14301
14627
|
}
|
|
14628
|
+
function updateIssue(projectRoot, id, patch) {
|
|
14629
|
+
return withIssueLock(projectRoot, id, () => _updateIssueUnlocked(projectRoot, id, patch));
|
|
14630
|
+
}
|
|
14302
14631
|
function addComment(projectRoot, id, text) {
|
|
14303
|
-
|
|
14304
|
-
|
|
14305
|
-
|
|
14632
|
+
if (!text.trim()) return getIssue(projectRoot, id);
|
|
14633
|
+
return withIssueLock(projectRoot, id, () => {
|
|
14634
|
+
const cur = getIssue(projectRoot, id);
|
|
14635
|
+
if (!cur) return null;
|
|
14636
|
+
const entry = `
|
|
14306
14637
|
|
|
14307
14638
|
---
|
|
14308
14639
|
**Follow-up \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}**
|
|
14309
14640
|
|
|
14310
14641
|
${text.trim()}`;
|
|
14311
|
-
|
|
14312
|
-
|
|
14642
|
+
const body = (cur.body ? cur.body.replace(/\s+$/, "") : "") + entry;
|
|
14643
|
+
return _updateIssueUnlocked(projectRoot, id, { body });
|
|
14644
|
+
});
|
|
14313
14645
|
}
|
|
14314
14646
|
function pauseIssue(projectRoot, id, question) {
|
|
14315
14647
|
const cur = getIssue(projectRoot, id);
|
|
@@ -14376,8 +14708,14 @@ function workflowCrons(wf) {
|
|
|
14376
14708
|
function workflowsDir(projectRoot) {
|
|
14377
14709
|
return join$1(projectRoot, ".svamp", "workflows");
|
|
14378
14710
|
}
|
|
14711
|
+
function validateWorkflowName(name) {
|
|
14712
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
|
|
14713
|
+
throw new Error(`Invalid workflow name "${name}" \u2014 allowed: letters, digits, dot, dash, underscore (no path separators).`);
|
|
14714
|
+
}
|
|
14715
|
+
return name;
|
|
14716
|
+
}
|
|
14379
14717
|
function workflowPath(projectRoot, name) {
|
|
14380
|
-
return join$1(workflowsDir(projectRoot), `${name}.yaml`);
|
|
14718
|
+
return join$1(workflowsDir(projectRoot), `${validateWorkflowName(name)}.yaml`);
|
|
14381
14719
|
}
|
|
14382
14720
|
function normalizeOn(on) {
|
|
14383
14721
|
if (!on || typeof on !== "object") {
|
|
@@ -14469,7 +14807,12 @@ function listWorkflows(projectRoot) {
|
|
|
14469
14807
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
14470
14808
|
}
|
|
14471
14809
|
function getWorkflow(projectRoot, name) {
|
|
14472
|
-
|
|
14810
|
+
let p;
|
|
14811
|
+
try {
|
|
14812
|
+
p = workflowPath(projectRoot, name);
|
|
14813
|
+
} catch {
|
|
14814
|
+
return null;
|
|
14815
|
+
}
|
|
14473
14816
|
if (!existsSync(p)) return null;
|
|
14474
14817
|
try {
|
|
14475
14818
|
return parseWorkflow(readFileSync(p, "utf-8"));
|
|
@@ -14478,14 +14821,19 @@ function getWorkflow(projectRoot, name) {
|
|
|
14478
14821
|
}
|
|
14479
14822
|
}
|
|
14480
14823
|
function rawWorkflow(projectRoot, name) {
|
|
14481
|
-
|
|
14824
|
+
let p;
|
|
14825
|
+
try {
|
|
14826
|
+
p = workflowPath(projectRoot, name);
|
|
14827
|
+
} catch {
|
|
14828
|
+
return null;
|
|
14829
|
+
}
|
|
14482
14830
|
return existsSync(p) ? readFileSync(p, "utf-8") : null;
|
|
14483
14831
|
}
|
|
14484
14832
|
function saveWorkflow(projectRoot, wf) {
|
|
14485
14833
|
const dir = workflowsDir(projectRoot);
|
|
14486
14834
|
mkdirSync$1(dir, { recursive: true });
|
|
14487
14835
|
const path = workflowPath(projectRoot, wf.name);
|
|
14488
|
-
const tmp = `${path}.tmp-${process.pid}`;
|
|
14836
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
14489
14837
|
writeFileSync$1(tmp, serializeWorkflow(wf));
|
|
14490
14838
|
renameSync(tmp, path);
|
|
14491
14839
|
}
|
|
@@ -14517,6 +14865,10 @@ function maxRunsPerWorkflow() {
|
|
|
14517
14865
|
}
|
|
14518
14866
|
const MAX_STREAM_CHARS = 8e3;
|
|
14519
14867
|
const STEP_TIMEOUT_MS = 12e4;
|
|
14868
|
+
function stepTimeoutMs() {
|
|
14869
|
+
const v = Number(process.env.SVAMP_WORKFLOW_STEP_TIMEOUT_MS);
|
|
14870
|
+
return Number.isFinite(v) && v > 0 ? v : STEP_TIMEOUT_MS;
|
|
14871
|
+
}
|
|
14520
14872
|
function capStream(s, max = MAX_STREAM_CHARS) {
|
|
14521
14873
|
if (s.length <= max) return s;
|
|
14522
14874
|
const head = Math.floor(max * 0.6);
|
|
@@ -14546,14 +14898,16 @@ function recordRun(projectRoot, run) {
|
|
|
14546
14898
|
const dir = runsDir(projectRoot);
|
|
14547
14899
|
if (!existsSync(dir)) mkdirSync$1(dir, { recursive: true });
|
|
14548
14900
|
const file = runsFile(projectRoot, run.workflow);
|
|
14549
|
-
|
|
14550
|
-
|
|
14551
|
-
|
|
14552
|
-
|
|
14553
|
-
|
|
14554
|
-
|
|
14555
|
-
|
|
14556
|
-
|
|
14901
|
+
withFileLock(`${file}.lock`, () => {
|
|
14902
|
+
const existing = loadRuns(projectRoot, run.workflow);
|
|
14903
|
+
const idx = existing.findIndex((r) => r.id === run.id);
|
|
14904
|
+
if (idx >= 0) existing[idx] = run;
|
|
14905
|
+
else existing.push(run);
|
|
14906
|
+
const kept = existing.slice(-maxRunsPerWorkflow());
|
|
14907
|
+
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
14908
|
+
writeFileSync$1(tmp, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
14909
|
+
renameSync(tmp, file);
|
|
14910
|
+
});
|
|
14557
14911
|
} catch {
|
|
14558
14912
|
}
|
|
14559
14913
|
}
|
|
@@ -14589,6 +14943,48 @@ function listRuns(projectRoot, workflow, opts = {}) {
|
|
|
14589
14943
|
function getRun(projectRoot, workflow, runId) {
|
|
14590
14944
|
return loadRuns(projectRoot, workflow).find((r) => r.id === runId) || null;
|
|
14591
14945
|
}
|
|
14946
|
+
function reconcileInterruptedRuns(projectRoot) {
|
|
14947
|
+
let fixed = 0;
|
|
14948
|
+
try {
|
|
14949
|
+
const dir = runsDir(projectRoot);
|
|
14950
|
+
if (!existsSync(dir)) return 0;
|
|
14951
|
+
for (const entry of readdirSync(dir)) {
|
|
14952
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
14953
|
+
const file = join$1(dir, entry);
|
|
14954
|
+
let runs;
|
|
14955
|
+
try {
|
|
14956
|
+
runs = readFileSync(file, "utf-8").split("\n").filter(Boolean).map((l) => {
|
|
14957
|
+
try {
|
|
14958
|
+
return JSON.parse(l);
|
|
14959
|
+
} catch {
|
|
14960
|
+
return null;
|
|
14961
|
+
}
|
|
14962
|
+
}).filter((r) => !!r);
|
|
14963
|
+
} catch {
|
|
14964
|
+
continue;
|
|
14965
|
+
}
|
|
14966
|
+
let changed = false;
|
|
14967
|
+
for (const r of runs) {
|
|
14968
|
+
if (r.status !== "running") continue;
|
|
14969
|
+
if (isWorkflowInFlight(projectRoot, r.workflow)) continue;
|
|
14970
|
+
r.status = "failed";
|
|
14971
|
+
r.error = r.error || "interrupted by daemon restart";
|
|
14972
|
+
r.finishedAt = r.finishedAt || r.startedAt;
|
|
14973
|
+
changed = true;
|
|
14974
|
+
fixed++;
|
|
14975
|
+
}
|
|
14976
|
+
if (!changed) continue;
|
|
14977
|
+
try {
|
|
14978
|
+
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
14979
|
+
writeFileSync$1(tmp, runs.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
14980
|
+
renameSync(tmp, file);
|
|
14981
|
+
} catch {
|
|
14982
|
+
}
|
|
14983
|
+
}
|
|
14984
|
+
} catch {
|
|
14985
|
+
}
|
|
14986
|
+
return fixed;
|
|
14987
|
+
}
|
|
14592
14988
|
function defaultExecStep(root, cmd, env) {
|
|
14593
14989
|
return new Promise((resolve) => {
|
|
14594
14990
|
let stdout = "", stderr = "", timedOut = false, settled = false;
|
|
@@ -14598,14 +14994,18 @@ function defaultExecStep(root, cmd, env) {
|
|
|
14598
14994
|
resolve({ exitCode, stdout, stderr, timedOut });
|
|
14599
14995
|
};
|
|
14600
14996
|
try {
|
|
14601
|
-
const child = spawn$1("sh", ["-c", cmd], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
14997
|
+
const child = spawn$1("sh", ["-c", cmd], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
14602
14998
|
const timer = setTimeout(() => {
|
|
14603
14999
|
timedOut = true;
|
|
14604
15000
|
try {
|
|
14605
|
-
child.kill("SIGKILL");
|
|
15001
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
14606
15002
|
} catch {
|
|
15003
|
+
try {
|
|
15004
|
+
child.kill("SIGKILL");
|
|
15005
|
+
} catch {
|
|
15006
|
+
}
|
|
14607
15007
|
}
|
|
14608
|
-
},
|
|
15008
|
+
}, stepTimeoutMs());
|
|
14609
15009
|
child.stdout?.on("data", (d) => {
|
|
14610
15010
|
stdout += d.toString();
|
|
14611
15011
|
if (stdout.length > MAX_STREAM_CHARS * 4) stdout = capStream(stdout);
|
|
@@ -14684,6 +15084,9 @@ const inFlightRuns = /* @__PURE__ */ new Map();
|
|
|
14684
15084
|
function workflowLockKey(projectRoot, name) {
|
|
14685
15085
|
return `${projectRoot}\0${name}`;
|
|
14686
15086
|
}
|
|
15087
|
+
function isWorkflowInFlight(projectRoot, name) {
|
|
15088
|
+
return inFlightRuns.has(workflowLockKey(projectRoot, name));
|
|
15089
|
+
}
|
|
14687
15090
|
function runWorkflow(projectRoot, wf, opts) {
|
|
14688
15091
|
if (opts.allowConcurrent) return runWorkflowInner(projectRoot, wf, opts);
|
|
14689
15092
|
const key = workflowLockKey(projectRoot, wf.name);
|
|
@@ -14760,6 +15163,25 @@ async function runWorkflowInner(projectRoot, wf, opts) {
|
|
|
14760
15163
|
return run;
|
|
14761
15164
|
}
|
|
14762
15165
|
|
|
15166
|
+
var runStore = /*#__PURE__*/Object.freeze({
|
|
15167
|
+
__proto__: null,
|
|
15168
|
+
MAX_RUNS_PER_WORKFLOW: MAX_RUNS_PER_WORKFLOW,
|
|
15169
|
+
MAX_STREAM_CHARS: MAX_STREAM_CHARS,
|
|
15170
|
+
STEP_TIMEOUT_MS: STEP_TIMEOUT_MS,
|
|
15171
|
+
capStream: capStream,
|
|
15172
|
+
escalateWorkflowFailure: escalateWorkflowFailure,
|
|
15173
|
+
getRun: getRun,
|
|
15174
|
+
isWorkflowInFlight: isWorkflowInFlight,
|
|
15175
|
+
listRuns: listRuns,
|
|
15176
|
+
loadRuns: loadRuns,
|
|
15177
|
+
maxRunsPerWorkflow: maxRunsPerWorkflow,
|
|
15178
|
+
reconcileInterruptedRuns: reconcileInterruptedRuns,
|
|
15179
|
+
recordRun: recordRun,
|
|
15180
|
+
runWorkflow: runWorkflow,
|
|
15181
|
+
runsDir: runsDir,
|
|
15182
|
+
stepTimeoutMs: stepTimeoutMs
|
|
15183
|
+
});
|
|
15184
|
+
|
|
14763
15185
|
const DEFAULT_IDLE_COOLDOWN_MS = 15e3;
|
|
14764
15186
|
function idleCooldownMs() {
|
|
14765
15187
|
const raw = Number(process.env.SVAMP_WORKFLOW_IDLE_COOLDOWN_MS);
|
|
@@ -15162,7 +15584,9 @@ async function readCredentials() {
|
|
|
15162
15584
|
}
|
|
15163
15585
|
async function writeCredentials(creds) {
|
|
15164
15586
|
const path = getCredentialsPath();
|
|
15165
|
-
|
|
15587
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
15588
|
+
await writeFile(tmp, JSON.stringify(creds), { mode: 384 });
|
|
15589
|
+
await rename$1(tmp, path);
|
|
15166
15590
|
}
|
|
15167
15591
|
async function checkAndRefreshOAuthToken(force = false, logger) {
|
|
15168
15592
|
const log = logger?.log ?? (() => {
|
|
@@ -17051,7 +17475,7 @@ async function startDaemon(options) {
|
|
|
17051
17475
|
try {
|
|
17052
17476
|
const dir = loadSessionIndex()[sessionId]?.directory;
|
|
17053
17477
|
if (!dir) return;
|
|
17054
|
-
const { reconcileServiceLinks } = await import('./agentCommands-
|
|
17478
|
+
const { reconcileServiceLinks } = await import('./agentCommands-CUcyA_KE.mjs');
|
|
17055
17479
|
const configPath = getSvampConfigPath(dir, sessionId);
|
|
17056
17480
|
const config = readSvampConfig(configPath);
|
|
17057
17481
|
const entries = Array.from(urls.entries());
|
|
@@ -17069,7 +17493,7 @@ async function startDaemon(options) {
|
|
|
17069
17493
|
}
|
|
17070
17494
|
}
|
|
17071
17495
|
async function createExposedTunnel(spec) {
|
|
17072
|
-
const { FrpcTunnel } = await import('./frpc-
|
|
17496
|
+
const { FrpcTunnel } = await import('./frpc-C1jS23Sn.mjs');
|
|
17073
17497
|
const tunnel = new FrpcTunnel({
|
|
17074
17498
|
name: spec.name,
|
|
17075
17499
|
ports: spec.ports,
|
|
@@ -17138,10 +17562,14 @@ async function startDaemon(options) {
|
|
|
17138
17562
|
const routeSupervisionVerdict = (parentId, v) => {
|
|
17139
17563
|
try {
|
|
17140
17564
|
if (!parentId) return;
|
|
17141
|
-
const
|
|
17142
|
-
const
|
|
17143
|
-
|
|
17144
|
-
|
|
17565
|
+
const tracked = Array.from(pidToTrackedSession.values());
|
|
17566
|
+
const parent = tracked.find((t) => t.svampSessionId === parentId);
|
|
17567
|
+
const liveParent = tracked.find((t) => t.svampSessionId === parentId && !t.stopped);
|
|
17568
|
+
const send = liveParent?.sessionRPCHandlers?.sendInboxMessage;
|
|
17569
|
+
if (!send) {
|
|
17570
|
+
logger.log(`[supervision] parent ${parentId.slice(0, 8)} not live \u2014 recording verdict on the backlog issue only (no in-memory wake)`);
|
|
17571
|
+
}
|
|
17572
|
+
if (send) Promise.resolve(send({
|
|
17145
17573
|
messageId: randomUUID$1(),
|
|
17146
17574
|
from: `agent:${v.sessionId}`,
|
|
17147
17575
|
fromSession: v.sessionId,
|
|
@@ -17680,24 +18108,22 @@ ${parts.join("\n")}`);
|
|
|
17680
18108
|
const STUCK_CHECK_INTERVAL_MS = 2 * 60 * 1e3;
|
|
17681
18109
|
let lastOutputTime = Date.now();
|
|
17682
18110
|
let stuckWatchdogTimer = null;
|
|
17683
|
-
const hasActiveChildren = (pid) => {
|
|
17684
|
-
|
|
17685
|
-
|
|
17686
|
-
|
|
17687
|
-
|
|
17688
|
-
return false;
|
|
17689
|
-
}
|
|
17690
|
-
};
|
|
18111
|
+
const hasActiveChildren = (pid) => new Promise((resolve2) => {
|
|
18112
|
+
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8", timeout: 5e3 }, (err, stdout) => {
|
|
18113
|
+
resolve2(!err && String(stdout).trim().length > 0);
|
|
18114
|
+
});
|
|
18115
|
+
});
|
|
17691
18116
|
const startStuckWatchdog = () => {
|
|
17692
18117
|
if (stuckWatchdogTimer) return;
|
|
17693
|
-
stuckWatchdogTimer = setInterval(() => {
|
|
18118
|
+
stuckWatchdogTimer = setInterval(async () => {
|
|
17694
18119
|
if (!claudeProcess || claudeProcess.exitCode !== null) return;
|
|
17695
18120
|
if (!sessionWasProcessing) return;
|
|
17696
18121
|
if (!isLoopActive(directory, sessionId)) return;
|
|
17697
|
-
if (claudeProcess.pid && hasActiveChildren(claudeProcess.pid)) {
|
|
18122
|
+
if (claudeProcess.pid && await hasActiveChildren(claudeProcess.pid)) {
|
|
17698
18123
|
lastOutputTime = Date.now();
|
|
17699
18124
|
return;
|
|
17700
18125
|
}
|
|
18126
|
+
if (!claudeProcess || claudeProcess.exitCode !== null) return;
|
|
17701
18127
|
const elapsed = Date.now() - lastOutputTime;
|
|
17702
18128
|
if (elapsed > STUCK_PROCESS_TIMEOUT_MS) {
|
|
17703
18129
|
logger.log(`[Session ${sessionId}] Loop stuck: mid-turn, no output for ${Math.round(elapsed / 1e3)}s, no child processes \u2014 killing to resume the loop`);
|
|
@@ -18398,7 +18824,7 @@ ${parts.join("\n")}`);
|
|
|
18398
18824
|
auto_resumes: prog.auto_resumes
|
|
18399
18825
|
});
|
|
18400
18826
|
const extNote = summarizeExtensions(ls.extensions, maxExt);
|
|
18401
|
-
const resumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `
|
|
18827
|
+
const resumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `Raise the cost cap to resume (--max won't \u2014 it only raises the iteration cap): svamp session loop ${sessionId} --max-runtime-sec <N> and/or --max-tokens-per-hour <N>`;
|
|
18402
18828
|
const detail = `${budgetCheck.reason} (hard ${iterStop ? "iteration ceiling" : "cost ceiling"} \u2014 will not auto-resume)${extNote ? ` [${extNote}]` : ""}. ${resumeHint}`;
|
|
18403
18829
|
sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${detail}`, level: "warning" }, "event");
|
|
18404
18830
|
checkSvampConfig?.();
|
|
@@ -19285,19 +19711,19 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
|
|
|
19285
19711
|
});
|
|
19286
19712
|
},
|
|
19287
19713
|
onIssue: async (params) => {
|
|
19288
|
-
const { issueRpc } = await import('./rpc
|
|
19714
|
+
const { issueRpc } = await import('./rpc-iNafaHoh.mjs');
|
|
19289
19715
|
return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
|
|
19290
19716
|
},
|
|
19291
19717
|
onWorkflow: async (params) => {
|
|
19292
|
-
const { workflowRpc } = await import('./rpc-
|
|
19718
|
+
const { workflowRpc } = await import('./rpc-BxmbyDga.mjs');
|
|
19293
19719
|
return workflowRpc(params?.cwd || directory, params || {});
|
|
19294
19720
|
},
|
|
19295
19721
|
onRipgrep: async (args, cwd) => {
|
|
19296
|
-
const { execFile } = await import('child_process');
|
|
19722
|
+
const { execFile: execFile2 } = await import('child_process');
|
|
19297
19723
|
const rgCwd = cwd || directory;
|
|
19298
19724
|
const argv = Array.isArray(args) ? args : String(args).split(/\s+/).filter(Boolean);
|
|
19299
19725
|
return new Promise((resolve2, reject) => {
|
|
19300
|
-
|
|
19726
|
+
execFile2("rg", argv, { cwd: rgCwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (err, stdout) => {
|
|
19301
19727
|
if (err && !stdout) {
|
|
19302
19728
|
reject(new Error(err.message));
|
|
19303
19729
|
} else {
|
|
@@ -19921,19 +20347,19 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
|
|
|
19921
20347
|
});
|
|
19922
20348
|
},
|
|
19923
20349
|
onIssue: async (params) => {
|
|
19924
|
-
const { issueRpc } = await import('./rpc
|
|
20350
|
+
const { issueRpc } = await import('./rpc-iNafaHoh.mjs');
|
|
19925
20351
|
return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
|
|
19926
20352
|
},
|
|
19927
20353
|
onWorkflow: async (params) => {
|
|
19928
|
-
const { workflowRpc } = await import('./rpc-
|
|
20354
|
+
const { workflowRpc } = await import('./rpc-BxmbyDga.mjs');
|
|
19929
20355
|
return workflowRpc(params?.cwd || directory, params || {});
|
|
19930
20356
|
},
|
|
19931
20357
|
onRipgrep: async (args, cwd) => {
|
|
19932
|
-
const { execFile } = await import('child_process');
|
|
20358
|
+
const { execFile: execFile2 } = await import('child_process');
|
|
19933
20359
|
const rgCwd = cwd || directory;
|
|
19934
20360
|
const argv = Array.isArray(args) ? args : String(args).split(/\s+/).filter(Boolean);
|
|
19935
20361
|
return new Promise((resolve2, reject) => {
|
|
19936
|
-
|
|
20362
|
+
execFile2("rg", argv, { cwd: rgCwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (err, stdout) => {
|
|
19937
20363
|
if (err && !stdout) {
|
|
19938
20364
|
reject(new Error(err.message));
|
|
19939
20365
|
} else {
|
|
@@ -20208,7 +20634,8 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
|
|
|
20208
20634
|
const startedAt = typeof ls.started_at === "number" ? ls.started_at : typeof ls.resumed_at === "number" ? ls.resumed_at : void 0;
|
|
20209
20635
|
const oracleCmd = typeof ls.oracle === "string" && ls.oracle.trim() ? ls.oracle.trim() : void 0;
|
|
20210
20636
|
const acpCumTokens = agentBackend.getCumulativeTokens?.() ?? 0;
|
|
20211
|
-
|
|
20637
|
+
let acpPrevCum = typeof ls.acp_last_cum_tokens === "number" ? ls.acp_last_cum_tokens : 0;
|
|
20638
|
+
if (acpCumTokens < acpPrevCum) acpPrevCum = 0;
|
|
20212
20639
|
const acpDeltaTokens = Math.max(0, acpCumTokens - acpPrevCum);
|
|
20213
20640
|
ls.acp_last_cum_tokens = acpCumTokens;
|
|
20214
20641
|
const ledger = accumulateLedger(ls.ledger, { turns: 1, tokens: acpDeltaTokens, ts: now });
|
|
@@ -20242,7 +20669,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
|
|
|
20242
20669
|
const iterStop = !isCostCeiling(budgetCheck.kind);
|
|
20243
20670
|
writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "gave_up", completed_at: now, gave_up_reason: `resource budget exhausted \u2014 ${budgetCheck.reason}`, ledger });
|
|
20244
20671
|
const acpExtNote = summarizeExtensions(ls.extensions, acpMaxExt);
|
|
20245
|
-
const acpResumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `
|
|
20672
|
+
const acpResumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `Raise the cost cap to resume (--max won't \u2014 it only raises the iteration cap): svamp session loop ${sessionId} --max-runtime-sec <N> and/or --max-tokens-per-hour <N>`;
|
|
20246
20673
|
sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${budgetCheck.reason} (hard ${iterStop ? "iteration ceiling" : "cost ceiling"} \u2014 will not auto-resume)${acpExtNote ? ` [${acpExtNote}]` : ""}. ${acpResumeHint}`, level: "warning" }, "event");
|
|
20247
20674
|
checkSvampConfig?.();
|
|
20248
20675
|
return;
|
|
@@ -20750,7 +21177,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
|
|
|
20750
21177
|
const channelHttpPort = Number(process.env.SVAMP_CHANNEL_HTTP_PORT) || 0;
|
|
20751
21178
|
if (channelHttpPort > 0) {
|
|
20752
21179
|
try {
|
|
20753
|
-
const { createChannelHttpServer } = await import('./httpServer-
|
|
21180
|
+
const { createChannelHttpServer } = await import('./httpServer-1XjB2h3K.mjs');
|
|
20754
21181
|
const channelHttpServer = createChannelHttpServer({
|
|
20755
21182
|
getSessionIds: () => {
|
|
20756
21183
|
const ids = [];
|
|
@@ -20991,7 +21418,10 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
|
|
|
20991
21418
|
}
|
|
20992
21419
|
if (sessionsToAutoContinue.length > 0 && !options?.noAutoContinue) {
|
|
20993
21420
|
logger.log(`Auto-continuing ${sessionsToAutoContinue.length} interrupted session(s)...`);
|
|
20994
|
-
|
|
21421
|
+
const AUTO_CONTINUE_STEP_MS = Number(process.env.SVAMP_AUTO_CONTINUE_STEP_MS) || 300;
|
|
21422
|
+
const AUTO_CONTINUE_MAX_SPREAD_MS = Number(process.env.SVAMP_AUTO_CONTINUE_MAX_SPREAD_MS) || 6e4;
|
|
21423
|
+
sessionsToAutoContinue.forEach((sessionId, i) => {
|
|
21424
|
+
const delay = 2e3 + Math.min(i * AUTO_CONTINUE_STEP_MS, AUTO_CONTINUE_MAX_SPREAD_MS) + Math.floor(Math.random() * AUTO_CONTINUE_STEP_MS);
|
|
20995
21425
|
setTimeout(async () => {
|
|
20996
21426
|
try {
|
|
20997
21427
|
const tracked = Array.from(pidToTrackedSession.values()).find((s) => s.svampSessionId === sessionId);
|
|
@@ -21008,8 +21438,8 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
|
|
|
21008
21438
|
} catch (err) {
|
|
21009
21439
|
logger.log(`Failed to auto-continue session ${sessionId}: ${err.message}`);
|
|
21010
21440
|
}
|
|
21011
|
-
},
|
|
21012
|
-
}
|
|
21441
|
+
}, delay);
|
|
21442
|
+
});
|
|
21013
21443
|
} else if (sessionsToAutoContinue.length > 0) {
|
|
21014
21444
|
logger.log(`Skipping auto-continue for ${sessionsToAutoContinue.length} interrupted session(s) (--no-auto-continue)`);
|
|
21015
21445
|
}
|
|
@@ -21072,15 +21502,24 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
|
|
|
21072
21502
|
const PING_TIMEOUT_MS = 15e3;
|
|
21073
21503
|
const POST_RECONNECT_GRACE_MS = 2e4;
|
|
21074
21504
|
const RECONNECT_JITTER_MS = 2500;
|
|
21075
|
-
const { WorkflowScheduler } = await import('./scheduler-
|
|
21505
|
+
const { WorkflowScheduler } = await import('./scheduler-CZYOanXt.mjs');
|
|
21506
|
+
const workflowProjectRoots = () => {
|
|
21507
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
21508
|
+
for (const s of pidToTrackedSession.values()) {
|
|
21509
|
+
if (!s.stopped && s.directory) dirs.add(s.directory);
|
|
21510
|
+
}
|
|
21511
|
+
return [...dirs];
|
|
21512
|
+
};
|
|
21513
|
+
try {
|
|
21514
|
+
const { reconcileInterruptedRuns } = await Promise.resolve().then(function () { return runStore; });
|
|
21515
|
+
let reconciled = 0;
|
|
21516
|
+
for (const root of workflowProjectRoots()) reconciled += reconcileInterruptedRuns(root);
|
|
21517
|
+
if (reconciled > 0) logger.log(`[workflow] reconciled ${reconciled} interrupted run(s) (running \u2192 failed) after restart`);
|
|
21518
|
+
} catch (e) {
|
|
21519
|
+
logger.log(`[workflow] interrupted-run reconcile skipped: ${e?.message || e}`);
|
|
21520
|
+
}
|
|
21076
21521
|
const workflowScheduler = new WorkflowScheduler({
|
|
21077
|
-
projectRoots:
|
|
21078
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
21079
|
-
for (const s of pidToTrackedSession.values()) {
|
|
21080
|
-
if (!s.stopped && s.directory) dirs.add(s.directory);
|
|
21081
|
-
}
|
|
21082
|
-
return [...dirs];
|
|
21083
|
-
},
|
|
21522
|
+
projectRoots: workflowProjectRoots,
|
|
21084
21523
|
log: (m) => logger.log(m)
|
|
21085
21524
|
});
|
|
21086
21525
|
const workflowSchedulerInterval = setInterval(() => {
|
|
@@ -21696,4 +22135,4 @@ var run = /*#__PURE__*/Object.freeze({
|
|
|
21696
22135
|
writeStopMarker: writeStopMarker
|
|
21697
22136
|
});
|
|
21698
22137
|
|
|
21699
|
-
export {
|
|
22138
|
+
export { downloadSkillFile as $, removeWorkflow as A, validateWorkflowName as B, saveWorkflow as C, rawWorkflow as D, listWorkflows as E, isWorkflowEnabled as F, workflowCrons as G, cronMatches as H, summarize as I, workflowSteps as J, parseJwtEmail as K, computeCollectionConfigUpdate as L, SYSTEM_COLLECTION_CONFIG as M, loadMachineContext as N, buildMachineInstructions as O, machineToolsForRole as P, buildMachineTools as Q, READ_ONLY_TOOLS as R, SharingNotificationSync as S, parseFrontmatter as T, getSkillsServer as U, getSkillsWorkspaceName as V, getSkillsCollectionName as W, fetchWithTimeout as X, searchSkills as Y, SKILLS_DIR as Z, getSkillInfo as _, createSessionStore as a, listSkillFiles as a0, resolveModel as a1, clearStopMarker as a2, stopMarkerExists as a3, formatHandle as a4, normalizeAllowedUser as a5, loadSecurityContextConfig as a6, resolveSecurityContext as a7, buildSecurityContextFromFlags as a8, mergeSecurityContexts as a9, buildSessionShareUrl as aa, computeOutboundHop as ab, registerAwaitingReply as ac, buildMachineShareUrl as ad, parseHandle as ae, handleMatchesMetadata as af, describeMisconfiguration as ag, buildMachineDeps as ah, applyClaudeProxyEnv as ai, composeSessionId as aj, generateFriendlyName as ak, generateHookSettings as al, staticFileServer as am, instanceConfig as an, claudeAuth as ao, codexProvider as ap, projectInfo as aq, DefaultTransport$1 as ar, acpBackend as as, acpAgentConfig as at, codexAppServerBackend as au, GeminiTransport$1 as av, api as aw, run as ax, stopDaemon as b, connectToHypha as c, daemonStatus as d, getFrpsSubdomainHost as e, getFrpsServerPort as f, getHyphaServerUrl$1 as g, getFrpsServerAddr as h, shortId as i, resolveProjectRoot as j, getIssue as k, resumeIssue as l, addComment as m, addIssue as n, listIssues as o, pauseIssue as p, searchIssues as q, registerMachineService as r, startDaemon as s, isVisibleTo as t, updateIssue as u, getRun as v, listRuns as w, getWorkflow as x, runWorkflow as y, setWorkflowEnabled as z };
|