hanoman 0.1.41 → 0.1.43

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/server.js CHANGED
@@ -6119,6 +6119,9 @@ var init_api = __esm({
6119
6119
  sessionReviewFile: (id, path2) => `${API}/terminal/sessions/${id}/review/${path2}`,
6120
6120
  sessionIntegrate: (id) => `${API}/terminal/sessions/${id}/integrate`,
6121
6121
  terminalWs: (id) => `${API}/terminal/sessions/${id}/ws`,
6122
+ // SPEC-816 · lampiran gambar sesi (multipart). Di bawah prefix /terminal supaya ikut capability
6123
+ // `sessions` yang sudah ada — POST menurunkan cabang tulisnya tanpa perubahan peta.
6124
+ terminalAttachments: (id) => `${API}/terminal/sessions/${id}/attachments`,
6122
6125
  // SPEC-362 · ADR-0079 · riwayat sesi. Di bawah prefix /terminal supaya ikut capability
6123
6126
  // `sessions` yang sudah ada (services/agent-capabilities.ts) tanpa menambah domain baru.
6124
6127
  sessionHistory: (qs = "") => `${API}/terminal/history${qs}`,
@@ -9362,6 +9365,400 @@ var init_session_id = __esm({
9362
9365
  }
9363
9366
  });
9364
9367
 
9368
+ // src/services/secret-box.ts
9369
+ import { chmodSync as chmodSync2, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync } from "node:fs";
9370
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9371
+ import { dirname as dirname6, join as join4 } from "node:path";
9372
+ function isEncrypted(value) {
9373
+ return value.startsWith(ENC_PREFIX);
9374
+ }
9375
+ function encryptWithKey(plain, key) {
9376
+ const iv = randomBytes(IV_BYTES);
9377
+ const cipher = createCipheriv(ALGO, key, iv);
9378
+ const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
9379
+ const tag2 = cipher.getAuthTag();
9380
+ return `${ENC_PREFIX}${iv.toString("base64url")}:${tag2.toString("base64url")}:${body.toString("base64url")}`;
9381
+ }
9382
+ function decryptWithKey(value, key) {
9383
+ if (!isEncrypted(value)) return value;
9384
+ const parts = value.slice(ENC_PREFIX.length).split(":");
9385
+ if (parts.length !== 3) return null;
9386
+ const [ivRaw, tagRaw, bodyRaw] = parts;
9387
+ try {
9388
+ const decipher = createDecipheriv(ALGO, key, Buffer.from(ivRaw, "base64url"));
9389
+ decipher.setAuthTag(Buffer.from(tagRaw, "base64url"));
9390
+ return Buffer.concat([decipher.update(Buffer.from(bodyRaw, "base64url")), decipher.final()]).toString("utf8");
9391
+ } catch {
9392
+ return null;
9393
+ }
9394
+ }
9395
+ function fromEnv(raw2) {
9396
+ for (const enc2 of ["base64url", "base64", "hex"]) {
9397
+ const buf = Buffer.from(raw2, enc2);
9398
+ if (buf.length === KEY_BYTES) return buf;
9399
+ }
9400
+ return null;
9401
+ }
9402
+ function secretKeyPath() {
9403
+ return join4(resolveHome(), "secret.key");
9404
+ }
9405
+ function secretKey() {
9406
+ if (cached) return cached;
9407
+ const override = process.env.HANOMAN_SECRET_KEY?.trim();
9408
+ if (override) {
9409
+ const key2 = fromEnv(override);
9410
+ if (!key2) throw new Error(`HANOMAN_SECRET_KEY harus 32 byte (hex/base64), dapat ${override.length} karakter`);
9411
+ cached = key2;
9412
+ return key2;
9413
+ }
9414
+ const path2 = secretKeyPath();
9415
+ if (existsSync5(path2)) {
9416
+ const key2 = Buffer.from(readFileSync4(path2, "utf8").trim(), "base64url");
9417
+ if (key2.length !== KEY_BYTES) throw new Error(`kunci di ${path2} rusak \u2014 panjangnya bukan 32 byte`);
9418
+ cached = key2;
9419
+ return key2;
9420
+ }
9421
+ const key = randomBytes(KEY_BYTES);
9422
+ mkdirSync3(dirname6(path2), { recursive: true });
9423
+ writeFileSync(path2, key.toString("base64url"), { mode: 384 });
9424
+ chmodSync2(path2, 384);
9425
+ cached = key;
9426
+ return key;
9427
+ }
9428
+ function encryptSecret(plain) {
9429
+ return encryptWithKey(plain, secretKey());
9430
+ }
9431
+ function decryptSecret(value) {
9432
+ return decryptWithKey(value, secretKey());
9433
+ }
9434
+ var ENC_PREFIX, ALGO, IV_BYTES, KEY_BYTES, cached;
9435
+ var init_secret_box = __esm({
9436
+ "src/services/secret-box.ts"() {
9437
+ "use strict";
9438
+ init_src2();
9439
+ ENC_PREFIX = "enc:v1:";
9440
+ ALGO = "aes-256-gcm";
9441
+ IV_BYTES = 12;
9442
+ KEY_BYTES = 32;
9443
+ cached = null;
9444
+ }
9445
+ });
9446
+
9447
+ // src/config.ts
9448
+ var config_exports = {};
9449
+ __export(config_exports, {
9450
+ clearConfig: () => clearConfig,
9451
+ effectiveBool: () => effectiveBool,
9452
+ effectiveInt: () => effectiveInt,
9453
+ effectiveStr: () => effectiveStr,
9454
+ loadConfig: () => loadConfig,
9455
+ rawDbValue: () => rawDbValue,
9456
+ setConfig: () => setConfig,
9457
+ setConfigsAtomic: () => setConfigsAtomic,
9458
+ sourceOf: () => sourceOf
9459
+ });
9460
+ async function loadConfig() {
9461
+ const rows = await prisma.runtimeConfig.findMany();
9462
+ const next = /* @__PURE__ */ new Map();
9463
+ for (const r of rows) {
9464
+ if (!isSecret(r.key)) {
9465
+ next.set(r.key, r.value);
9466
+ continue;
9467
+ }
9468
+ const plain = decryptSecret(r.value);
9469
+ if (plain === null) {
9470
+ console.error(`config: nilai '${r.key}' tak bisa didekripsi \u2014 diabaikan`);
9471
+ continue;
9472
+ }
9473
+ next.set(r.key, plain);
9474
+ }
9475
+ cache = next;
9476
+ }
9477
+ function rawDbValue(key) {
9478
+ return cache.get(key);
9479
+ }
9480
+ function effectiveStr(key) {
9481
+ return cache.get(key) ?? process.env[key] ?? configEntry(key)?.default;
9482
+ }
9483
+ function effectiveInt(key) {
9484
+ const v = effectiveStr(key);
9485
+ return v === void 0 ? void 0 : Number(v);
9486
+ }
9487
+ function effectiveBool(key) {
9488
+ const v = effectiveStr(key);
9489
+ return v === "1" || v === "true";
9490
+ }
9491
+ function sourceOf(key) {
9492
+ if (cache.has(key)) return "db";
9493
+ if (process.env[key] !== void 0) return "env";
9494
+ return "default";
9495
+ }
9496
+ async function setConfig(key, value) {
9497
+ const stored = isSecret(key) ? encryptSecret(value) : value;
9498
+ await prisma.runtimeConfig.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored } });
9499
+ cache.set(key, value);
9500
+ }
9501
+ async function setConfigsAtomic(values) {
9502
+ await prisma.$transaction(Object.entries(values).map(([key, value]) => {
9503
+ const stored = isSecret(key) ? encryptSecret(value) : value;
9504
+ return prisma.runtimeConfig.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored } });
9505
+ }));
9506
+ for (const [key, value] of Object.entries(values)) cache.set(key, value);
9507
+ }
9508
+ async function clearConfig(key) {
9509
+ await prisma.runtimeConfig.deleteMany({ where: { key } });
9510
+ cache.delete(key);
9511
+ }
9512
+ var cache, isSecret;
9513
+ var init_config2 = __esm({
9514
+ "src/config.ts"() {
9515
+ "use strict";
9516
+ init_db();
9517
+ init_src();
9518
+ init_secret_box();
9519
+ cache = /* @__PURE__ */ new Map();
9520
+ isSecret = (key) => configEntry(key)?.kind === "secret";
9521
+ }
9522
+ });
9523
+
9524
+ // src/services/webhooks/ssrf.ts
9525
+ import { lookup as dnsLookup } from "node:dns/promises";
9526
+ import { isIPv4 } from "node:net";
9527
+ function validateWebhookUrl(raw2) {
9528
+ let url2;
9529
+ try {
9530
+ url2 = new URL(raw2.trim());
9531
+ } catch {
9532
+ return { ok: false, error: "URL tak valid" };
9533
+ }
9534
+ if (url2.protocol !== "http:" && url2.protocol !== "https:")
9535
+ return { ok: false, error: "hanya http atau https" };
9536
+ if (url2.username || url2.password)
9537
+ return { ok: false, error: "kredensial di URL tak diizinkan" };
9538
+ if (!url2.hostname) return { ok: false, error: "hostname kosong" };
9539
+ return { ok: true, url: url2 };
9540
+ }
9541
+ function blockedV4(ip) {
9542
+ const p = ip.split(".").map(Number);
9543
+ if (p.length !== 4 || p.some((n2) => !Number.isInteger(n2) || n2 < 0 || n2 > 255)) return true;
9544
+ const [a, b] = p;
9545
+ if (a === 0 || a === 127) return true;
9546
+ if (a === 10) return true;
9547
+ if (a === 172 && b >= 16 && b <= 31) return true;
9548
+ if (a === 192 && b === 168) return true;
9549
+ if (a === 169 && b === 254) return true;
9550
+ if (a === 100 && b >= 64 && b <= 127) return true;
9551
+ if (a === 192 && b === 0) return true;
9552
+ if (a >= 224) return true;
9553
+ return false;
9554
+ }
9555
+ function isBlockedAddress(ip) {
9556
+ const v = ip.trim().toLowerCase();
9557
+ if (!v) return true;
9558
+ const mapped = v.startsWith("::ffff:") ? v.slice(7) : v;
9559
+ if (isIPv4(mapped)) return blockedV4(mapped);
9560
+ if (!v.includes(":")) return true;
9561
+ if (v === "::" || v === "::1") return true;
9562
+ if (v.startsWith("fe80") || v.startsWith("fec0")) return true;
9563
+ if (/^f[cd]/.test(v)) return true;
9564
+ if (v.startsWith("ff")) return true;
9565
+ return false;
9566
+ }
9567
+ function checkUrlShape(raw2, allowPrivate) {
9568
+ const parsed = validateWebhookUrl(raw2);
9569
+ if (!parsed.ok) return parsed;
9570
+ if (allowPrivate) return parsed;
9571
+ const host2 = parsed.url.hostname.replace(/^\[|\]$/g, "");
9572
+ const literal = isIPv4(host2) || host2.includes(":");
9573
+ if (literal && isBlockedAddress(host2))
9574
+ return {
9575
+ ok: false,
9576
+ error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
9577
+ };
9578
+ if (host2 === "localhost" || host2.endsWith(".localhost"))
9579
+ return {
9580
+ ok: false,
9581
+ error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
9582
+ };
9583
+ return parsed;
9584
+ }
9585
+ async function checkDestination(url2, allowPrivate, lookup = defaultLookup) {
9586
+ if (allowPrivate) return { ok: true };
9587
+ const host2 = url2.hostname.replace(/^\[|\]$/g, "");
9588
+ let addrs;
9589
+ try {
9590
+ addrs = await lookup(host2);
9591
+ } catch (e) {
9592
+ return { ok: false, error: `DNS gagal: ${e.message}` };
9593
+ }
9594
+ if (!addrs.length) return { ok: false, error: "DNS tak mengembalikan alamat" };
9595
+ for (const a of addrs)
9596
+ if (isBlockedAddress(a.address))
9597
+ return {
9598
+ ok: false,
9599
+ error: `alamat internal ditolak (${a.address}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
9600
+ };
9601
+ return { ok: true };
9602
+ }
9603
+ var defaultLookup;
9604
+ var init_ssrf = __esm({
9605
+ "src/services/webhooks/ssrf.ts"() {
9606
+ "use strict";
9607
+ defaultLookup = (host2) => dnsLookup(host2, { all: true });
9608
+ }
9609
+ });
9610
+
9611
+ // src/services/safe-outbound-request.ts
9612
+ import { lookup as dnsLookup2 } from "node:dns/promises";
9613
+ import { request as httpRequest } from "node:http";
9614
+ import { request as httpsRequest } from "node:https";
9615
+ async function pinnedRequest(input) {
9616
+ return new Promise((resolve19, reject2) => {
9617
+ const transport = input.url.protocol === "https:" ? httpsRequest : httpRequest;
9618
+ const req = transport({
9619
+ protocol: input.url.protocol,
9620
+ hostname: input.url.hostname,
9621
+ port: input.url.port || void 0,
9622
+ method: input.method,
9623
+ path: `${input.url.pathname}${input.url.search}`,
9624
+ headers: input.headers,
9625
+ servername: input.url.hostname,
9626
+ // Node >= 20 menyalakan `autoSelectFamily` secara default: socket memanggil `lookup` dengan
9627
+ // `all: true` dan membaca `addresses[0].address` dari hasilnya. Menjawab dalam bentuk skalar
9628
+ // di situ memberi `undefined` → ERR_INVALID_IP_ADDRESS sebelum satu paket pun keluar, dan
9629
+ // pemanggil (sync tick, webhook) menelannya sebagai "offline". Kedua bentuk dijawab supaya
9630
+ // pinning tetap benar apa pun setelan family-nya.
9631
+ lookup: (_host, opts, callback) => opts.all ? callback(null, [{ address: input.address, family: input.family }]) : callback(null, input.address, input.family),
9632
+ timeout: input.connectMs
9633
+ }, (response) => {
9634
+ const chunks = [];
9635
+ let bytes = 0;
9636
+ response.on("data", (chunk) => {
9637
+ bytes += chunk.length;
9638
+ if (bytes > input.maxResponseBytes) {
9639
+ response.destroy(new Error("outbound response terlalu besar"));
9640
+ return;
9641
+ }
9642
+ chunks.push(chunk);
9643
+ });
9644
+ response.on("end", () => resolve19({
9645
+ status: response.statusCode ?? 0,
9646
+ headers: response.headers,
9647
+ body: Buffer.concat(chunks)
9648
+ }));
9649
+ });
9650
+ const timer9 = setTimeout(() => req.destroy(Object.assign(new Error("outbound timeout"), { name: "AbortError" })), input.totalMs);
9651
+ timer9.unref?.();
9652
+ req.once("close", () => clearTimeout(timer9));
9653
+ req.once("error", reject2);
9654
+ if (input.body) req.write(input.body);
9655
+ req.end();
9656
+ });
9657
+ }
9658
+ async function safeRequest(options2, deps = {}) {
9659
+ if (options2.url.protocol !== "http:" && options2.url.protocol !== "https:") throw new Error("outbound scheme ditolak");
9660
+ if (options2.url.username || options2.url.password) throw new Error("outbound credential URL ditolak");
9661
+ const addresses = await (deps.lookupAll ?? defaultLookup2)(options2.url.hostname);
9662
+ if (!addresses.length) throw new Error("DNS tak mengembalikan alamat");
9663
+ if (!options2.allowPrivate) {
9664
+ const blocked = addresses.find((row) => isBlockedAddress(row.address));
9665
+ if (blocked) throw new Error(`alamat internal ditolak (${blocked.address})`);
9666
+ }
9667
+ const selected = addresses[0];
9668
+ return (deps.request ?? pinnedRequest)({ ...options2, address: selected.address, family: selected.family });
9669
+ }
9670
+ var defaultLookup2;
9671
+ var init_safe_outbound_request = __esm({
9672
+ "src/services/safe-outbound-request.ts"() {
9673
+ "use strict";
9674
+ init_ssrf();
9675
+ defaultLookup2 = async (host2) => (await dnsLookup2(host2, { all: true })).map((row) => ({ address: row.address, family: row.family }));
9676
+ }
9677
+ });
9678
+
9679
+ // src/services/uploads.ts
9680
+ import { randomUUID } from "node:crypto";
9681
+ import { mkdir, writeFile, readFile as readFile2, unlink, rm } from "node:fs/promises";
9682
+ import { join as join5, resolve as resolve8 } from "node:path";
9683
+ function extFor(mimeType) {
9684
+ return EXT[mimeType] ?? ".bin";
9685
+ }
9686
+ function uploadDir() {
9687
+ return resolve8(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join5(resolveHome(), "uploads"));
9688
+ }
9689
+ function sessionUploadDir(sessionId2) {
9690
+ if (!SESSION_ID.test(sessionId2)) throw new Error(`sessionId tak sah: ${sessionId2}`);
9691
+ return join5(uploadDir(), "terminal", sessionId2);
9692
+ }
9693
+ async function saveSessionUpload(sessionId2, buf, mimeType) {
9694
+ const dir2 = sessionUploadDir(sessionId2);
9695
+ await mkdir(dir2, { recursive: true, mode: 448 });
9696
+ const path2 = join5(dir2, `${randomUUID()}${extFor(mimeType)}`);
9697
+ await writeFile(path2, buf, { mode: 384 });
9698
+ return { path: path2, size: buf.length };
9699
+ }
9700
+ async function dropSessionUploads(sessionId2) {
9701
+ let dir2;
9702
+ try {
9703
+ dir2 = sessionUploadDir(sessionId2);
9704
+ } catch {
9705
+ return;
9706
+ }
9707
+ await rm(dir2, { recursive: true, force: true }).catch(() => {
9708
+ });
9709
+ }
9710
+ async function readUpload(storageKey) {
9711
+ const safe = storageKey.replace(/[/\\]/g, "");
9712
+ return readFile2(join5(uploadDir(), safe));
9713
+ }
9714
+ async function readUploadOrFetch(storageKey) {
9715
+ const safe = storageKey.replace(/[/\\]/g, "");
9716
+ const target2 = join5(uploadDir(), safe);
9717
+ try {
9718
+ return await readFile2(target2);
9719
+ } catch {
9720
+ const base2 = effectiveStr("SYNC_SERVER_URL");
9721
+ const token = effectiveStr("SYNC_DEVICE_TOKEN");
9722
+ if (!base2 || !token) throw new Error(`lampiran ${safe} tak ada lokal & bukan client sync`);
9723
+ const url2 = new URL(`/api/sync/attachments/${encodeURIComponent(safe)}`, `${base2.replace(/\/$/, "")}/`);
9724
+ const loopback = ["localhost", "127.0.0.1", "::1"].includes(url2.hostname);
9725
+ const res = await safeRequest({
9726
+ url: url2,
9727
+ method: "GET",
9728
+ headers: { authorization: `Bearer ${token}` },
9729
+ allowPrivate: process.env.NODE_ENV !== "production" && loopback,
9730
+ connectMs: 5e3,
9731
+ totalMs: 15e3,
9732
+ maxResponseBytes: 5 * 1024 * 1024
9733
+ });
9734
+ if (res.status < 200 || res.status >= 300) throw new Error(`fetch lampiran hub gagal: ${res.status}`);
9735
+ const buf = res.body;
9736
+ await mkdir(uploadDir(), { recursive: true, mode: 448 });
9737
+ await writeFile(target2, buf, { mode: 384 });
9738
+ return buf;
9739
+ }
9740
+ }
9741
+ async function deleteUpload(storageKey) {
9742
+ const safe = storageKey.replace(/[/\\]/g, "");
9743
+ await unlink(join5(uploadDir(), safe)).catch(() => {
9744
+ });
9745
+ }
9746
+ var EXT, SESSION_ID;
9747
+ var init_uploads = __esm({
9748
+ "src/services/uploads.ts"() {
9749
+ "use strict";
9750
+ init_config2();
9751
+ init_safe_outbound_request();
9752
+ init_src2();
9753
+ EXT = {
9754
+ "image/png": ".png",
9755
+ "image/jpeg": ".jpg",
9756
+ "image/webp": ".webp"
9757
+ };
9758
+ SESSION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
9759
+ }
9760
+ });
9761
+
9365
9762
  // src/services/tui-dialog.ts
9366
9763
  function readChoiceDialog(paneText2) {
9367
9764
  const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
@@ -9594,162 +9991,6 @@ var init_tui_dialog = __esm({
9594
9991
  }
9595
9992
  });
9596
9993
 
9597
- // src/services/secret-box.ts
9598
- import { chmodSync as chmodSync2, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync } from "node:fs";
9599
- import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9600
- import { dirname as dirname6, join as join4 } from "node:path";
9601
- function isEncrypted(value) {
9602
- return value.startsWith(ENC_PREFIX);
9603
- }
9604
- function encryptWithKey(plain, key) {
9605
- const iv = randomBytes(IV_BYTES);
9606
- const cipher = createCipheriv(ALGO, key, iv);
9607
- const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
9608
- const tag2 = cipher.getAuthTag();
9609
- return `${ENC_PREFIX}${iv.toString("base64url")}:${tag2.toString("base64url")}:${body.toString("base64url")}`;
9610
- }
9611
- function decryptWithKey(value, key) {
9612
- if (!isEncrypted(value)) return value;
9613
- const parts = value.slice(ENC_PREFIX.length).split(":");
9614
- if (parts.length !== 3) return null;
9615
- const [ivRaw, tagRaw, bodyRaw] = parts;
9616
- try {
9617
- const decipher = createDecipheriv(ALGO, key, Buffer.from(ivRaw, "base64url"));
9618
- decipher.setAuthTag(Buffer.from(tagRaw, "base64url"));
9619
- return Buffer.concat([decipher.update(Buffer.from(bodyRaw, "base64url")), decipher.final()]).toString("utf8");
9620
- } catch {
9621
- return null;
9622
- }
9623
- }
9624
- function fromEnv(raw2) {
9625
- for (const enc2 of ["base64url", "base64", "hex"]) {
9626
- const buf = Buffer.from(raw2, enc2);
9627
- if (buf.length === KEY_BYTES) return buf;
9628
- }
9629
- return null;
9630
- }
9631
- function secretKeyPath() {
9632
- return join4(resolveHome(), "secret.key");
9633
- }
9634
- function secretKey() {
9635
- if (cached) return cached;
9636
- const override = process.env.HANOMAN_SECRET_KEY?.trim();
9637
- if (override) {
9638
- const key2 = fromEnv(override);
9639
- if (!key2) throw new Error(`HANOMAN_SECRET_KEY harus 32 byte (hex/base64), dapat ${override.length} karakter`);
9640
- cached = key2;
9641
- return key2;
9642
- }
9643
- const path2 = secretKeyPath();
9644
- if (existsSync5(path2)) {
9645
- const key2 = Buffer.from(readFileSync4(path2, "utf8").trim(), "base64url");
9646
- if (key2.length !== KEY_BYTES) throw new Error(`kunci di ${path2} rusak \u2014 panjangnya bukan 32 byte`);
9647
- cached = key2;
9648
- return key2;
9649
- }
9650
- const key = randomBytes(KEY_BYTES);
9651
- mkdirSync3(dirname6(path2), { recursive: true });
9652
- writeFileSync(path2, key.toString("base64url"), { mode: 384 });
9653
- chmodSync2(path2, 384);
9654
- cached = key;
9655
- return key;
9656
- }
9657
- function encryptSecret(plain) {
9658
- return encryptWithKey(plain, secretKey());
9659
- }
9660
- function decryptSecret(value) {
9661
- return decryptWithKey(value, secretKey());
9662
- }
9663
- var ENC_PREFIX, ALGO, IV_BYTES, KEY_BYTES, cached;
9664
- var init_secret_box = __esm({
9665
- "src/services/secret-box.ts"() {
9666
- "use strict";
9667
- init_src2();
9668
- ENC_PREFIX = "enc:v1:";
9669
- ALGO = "aes-256-gcm";
9670
- IV_BYTES = 12;
9671
- KEY_BYTES = 32;
9672
- cached = null;
9673
- }
9674
- });
9675
-
9676
- // src/config.ts
9677
- var config_exports = {};
9678
- __export(config_exports, {
9679
- clearConfig: () => clearConfig,
9680
- effectiveBool: () => effectiveBool,
9681
- effectiveInt: () => effectiveInt,
9682
- effectiveStr: () => effectiveStr,
9683
- loadConfig: () => loadConfig,
9684
- rawDbValue: () => rawDbValue,
9685
- setConfig: () => setConfig,
9686
- setConfigsAtomic: () => setConfigsAtomic,
9687
- sourceOf: () => sourceOf
9688
- });
9689
- async function loadConfig() {
9690
- const rows = await prisma.runtimeConfig.findMany();
9691
- const next = /* @__PURE__ */ new Map();
9692
- for (const r of rows) {
9693
- if (!isSecret(r.key)) {
9694
- next.set(r.key, r.value);
9695
- continue;
9696
- }
9697
- const plain = decryptSecret(r.value);
9698
- if (plain === null) {
9699
- console.error(`config: nilai '${r.key}' tak bisa didekripsi \u2014 diabaikan`);
9700
- continue;
9701
- }
9702
- next.set(r.key, plain);
9703
- }
9704
- cache = next;
9705
- }
9706
- function rawDbValue(key) {
9707
- return cache.get(key);
9708
- }
9709
- function effectiveStr(key) {
9710
- return cache.get(key) ?? process.env[key] ?? configEntry(key)?.default;
9711
- }
9712
- function effectiveInt(key) {
9713
- const v = effectiveStr(key);
9714
- return v === void 0 ? void 0 : Number(v);
9715
- }
9716
- function effectiveBool(key) {
9717
- const v = effectiveStr(key);
9718
- return v === "1" || v === "true";
9719
- }
9720
- function sourceOf(key) {
9721
- if (cache.has(key)) return "db";
9722
- if (process.env[key] !== void 0) return "env";
9723
- return "default";
9724
- }
9725
- async function setConfig(key, value) {
9726
- const stored = isSecret(key) ? encryptSecret(value) : value;
9727
- await prisma.runtimeConfig.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored } });
9728
- cache.set(key, value);
9729
- }
9730
- async function setConfigsAtomic(values) {
9731
- await prisma.$transaction(Object.entries(values).map(([key, value]) => {
9732
- const stored = isSecret(key) ? encryptSecret(value) : value;
9733
- return prisma.runtimeConfig.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored } });
9734
- }));
9735
- for (const [key, value] of Object.entries(values)) cache.set(key, value);
9736
- }
9737
- async function clearConfig(key) {
9738
- await prisma.runtimeConfig.deleteMany({ where: { key } });
9739
- cache.delete(key);
9740
- }
9741
- var cache, isSecret;
9742
- var init_config2 = __esm({
9743
- "src/config.ts"() {
9744
- "use strict";
9745
- init_db();
9746
- init_src();
9747
- init_secret_box();
9748
- cache = /* @__PURE__ */ new Map();
9749
- isSecret = (key) => configEntry(key)?.kind === "secret";
9750
- }
9751
- });
9752
-
9753
9994
  // src/services/session-sandbox.ts
9754
9995
  function assertRuntimeBoundary(env, runtime) {
9755
9996
  if (env.NODE_ENV !== "production") return;
@@ -9836,7 +10077,7 @@ var init_session_sandbox = __esm({
9836
10077
  import { spawn } from "node-pty";
9837
10078
  import { execFileSync } from "node:child_process";
9838
10079
  import { createRequire } from "node:module";
9839
- import { randomUUID } from "node:crypto";
10080
+ import { randomUUID as randomUUID2 } from "node:crypto";
9840
10081
  import { mkdirSync as mkdirSync4, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
9841
10082
  import { dirname as dirname7 } from "node:path";
9842
10083
  import { tmpdir } from "node:os";
@@ -10173,12 +10414,24 @@ function broadcast(a, f) {
10173
10414
  const msg = frame(f);
10174
10415
  for (const c of a.clients) c.send(msg);
10175
10416
  }
10417
+ function flushOutput(a) {
10418
+ if (a.flushTimer) {
10419
+ clearTimeout(a.flushTimer);
10420
+ a.flushTimer = void 0;
10421
+ }
10422
+ if (!a.pending) return;
10423
+ const d = a.pending;
10424
+ a.pending = "";
10425
+ a.scrollback = trimScrollback(a.scrollback + d);
10426
+ broadcast(a, { t: "data", d });
10427
+ }
10176
10428
  function open(id) {
10177
10429
  const pty = spawnPty("attach-session", "-d", "-t", name(id));
10178
- const a = { pty, scrollback: "", clients: /* @__PURE__ */ new Set(), lastPhases: "" };
10430
+ const a = { pty, scrollback: "", clients: /* @__PURE__ */ new Set(), lastPhases: "", pending: "" };
10179
10431
  pty.onData((d) => {
10180
- a.scrollback = (a.scrollback + d).slice(-MAX_SCROLLBACK);
10181
- broadcast(a, { t: "data", d });
10432
+ a.pending += d;
10433
+ if (a.pending.length >= COALESCE_MAX_BYTES) flushOutput(a);
10434
+ else if (!a.flushTimer) a.flushTimer = setTimeout(() => flushOutput(a), COALESCE_MS);
10182
10435
  });
10183
10436
  pty.onExit(() => {
10184
10437
  if (attached.get(id) === a) drop(id);
@@ -10190,6 +10443,7 @@ function open(id) {
10190
10443
  function drop(id) {
10191
10444
  const a = attached.get(id);
10192
10445
  if (!a) return;
10446
+ flushOutput(a);
10193
10447
  attached.delete(id);
10194
10448
  a.pty.kill();
10195
10449
  for (const c of a.clients) c.close();
@@ -10198,6 +10452,7 @@ function drop(id) {
10198
10452
  function end(id, code) {
10199
10453
  const a = attached.get(id);
10200
10454
  if (!a) return;
10455
+ flushOutput(a);
10201
10456
  broadcast(a, { t: "exit", code });
10202
10457
  drop(id);
10203
10458
  }
@@ -10269,6 +10524,8 @@ function killSession(id) {
10269
10524
  drop(id);
10270
10525
  tmux("kill-session", "-t", name(id));
10271
10526
  emitDeath({ sessionId: id, exitCode: p.exited ? p.code : null, transcript });
10527
+ void dropSessionUploads(id).catch(() => {
10528
+ });
10272
10529
  return true;
10273
10530
  }
10274
10531
  function detachAll() {
@@ -10291,7 +10548,7 @@ function spawnPty(...args) {
10291
10548
  );
10292
10549
  }
10293
10550
  }
10294
- var socket, PREFIX, MAX_SCROLLBACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, agentsFilePath, NO_SERVER, TmuxError, sq, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, customAgentSource, customAgentsFor, sleep, paneText, dialogIO, DIALOG_CAPTURE_LINES, GOAL_ARMED_MARKERS, goalArmed, phaseKey, paneComplete, sessionFinished, poll, detach;
10551
+ var socket, PREFIX, MAX_SCROLLBACK, SCROLLBACK_SLACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, agentsFilePath, NO_SERVER, TmuxError, sq, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, customAgentSource, customAgentsFor, sleep, paneText, dialogIO, DIALOG_CAPTURE_LINES, GOAL_ARMED_MARKERS, goalArmed, COALESCE_MS, COALESCE_MAX_BYTES, trimScrollback, phaseKey, paneComplete, sessionFinished, poll, detach;
10295
10552
  var init_pty = __esm({
10296
10553
  "src/services/pty.ts"() {
10297
10554
  "use strict";
@@ -10299,12 +10556,14 @@ var init_pty = __esm({
10299
10556
  init_src();
10300
10557
  init_session_phases();
10301
10558
  init_session_id();
10559
+ init_uploads();
10302
10560
  init_tui_dialog();
10303
10561
  init_config2();
10304
10562
  init_session_sandbox();
10305
10563
  socket = () => effectiveStr("HANOMAN_TMUX_SOCKET") ?? "hanoman";
10306
10564
  PREFIX = "hanoman-";
10307
10565
  MAX_SCROLLBACK = 256 * 1024;
10566
+ SCROLLBACK_SLACK = 64 * 1024;
10308
10567
  POLL_MS = 500;
10309
10568
  markerFilled = (f) => {
10310
10569
  try {
@@ -10333,7 +10592,7 @@ var init_pty = __esm({
10333
10592
  }
10334
10593
  };
10335
10594
  sq = (s2) => `'${s2.split("'").join("'\\''")}'`;
10336
- idFor = (specId) => specId ? sessionIdForSpec(specId) : randomUUID().slice(0, 8);
10595
+ idFor = (specId) => specId ? sessionIdForSpec(specId) : randomUUID2().slice(0, 8);
10337
10596
  FMT = [
10338
10597
  "#{session_name}",
10339
10598
  "#{@hanoman_project}",
@@ -10418,6 +10677,9 @@ var init_pty = __esm({
10418
10677
  const text = paneText(id);
10419
10678
  return GOAL_ARMED_MARKERS[agent].some((m) => text.includes(m));
10420
10679
  };
10680
+ COALESCE_MS = 16;
10681
+ COALESCE_MAX_BYTES = 64 * 1024;
10682
+ trimScrollback = (s2) => s2.length > MAX_SCROLLBACK + SCROLLBACK_SLACK ? s2.slice(-MAX_SCROLLBACK) : s2;
10421
10683
  phaseKey = (phases, complete) => JSON.stringify({ phases, complete });
10422
10684
  paneComplete = (p) => !!p.flow && !!p.phaseFile && sessionComplete(readPhases(p.phaseFile, p.flow), p.cwd, p.specId);
10423
10685
  sessionFinished = (id) => {
@@ -12566,161 +12828,6 @@ var init_conflicts = __esm({
12566
12828
  }
12567
12829
  });
12568
12830
 
12569
- // src/services/webhooks/ssrf.ts
12570
- import { lookup as dnsLookup } from "node:dns/promises";
12571
- import { isIPv4 } from "node:net";
12572
- function validateWebhookUrl(raw2) {
12573
- let url2;
12574
- try {
12575
- url2 = new URL(raw2.trim());
12576
- } catch {
12577
- return { ok: false, error: "URL tak valid" };
12578
- }
12579
- if (url2.protocol !== "http:" && url2.protocol !== "https:")
12580
- return { ok: false, error: "hanya http atau https" };
12581
- if (url2.username || url2.password)
12582
- return { ok: false, error: "kredensial di URL tak diizinkan" };
12583
- if (!url2.hostname) return { ok: false, error: "hostname kosong" };
12584
- return { ok: true, url: url2 };
12585
- }
12586
- function blockedV4(ip) {
12587
- const p = ip.split(".").map(Number);
12588
- if (p.length !== 4 || p.some((n2) => !Number.isInteger(n2) || n2 < 0 || n2 > 255)) return true;
12589
- const [a, b] = p;
12590
- if (a === 0 || a === 127) return true;
12591
- if (a === 10) return true;
12592
- if (a === 172 && b >= 16 && b <= 31) return true;
12593
- if (a === 192 && b === 168) return true;
12594
- if (a === 169 && b === 254) return true;
12595
- if (a === 100 && b >= 64 && b <= 127) return true;
12596
- if (a === 192 && b === 0) return true;
12597
- if (a >= 224) return true;
12598
- return false;
12599
- }
12600
- function isBlockedAddress(ip) {
12601
- const v = ip.trim().toLowerCase();
12602
- if (!v) return true;
12603
- const mapped = v.startsWith("::ffff:") ? v.slice(7) : v;
12604
- if (isIPv4(mapped)) return blockedV4(mapped);
12605
- if (!v.includes(":")) return true;
12606
- if (v === "::" || v === "::1") return true;
12607
- if (v.startsWith("fe80") || v.startsWith("fec0")) return true;
12608
- if (/^f[cd]/.test(v)) return true;
12609
- if (v.startsWith("ff")) return true;
12610
- return false;
12611
- }
12612
- function checkUrlShape(raw2, allowPrivate) {
12613
- const parsed = validateWebhookUrl(raw2);
12614
- if (!parsed.ok) return parsed;
12615
- if (allowPrivate) return parsed;
12616
- const host2 = parsed.url.hostname.replace(/^\[|\]$/g, "");
12617
- const literal = isIPv4(host2) || host2.includes(":");
12618
- if (literal && isBlockedAddress(host2))
12619
- return {
12620
- ok: false,
12621
- error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
12622
- };
12623
- if (host2 === "localhost" || host2.endsWith(".localhost"))
12624
- return {
12625
- ok: false,
12626
- error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
12627
- };
12628
- return parsed;
12629
- }
12630
- async function checkDestination(url2, allowPrivate, lookup = defaultLookup) {
12631
- if (allowPrivate) return { ok: true };
12632
- const host2 = url2.hostname.replace(/^\[|\]$/g, "");
12633
- let addrs;
12634
- try {
12635
- addrs = await lookup(host2);
12636
- } catch (e) {
12637
- return { ok: false, error: `DNS gagal: ${e.message}` };
12638
- }
12639
- if (!addrs.length) return { ok: false, error: "DNS tak mengembalikan alamat" };
12640
- for (const a of addrs)
12641
- if (isBlockedAddress(a.address))
12642
- return {
12643
- ok: false,
12644
- error: `alamat internal ditolak (${a.address}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
12645
- };
12646
- return { ok: true };
12647
- }
12648
- var defaultLookup;
12649
- var init_ssrf = __esm({
12650
- "src/services/webhooks/ssrf.ts"() {
12651
- "use strict";
12652
- defaultLookup = (host2) => dnsLookup(host2, { all: true });
12653
- }
12654
- });
12655
-
12656
- // src/services/safe-outbound-request.ts
12657
- import { lookup as dnsLookup2 } from "node:dns/promises";
12658
- import { request as httpRequest } from "node:http";
12659
- import { request as httpsRequest } from "node:https";
12660
- async function pinnedRequest(input) {
12661
- return new Promise((resolve19, reject2) => {
12662
- const transport = input.url.protocol === "https:" ? httpsRequest : httpRequest;
12663
- const req = transport({
12664
- protocol: input.url.protocol,
12665
- hostname: input.url.hostname,
12666
- port: input.url.port || void 0,
12667
- method: input.method,
12668
- path: `${input.url.pathname}${input.url.search}`,
12669
- headers: input.headers,
12670
- servername: input.url.hostname,
12671
- // Node >= 20 menyalakan `autoSelectFamily` secara default: socket memanggil `lookup` dengan
12672
- // `all: true` dan membaca `addresses[0].address` dari hasilnya. Menjawab dalam bentuk skalar
12673
- // di situ memberi `undefined` → ERR_INVALID_IP_ADDRESS sebelum satu paket pun keluar, dan
12674
- // pemanggil (sync tick, webhook) menelannya sebagai "offline". Kedua bentuk dijawab supaya
12675
- // pinning tetap benar apa pun setelan family-nya.
12676
- lookup: (_host, opts, callback) => opts.all ? callback(null, [{ address: input.address, family: input.family }]) : callback(null, input.address, input.family),
12677
- timeout: input.connectMs
12678
- }, (response) => {
12679
- const chunks = [];
12680
- let bytes = 0;
12681
- response.on("data", (chunk) => {
12682
- bytes += chunk.length;
12683
- if (bytes > input.maxResponseBytes) {
12684
- response.destroy(new Error("outbound response terlalu besar"));
12685
- return;
12686
- }
12687
- chunks.push(chunk);
12688
- });
12689
- response.on("end", () => resolve19({
12690
- status: response.statusCode ?? 0,
12691
- headers: response.headers,
12692
- body: Buffer.concat(chunks)
12693
- }));
12694
- });
12695
- const timer9 = setTimeout(() => req.destroy(Object.assign(new Error("outbound timeout"), { name: "AbortError" })), input.totalMs);
12696
- timer9.unref?.();
12697
- req.once("close", () => clearTimeout(timer9));
12698
- req.once("error", reject2);
12699
- if (input.body) req.write(input.body);
12700
- req.end();
12701
- });
12702
- }
12703
- async function safeRequest(options2, deps = {}) {
12704
- if (options2.url.protocol !== "http:" && options2.url.protocol !== "https:") throw new Error("outbound scheme ditolak");
12705
- if (options2.url.username || options2.url.password) throw new Error("outbound credential URL ditolak");
12706
- const addresses = await (deps.lookupAll ?? defaultLookup2)(options2.url.hostname);
12707
- if (!addresses.length) throw new Error("DNS tak mengembalikan alamat");
12708
- if (!options2.allowPrivate) {
12709
- const blocked = addresses.find((row) => isBlockedAddress(row.address));
12710
- if (blocked) throw new Error(`alamat internal ditolak (${blocked.address})`);
12711
- }
12712
- const selected = addresses[0];
12713
- return (deps.request ?? pinnedRequest)({ ...options2, address: selected.address, family: selected.family });
12714
- }
12715
- var defaultLookup2;
12716
- var init_safe_outbound_request = __esm({
12717
- "src/services/safe-outbound-request.ts"() {
12718
- "use strict";
12719
- init_ssrf();
12720
- defaultLookup2 = async (host2) => (await dnsLookup2(host2, { all: true })).map((row) => ({ address: row.address, family: row.family }));
12721
- }
12722
- });
12723
-
12724
12831
  // ../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js
12725
12832
  var require_constants = __commonJS({
12726
12833
  "../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js"(exports, module) {
@@ -24806,13 +24913,13 @@ init_src();
24806
24913
  import { execFile } from "node:child_process";
24807
24914
  import { promisify } from "node:util";
24808
24915
  import { existsSync as existsSync6, rmSync as rmSync2 } from "node:fs";
24809
- import { resolve as resolve9 } from "node:path";
24916
+ import { resolve as resolve10 } from "node:path";
24810
24917
 
24811
24918
  // src/services/safe-repo-path.ts
24812
24919
  import { constants } from "node:fs";
24813
- import { lstat, mkdir, open as open2, realpath, rename, unlink } from "node:fs/promises";
24814
- import { isAbsolute as isAbsolute3, join as join5, relative, resolve as resolve8, sep as sep2 } from "node:path";
24815
- import { randomUUID as randomUUID2 } from "node:crypto";
24920
+ import { lstat, mkdir as mkdir2, open as open2, realpath, rename, unlink as unlink2 } from "node:fs/promises";
24921
+ import { isAbsolute as isAbsolute3, join as join6, relative, resolve as resolve9, sep as sep2 } from "node:path";
24922
+ import { randomUUID as randomUUID3 } from "node:crypto";
24816
24923
  import { closeSync, fstatSync, lstatSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync5, realpathSync as realpathSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
24817
24924
  var PathContainmentError = class extends Error {
24818
24925
  code = "PATH_CONTAINMENT";
@@ -24841,9 +24948,9 @@ async function ensureRepoParents(root, rel) {
24841
24948
  const parts = components(rel).slice(0, -1);
24842
24949
  let current = base2;
24843
24950
  for (const part of parts) {
24844
- current = join5(current, part);
24951
+ current = join6(current, part);
24845
24952
  try {
24846
- await mkdir(current, { mode: 448 });
24953
+ await mkdir2(current, { mode: 448 });
24847
24954
  } catch (error) {
24848
24955
  if (error.code !== "EEXIST") throw error;
24849
24956
  }
@@ -24858,7 +24965,7 @@ function ensureRepoParentsSync(root, rel) {
24858
24965
  const parts = components(rel).slice(0, -1);
24859
24966
  let current = base2;
24860
24967
  for (const part of parts) {
24861
- current = join5(current, part);
24968
+ current = join6(current, part);
24862
24969
  try {
24863
24970
  mkdirSync5(current, { mode: 448 });
24864
24971
  } catch (error) {
@@ -24874,7 +24981,7 @@ async function resolveRepoEntry(root, rel, opts = {}) {
24874
24981
  const parts = components(rel);
24875
24982
  let current = base2;
24876
24983
  for (let i = 0; i < parts.length; i++) {
24877
- current = join5(current, parts[i]);
24984
+ current = join6(current, parts[i]);
24878
24985
  const stat2 = await lstat(current).catch((error) => {
24879
24986
  if (error.code === "ENOENT" && opts.allowMissingFinal && i === parts.length - 1) return null;
24880
24987
  return denied("komponen tidak ada");
@@ -24883,8 +24990,8 @@ async function resolveRepoEntry(root, rel, opts = {}) {
24883
24990
  if (stat2.isSymbolicLink()) denied("symlink");
24884
24991
  if (i < parts.length - 1 && !stat2.isDirectory()) denied("parent bukan direktori");
24885
24992
  }
24886
- if (!beneath(base2, resolve8(current))) denied("keluar root");
24887
- const parent = resolve8(current, "..");
24993
+ if (!beneath(base2, resolve9(current))) denied("keluar root");
24994
+ const parent = resolve9(current, "..");
24888
24995
  const parentReal = await realpath(parent).catch(() => denied("parent tidak ada"));
24889
24996
  if (!beneath(base2, parentReal)) denied("parent keluar root");
24890
24997
  return { root: base2, path: current, parent: parentReal };
@@ -24894,7 +25001,7 @@ function assertSafeRepoPathSync(root, rel, allowMissingFinal = false, allowMissi
24894
25001
  const parts = components(rel);
24895
25002
  let current = base2;
24896
25003
  for (let i = 0; i < parts.length; i++) {
24897
- current = join5(current, parts[i]);
25004
+ current = join6(current, parts[i]);
24898
25005
  try {
24899
25006
  const stat2 = lstatSync(current);
24900
25007
  if (stat2.isSymbolicLink()) denied("symlink");
@@ -24905,7 +25012,7 @@ function assertSafeRepoPathSync(root, rel, allowMissingFinal = false, allowMissi
24905
25012
  throw error;
24906
25013
  }
24907
25014
  }
24908
- if (!beneath(base2, resolve8(current))) denied("keluar root");
25015
+ if (!beneath(base2, resolve9(current))) denied("keluar root");
24909
25016
  return current;
24910
25017
  }
24911
25018
  function readRepoFileSync(root, rel) {
@@ -24921,10 +25028,10 @@ function readRepoFileSync(root, rel) {
24921
25028
  function writeRepoFileAtomicSync(root, rel, data) {
24922
25029
  ensureRepoParentsSync(root, rel);
24923
25030
  const path2 = assertSafeRepoPathSync(root, rel, true);
24924
- const parent = resolve8(path2, "..");
25031
+ const parent = resolve9(path2, "..");
24925
25032
  const parentRel = relative(realpathSync2(root), parent);
24926
25033
  if (parentRel) assertSafeRepoPathSync(root, parentRel);
24927
- const temp = join5(parent, `.hanoman-${randomUUID2()}.tmp`);
25034
+ const temp = join6(parent, `.hanoman-${randomUUID3()}.tmp`);
24928
25035
  const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 384);
24929
25036
  try {
24930
25037
  writeFileSync3(fd, data);
@@ -24968,10 +25075,10 @@ async function writeRepoFileAtomic(root, rel, data) {
24968
25075
  return denied("final component tak terbaca");
24969
25076
  });
24970
25077
  if (current?.isSymbolicLink() || current && !current.isFile()) denied("target bukan file regular");
24971
- await mkdir(entry.parent, { recursive: false, mode: 448 }).catch((error) => {
25078
+ await mkdir2(entry.parent, { recursive: false, mode: 448 }).catch((error) => {
24972
25079
  if (error.code !== "EEXIST") throw error;
24973
25080
  });
24974
- const temp = join5(entry.parent, `.hanoman-${randomUUID2()}.tmp`);
25081
+ const temp = join6(entry.parent, `.hanoman-${randomUUID3()}.tmp`);
24975
25082
  const handle = await open2(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 384);
24976
25083
  try {
24977
25084
  await handle.writeFile(data);
@@ -24986,7 +25093,7 @@ async function writeRepoFileAtomic(root, rel, data) {
24986
25093
  if (finalAgain?.isSymbolicLink() || finalAgain && !finalAgain.isFile()) denied("target berubah");
24987
25094
  await rename(temp, entry.path);
24988
25095
  } catch (error) {
24989
- await unlink(temp).catch(() => {
25096
+ await unlink2(temp).catch(() => {
24990
25097
  });
24991
25098
  throw error;
24992
25099
  }
@@ -25016,7 +25123,7 @@ function docsDirOf(repoDir) {
25016
25123
  }
25017
25124
  function resolveIndex(repoDir, docsDir) {
25018
25125
  const rel = `${docsDir}/README.md`;
25019
- return existsSync6(resolve9(repoDir, rel)) ? rel : "";
25126
+ return existsSync6(resolve10(repoDir, rel)) ? rel : "";
25020
25127
  }
25021
25128
  var catOf = (rel) => rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ".";
25022
25129
  var nameOf = (rel) => rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
@@ -25393,7 +25500,7 @@ import { existsSync as existsSync7 } from "node:fs";
25393
25500
  import { execFile as execFile3 } from "node:child_process";
25394
25501
  import { promisify as promisify3 } from "node:util";
25395
25502
  import { rmSync as rmSync3 } from "node:fs";
25396
- import { join as join6 } from "node:path";
25503
+ import { join as join7 } from "node:path";
25397
25504
  var sanitize = (id) => id.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25398
25505
  var sourceBranch = (specId) => `hanoman/${sanitize(specId)}`;
25399
25506
  var exec3 = promisify3(execFile3);
@@ -25437,7 +25544,7 @@ async function integrateBranch(repoDir, src, op, target2) {
25437
25544
  const tgt = await resolveTarget(repoDir, target2);
25438
25545
  if (!tgt) return { status: "error", code: 400, error: `target "${target2}" tidak dikenal` };
25439
25546
  await sh(repoDir, ["fetch", "origin"]);
25440
- const wt = join6(repoDir, ".worktrees", `merge-${sanitize(src.mergeId)}`);
25547
+ const wt = join7(repoDir, ".worktrees", `merge-${sanitize(src.mergeId)}`);
25441
25548
  await reclaim(repoDir, wt);
25442
25549
  const baseRef = op === "merge" ? tgt.ref : source;
25443
25550
  const baseSha = await out(repoDir, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]);
@@ -25498,7 +25605,7 @@ async function mergeIntoCurrent(repoDir, source, opts = {}) {
25498
25605
  const src = await resolveGraphSource(repoDir, source);
25499
25606
  if (!src) return { status: "error", code: 400, error: `source "${source}" tak dikenal` };
25500
25607
  await sh(repoDir, ["fetch", "origin"]);
25501
- const wt = join6(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25608
+ const wt = join7(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25502
25609
  await reclaim(repoDir, wt);
25503
25610
  const baseSha = await out(repoDir, ["rev-parse", "--verify", "--end-of-options", `refs/heads/${current}^{commit}`]);
25504
25611
  if (!await ok(repoDir, ["worktree", "add", "--detach", "-q", wt, baseSha]))
@@ -25536,7 +25643,7 @@ async function replayCurrent(repoDir, source, cmd) {
25536
25643
  const current = await out(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
25537
25644
  if (!current || current === "HEAD")
25538
25645
  return { status: "error", code: 409, error: "HEAD detached \u2014 checkout sebuah branch dulu" };
25539
- const wt = join6(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25646
+ const wt = join7(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25540
25647
  await reclaim(repoDir, wt);
25541
25648
  const baseSha = await out(repoDir, ["rev-parse", "--verify", "--end-of-options", `refs/heads/${current}^{commit}`]);
25542
25649
  if (!await ok(repoDir, ["worktree", "add", "--detach", "-q", wt, baseSha]))
@@ -25583,24 +25690,24 @@ init_db();
25583
25690
  // src/services/spec-review.ts
25584
25691
  import { execFile as execFile4 } from "node:child_process";
25585
25692
  import { promisify as promisify4 } from "node:util";
25586
- import { mkdtemp, copyFile, rm } from "node:fs/promises";
25693
+ import { mkdtemp, copyFile, rm as rm2 } from "node:fs/promises";
25587
25694
  import { tmpdir as tmpdir2 } from "node:os";
25588
- import { join as join7, resolve as resolve10 } from "node:path";
25695
+ import { join as join8, resolve as resolve11 } from "node:path";
25589
25696
  var exec4 = promisify4(execFile4);
25590
25697
  var GIT3 = { maxBuffer: 1 << 24 };
25591
25698
  var MAX = 256 * 1024;
25592
- var worktreeDir = (repoDir, specId) => join7(repoDir, ".worktrees", specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_"));
25699
+ var worktreeDir = (repoDir, specId) => join8(repoDir, ".worktrees", specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_"));
25593
25700
  async function withTempIndex(wt, fn2) {
25594
25701
  const idx = (await exec4("git", ["rev-parse", "--git-path", "index"], { cwd: wt, ...GIT3 })).stdout.trim();
25595
- const dir2 = await mkdtemp(join7(tmpdir2(), "hanoman-idx-"));
25596
- const tmp = join7(dir2, "index");
25597
- await copyFile(resolve10(wt, idx), tmp);
25702
+ const dir2 = await mkdtemp(join8(tmpdir2(), "hanoman-idx-"));
25703
+ const tmp = join8(dir2, "index");
25704
+ await copyFile(resolve11(wt, idx), tmp);
25598
25705
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
25599
25706
  try {
25600
25707
  await exec4("git", ["add", "-A", "-N"], { cwd: wt, env, ...GIT3 });
25601
25708
  return await fn2(env);
25602
25709
  } finally {
25603
- await rm(dir2, { recursive: true, force: true });
25710
+ await rm2(dir2, { recursive: true, force: true });
25604
25711
  }
25605
25712
  }
25606
25713
  var splitZ = (s2) => s2.split("\0").filter(Boolean);
@@ -25804,7 +25911,7 @@ init_notifications();
25804
25911
 
25805
25912
  // src/services/session-result.ts
25806
25913
  init_db();
25807
- import { randomUUID as randomUUID3 } from "node:crypto";
25914
+ import { randomUUID as randomUUID4 } from "node:crypto";
25808
25915
  var WHITELIST = [
25809
25916
  "projectId",
25810
25917
  "specId",
@@ -25818,7 +25925,7 @@ var WHITELIST = [
25818
25925
  "author"
25819
25926
  ];
25820
25927
  async function recordSessionResult(input) {
25821
- const id = randomUUID3();
25928
+ const id = randomUUID4();
25822
25929
  const data = { id };
25823
25930
  for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
25824
25931
  if (data.status === void 0) data.status = "done";
@@ -29167,7 +29274,7 @@ import { promisify as promisify7 } from "node:util";
29167
29274
  import { execFile as execFile6 } from "node:child_process";
29168
29275
  import { promisify as promisify6 } from "node:util";
29169
29276
  import { existsSync as existsSync9 } from "node:fs";
29170
- import { resolve as resolve11 } from "node:path";
29277
+ import { resolve as resolve12 } from "node:path";
29171
29278
  var exec6 = promisify6(execFile6);
29172
29279
  var GIT5 = { maxBuffer: 1 << 24 };
29173
29280
  var MAX2 = 256 * 1024;
@@ -29175,7 +29282,7 @@ var splitZ2 = (s2) => s2.split("\0").filter(Boolean);
29175
29282
  function repoAbsPath(repoDir, rel, allowMissingFinal = false) {
29176
29283
  if (rel.split(/[\\/]/).includes(".git")) throw new Error("tidak boleh menyentuh .git");
29177
29284
  assertSafeRepoPathSync(repoDir, rel, allowMissingFinal, true);
29178
- return resolve11(repoDir, rel);
29285
+ return resolve12(repoDir, rel);
29179
29286
  }
29180
29287
  async function listRepoTree(repoDir, ref = "") {
29181
29288
  if (!repoDir || !existsSync9(repoDir)) return [];
@@ -30065,14 +30172,14 @@ async function finishGraphOp(reply, id, repoDir, r, verb) {
30065
30172
  // src/routes/fs.ts
30066
30173
  import { readdir } from "node:fs/promises";
30067
30174
  import { homedir as homedir4 } from "node:os";
30068
- import { resolve as resolve12, dirname as dirname9, join as join8 } from "node:path";
30175
+ import { resolve as resolve13, dirname as dirname9, join as join9 } from "node:path";
30069
30176
  async function fs_default(app2) {
30070
30177
  app2.get("/fs/browse", async (req, reply) => {
30071
30178
  const q = req.query.path;
30072
- const dir2 = q && q.trim() ? resolve12(q.trim()) : homedir4();
30179
+ const dir2 = q && q.trim() ? resolve13(q.trim()) : homedir4();
30073
30180
  try {
30074
30181
  const ents = await readdir(dir2, { withFileTypes: true });
30075
- const entries = ents.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, path: join8(dir2, e.name) })).sort((a, b) => a.name.localeCompare(b.name));
30182
+ const entries = ents.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, path: join9(dir2, e.name) })).sort((a, b) => a.name.localeCompare(b.name));
30076
30183
  const parent = dirname9(dir2);
30077
30184
  return { path: dir2, parent: parent === dir2 ? null : parent, entries };
30078
30185
  } catch {
@@ -30506,11 +30613,11 @@ function installCommand(m, agent, shell = shellBin()) {
30506
30613
  }
30507
30614
 
30508
30615
  // src/services/session-worktree.ts
30509
- import { resolve as resolve13, sep as sep3 } from "node:path";
30616
+ import { resolve as resolve14, sep as sep3 } from "node:path";
30510
30617
  function ownsWorktree(repoDir, cwd) {
30511
- const base2 = resolve13(repoDir);
30512
- const dir2 = resolve13(cwd);
30513
- const container = resolve13(base2, ".worktrees");
30618
+ const base2 = resolve14(repoDir);
30619
+ const dir2 = resolve14(cwd);
30620
+ const container = resolve14(base2, ".worktrees");
30514
30621
  return dir2 !== base2 && dir2 !== container && dir2.startsWith(container + sep3);
30515
30622
  }
30516
30623
 
@@ -30518,15 +30625,15 @@ function ownsWorktree(repoDir, cwd) {
30518
30625
  init_src2();
30519
30626
  init_db();
30520
30627
  import { execFile as execFile8 } from "node:child_process";
30521
- import { readdir as readdir2, rm as rm2 } from "node:fs/promises";
30628
+ import { readdir as readdir2, rm as rm3 } from "node:fs/promises";
30522
30629
  import { promisify as promisify9 } from "node:util";
30523
- import { join as join9, resolve as resolve14 } from "node:path";
30630
+ import { join as join10, resolve as resolve15 } from "node:path";
30524
30631
  init_notifications();
30525
30632
  var TICK_MS = 6e4;
30526
- var trashDirOf = (repoDir) => resolve14(repoDir, ".worktrees", ".trash");
30633
+ var trashDirOf = (repoDir) => resolve15(repoDir, ".worktrees", ".trash");
30527
30634
  var exec8 = promisify9(execFile8);
30528
30635
  var prodReaperDeps = {
30529
- rm: (path2) => rm2(path2, { recursive: true, force: true }),
30636
+ rm: (path2) => rm3(path2, { recursive: true, force: true }),
30530
30637
  prune: async (repoDir) => {
30531
30638
  try {
30532
30639
  await exec8("git", ["worktree", "prune"], { cwd: repoDir, timeout: 3e4 });
@@ -30580,7 +30687,7 @@ async function sweepRepo(repoDir, projectId, deps = prodReaperDeps) {
30580
30687
  }
30581
30688
  let removed = 0;
30582
30689
  for (const entry of entries) {
30583
- const path2 = join9(dir2, entry);
30690
+ const path2 = join10(dir2, entry);
30584
30691
  const known = pending.get(path2);
30585
30692
  const row = known ?? { path: path2, repoDir, projectId, entry, sessionId: sessionIdOf(entry), since: Date.now() };
30586
30693
  pending.set(path2, row);
@@ -30624,6 +30731,8 @@ function startWorktreeReaper(deps = prodReaperDeps) {
30624
30731
  init_notifications();
30625
30732
  init_stage_machine();
30626
30733
  init_pty();
30734
+ init_uploads();
30735
+ var ATTACHMENT_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
30627
30736
  var TERMINAL_WS_MESSAGES_PER_MINUTE = 6e3;
30628
30737
  async function advanceStage(specId, repoDir, sessionId2, flow, worktree) {
30629
30738
  const next = stageForRun(readPhases(phaseFilePath(repoDir, sessionId2), flow), worktree, specId);
@@ -30947,6 +31056,18 @@ async function terminal_default(app2, opts) {
30947
31056
  killSession(id);
30948
31057
  return reply.code(202).send({ cleanup: null });
30949
31058
  });
31059
+ app2.post("/terminal/sessions/:id/attachments", async (req, reply) => {
31060
+ const { id } = req.params;
31061
+ if (!getSession(id)) return reply.code(404).send({ error: "not found" });
31062
+ if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
31063
+ const part = await req.file?.();
31064
+ if (!part) return reply.code(400).send({ error: "unggahan tak valid" });
31065
+ const buf = await part.toBuffer();
31066
+ if (part.file?.truncated) return reply.code(413).send({ error: "berkas melebihi 5 MB" });
31067
+ if (!ATTACHMENT_MIME.has(part.mimetype)) return reply.code(415).send({ error: "tipe berkas tak didukung" });
31068
+ const { path: path2 } = await saveSessionUpload(id, buf, part.mimetype);
31069
+ return { path: path2 };
31070
+ });
30950
31071
  app2.get("/terminal/sessions/:id/ws", {
30951
31072
  websocket: true,
30952
31073
  preValidation: async (req, reply) => {
@@ -31185,7 +31306,7 @@ init_config2();
31185
31306
  import { spawn as spawn3 } from "node:child_process";
31186
31307
  import { mkdtempSync, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
31187
31308
  import { tmpdir as tmpdir3 } from "node:os";
31188
- import { dirname as dirname10, join as join10 } from "node:path";
31309
+ import { dirname as dirname10, join as join11 } from "node:path";
31189
31310
  var sshBin = () => effectiveStr("HANOMAN_SSH_BIN") ?? "ssh";
31190
31311
  function consoleArgv(t) {
31191
31312
  return [
@@ -31200,8 +31321,8 @@ function consoleArgv(t) {
31200
31321
  ];
31201
31322
  }
31202
31323
  function askpassScript() {
31203
- const dir2 = mkdtempSync(join10(tmpdir3(), "hanoman-askpass-"));
31204
- const path2 = join10(dir2, "askpass.sh");
31324
+ const dir2 = mkdtempSync(join11(tmpdir3(), "hanoman-askpass-"));
31325
+ const path2 = join11(dir2, "askpass.sh");
31205
31326
  writeFileSync4(path2, `#!/bin/sh
31206
31327
  printf '%s' "$HANOMAN_SSH_PASSWORD"
31207
31328
  `, { mode: 448 });
@@ -31262,15 +31383,15 @@ function sshExec(t, remoteCmd, opts = {}) {
31262
31383
  // src/services/vps-audit.ts
31263
31384
  init_db();
31264
31385
  import { readFileSync as readFileSync7 } from "node:fs";
31265
- import { join as join12 } from "node:path";
31386
+ import { join as join13 } from "node:path";
31266
31387
 
31267
31388
  // src/runner/deps.ts
31268
31389
  import { existsSync as existsSync11 } from "node:fs";
31269
- import { dirname as dirname11, join as join11 } from "node:path";
31390
+ import { dirname as dirname11, join as join12 } from "node:path";
31270
31391
  function repoRootFrom(startDir) {
31271
31392
  let dir2 = startDir;
31272
31393
  for (let i = 0; i < 8; i++) {
31273
- if (existsSync11(join11(dir2, "pnpm-workspace.yaml"))) return dir2;
31394
+ if (existsSync11(join12(dir2, "pnpm-workspace.yaml"))) return dir2;
31274
31395
  const parent = dirname11(dir2);
31275
31396
  if (parent === dir2) break;
31276
31397
  dir2 = parent;
@@ -31376,7 +31497,7 @@ function parseHealth(out3) {
31376
31497
  return { uptime: h.uptime ?? "", disk: h.disk ?? "", mem: h.mem ?? "", load: h.load ?? "" };
31377
31498
  }
31378
31499
  var target = (v) => ({ host: v.host, port: v.port, user: v.user, keyPath: v.keyPath });
31379
- var scriptPath = (f) => join12(repoRoot(), "server", "scripts", "vps", f);
31500
+ var scriptPath = (f) => join13(repoRoot(), "server", "scripts", "vps", f);
31380
31501
  async function itemStatesOf(vpsId) {
31381
31502
  const rows = await prisma.vpsItemState.findMany({ where: { vpsId } });
31382
31503
  return Object.fromEntries(rows.map((s2) => [s2.itemId, { na: s2.na, attested: s2.attested }]));
@@ -31525,11 +31646,11 @@ init_config2();
31525
31646
  import { execFileSync as execFileSync2 } from "node:child_process";
31526
31647
  import { existsSync as existsSync12, mkdirSync as mkdirSync8, readFileSync as readFileSync9 } from "node:fs";
31527
31648
  import { homedir as homedir5 } from "node:os";
31528
- import { join as join13 } from "node:path";
31529
- var keyDir = () => effectiveStr("HANOMAN_SSH_KEY_DIR") ?? join13(homedir5(), ".hanoman");
31649
+ import { join as join14 } from "node:path";
31650
+ var keyDir = () => effectiveStr("HANOMAN_SSH_KEY_DIR") ?? join14(homedir5(), ".hanoman");
31530
31651
  function ensureHanomanKey() {
31531
31652
  const dir2 = keyDir();
31532
- const privPath = join13(dir2, "id_ed25519");
31653
+ const privPath = join14(dir2, "id_ed25519");
31533
31654
  const pubPath = `${privPath}.pub`;
31534
31655
  if (!existsSync12(privPath)) {
31535
31656
  mkdirSync8(dir2, { recursive: true, mode: 448 });
@@ -31767,7 +31888,7 @@ init_config2();
31767
31888
  import { execFileSync as execFileSync3 } from "node:child_process";
31768
31889
  import { readFileSync as readFileSync11 } from "node:fs";
31769
31890
  import { homedir as homedir7 } from "node:os";
31770
- import { join as join14 } from "node:path";
31891
+ import { join as join15 } from "node:path";
31771
31892
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
31772
31893
  var TTL_MS2 = 3e4;
31773
31894
  var lastOk = null;
@@ -31780,7 +31901,7 @@ var LABELS = {
31780
31901
  var humanize = (k) => k.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
31781
31902
  var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
31782
31903
  function credsFile() {
31783
- return join14(effectiveStr("CLAUDE_CONFIG_DIR") ?? join14(homedir7(), ".claude"), ".credentials.json");
31904
+ return join15(effectiveStr("CLAUDE_CONFIG_DIR") ?? join15(homedir7(), ".claude"), ".credentials.json");
31784
31905
  }
31785
31906
  function readAccessToken() {
31786
31907
  if (process.platform === "darwin" && !effectiveStr("CLAUDE_CONFIG_DIR")) {
@@ -31854,14 +31975,14 @@ async function getLimits() {
31854
31975
  // src/services/codex-limits.ts
31855
31976
  import { open as open3, readdir as readdir3, stat } from "node:fs/promises";
31856
31977
  import { homedir as homedir8 } from "node:os";
31857
- import { join as join15 } from "node:path";
31978
+ import { join as join16 } from "node:path";
31858
31979
  var TTL_MS3 = 3e4;
31859
31980
  var STALE_AFTER_MS = 12 * 36e5;
31860
31981
  var TAIL_BYTES = 512 * 1024;
31861
31982
  var MAX_FILES = 8;
31862
31983
  var cache2 = null;
31863
31984
  var freshUntil2 = 0;
31864
- var codexSessionsDir = () => join15(process.env.CODEX_HOME ?? join15(homedir8(), ".codex"), "sessions");
31985
+ var codexSessionsDir = () => join16(process.env.CODEX_HOME ?? join16(homedir8(), ".codex"), "sessions");
31865
31986
  var UNAVAILABLE = { status: "unavailable", windows: [], fetchedAt: null, plan: null };
31866
31987
  function windowLabel(minutes) {
31867
31988
  if (minutes === 300) return "Sesi 5 jam";
@@ -31894,7 +32015,7 @@ async function recentRollouts(dir2) {
31894
32015
  return [];
31895
32016
  }
31896
32017
  const stamped = await Promise.all(entries.map(async (rel) => {
31897
- const full = join15(dir2, rel);
32018
+ const full = join16(dir2, rel);
31898
32019
  try {
31899
32020
  return { full, mtime: (await stat(full)).mtimeMs };
31900
32021
  } catch {
@@ -32022,7 +32143,7 @@ init_src();
32022
32143
  init_src();
32023
32144
  init_config2();
32024
32145
  import { readFileSync as readFileSync12 } from "node:fs";
32025
- import { resolve as resolve15, dirname as dirname12 } from "node:path";
32146
+ import { resolve as resolve16, dirname as dirname12 } from "node:path";
32026
32147
  import { fileURLToPath as fileURLToPath3 } from "node:url";
32027
32148
  var UPDATE_COMMAND = "npm i -g hanoman@latest";
32028
32149
  function composeUpdate(x) {
@@ -32048,7 +32169,7 @@ var lastLatest = null;
32048
32169
  var lastStatus = "unavailable";
32049
32170
  function runningVersion() {
32050
32171
  const here = dirname12(fileURLToPath3(import.meta.url));
32051
- for (const p of [resolve15(here, "build-info.json"), resolve15(here, "../package.json")]) {
32172
+ for (const p of [resolve16(here, "build-info.json"), resolve16(here, "../package.json")]) {
32052
32173
  try {
32053
32174
  const v = JSON.parse(readFileSync12(p, "utf8")).version;
32054
32175
  if (typeof v === "string" && v) return v;
@@ -32399,54 +32520,7 @@ setAcceptedHook((row) => broadcastSyncLog(row));
32399
32520
  init_sync();
32400
32521
  init_sync_client();
32401
32522
  init_conflicts();
32402
-
32403
- // src/services/uploads.ts
32404
- init_config2();
32405
- init_safe_outbound_request();
32406
- init_src2();
32407
- import { mkdir as mkdir2, writeFile, readFile as readFile2, unlink as unlink2 } from "node:fs/promises";
32408
- import { join as join16, resolve as resolve16 } from "node:path";
32409
- function uploadDir() {
32410
- return resolve16(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join16(resolveHome(), "uploads"));
32411
- }
32412
- async function readUpload(storageKey) {
32413
- const safe = storageKey.replace(/[/\\]/g, "");
32414
- return readFile2(join16(uploadDir(), safe));
32415
- }
32416
- async function readUploadOrFetch(storageKey) {
32417
- const safe = storageKey.replace(/[/\\]/g, "");
32418
- const target2 = join16(uploadDir(), safe);
32419
- try {
32420
- return await readFile2(target2);
32421
- } catch {
32422
- const base2 = effectiveStr("SYNC_SERVER_URL");
32423
- const token = effectiveStr("SYNC_DEVICE_TOKEN");
32424
- if (!base2 || !token) throw new Error(`lampiran ${safe} tak ada lokal & bukan client sync`);
32425
- const url2 = new URL(`/api/sync/attachments/${encodeURIComponent(safe)}`, `${base2.replace(/\/$/, "")}/`);
32426
- const loopback = ["localhost", "127.0.0.1", "::1"].includes(url2.hostname);
32427
- const res = await safeRequest({
32428
- url: url2,
32429
- method: "GET",
32430
- headers: { authorization: `Bearer ${token}` },
32431
- allowPrivate: process.env.NODE_ENV !== "production" && loopback,
32432
- connectMs: 5e3,
32433
- totalMs: 15e3,
32434
- maxResponseBytes: 5 * 1024 * 1024
32435
- });
32436
- if (res.status < 200 || res.status >= 300) throw new Error(`fetch lampiran hub gagal: ${res.status}`);
32437
- const buf = res.body;
32438
- await mkdir2(uploadDir(), { recursive: true, mode: 448 });
32439
- await writeFile(target2, buf, { mode: 384 });
32440
- return buf;
32441
- }
32442
- }
32443
- async function deleteUpload(storageKey) {
32444
- const safe = storageKey.replace(/[/\\]/g, "");
32445
- await unlink2(join16(uploadDir(), safe)).catch(() => {
32446
- });
32447
- }
32448
-
32449
- // src/routes/sync.ts
32523
+ init_uploads();
32450
32524
  init_config2();
32451
32525
  var zPush = external_exports.object({
32452
32526
  records: external_exports.array(external_exports.object({
@@ -32597,12 +32671,12 @@ async function session_results_default(app2) {
32597
32671
  // src/services/session-history.ts
32598
32672
  init_db();
32599
32673
  init_pty();
32600
- import { randomUUID as randomUUID5 } from "node:crypto";
32674
+ import { randomUUID as randomUUID6 } from "node:crypto";
32601
32675
 
32602
32676
  // src/services/transcript-store.ts
32603
32677
  init_config2();
32604
32678
  init_src2();
32605
- import { randomUUID as randomUUID4 } from "node:crypto";
32679
+ import { randomUUID as randomUUID5 } from "node:crypto";
32606
32680
  import { mkdir as mkdir3, writeFile as writeFile2, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
32607
32681
  import { join as join17, resolve as resolve17, basename as basename4 } from "node:path";
32608
32682
  var MAX_TRANSCRIPT_BYTES = 1024 * 1024;
@@ -32624,7 +32698,7 @@ async function saveTranscript(text) {
32624
32698
  const { body, truncated } = clamp(text);
32625
32699
  const dir2 = transcriptDir();
32626
32700
  await mkdir3(dir2, { recursive: true, mode: 448 });
32627
- const key = `${randomUUID4()}.log`;
32701
+ const key = `${randomUUID5()}.log`;
32628
32702
  await writeFile2(join17(dir2, key), body, { encoding: "utf8", mode: 384 });
32629
32703
  return { key, bytes: Buffer.byteLength(body, "utf8"), truncated };
32630
32704
  }
@@ -32669,7 +32743,7 @@ async function titleFor(specId) {
32669
32743
  async function beginSession(b) {
32670
32744
  await prisma.sessionHistory.create({
32671
32745
  data: {
32672
- id: randomUUID5(),
32746
+ id: randomUUID6(),
32673
32747
  sessionId: b.sessionId,
32674
32748
  projectId: b.projectId,
32675
32749
  specId: b.specId ?? null,
@@ -32874,8 +32948,9 @@ init_db();
32874
32948
 
32875
32949
  // src/services/ticket.ts
32876
32950
  init_db();
32877
- import { createHash as createHash7, randomBytes as randomBytes6 } from "node:crypto";
32951
+ init_uploads();
32878
32952
  init_src();
32953
+ import { createHash as createHash7, randomBytes as randomBytes6 } from "node:crypto";
32879
32954
  function hashAccessKey(key) {
32880
32955
  return createHash7("sha256").update(key).digest("hex");
32881
32956
  }
@@ -32920,9 +32995,10 @@ async function pruneOldTickets(now = Date.now()) {
32920
32995
  // src/services/ticket-intake.ts
32921
32996
  init_db();
32922
32997
  init_notifications();
32998
+ init_uploads();
32923
32999
 
32924
33000
  // src/services/upload-pipeline.ts
32925
- import { randomUUID as randomUUID6 } from "node:crypto";
33001
+ import { randomUUID as randomUUID7 } from "node:crypto";
32926
33002
  import { spawn as spawn4 } from "node:child_process";
32927
33003
  import { mkdir as mkdir4, rename as rename2, unlink as unlink4, writeFile as writeFile3 } from "node:fs/promises";
32928
33004
  import { basename as basename5, isAbsolute as isAbsolute4, join as join18 } from "node:path";
@@ -40288,6 +40364,7 @@ var dist_default = constructor_default;
40288
40364
 
40289
40365
  // src/services/upload-pipeline.ts
40290
40366
  init_db();
40367
+ init_uploads();
40291
40368
  var UPLOAD_LIMITS = {
40292
40369
  fileBytes: 5 * 1024 * 1024,
40293
40370
  ticketBytes: 10 * 1024 * 1024,
@@ -40391,8 +40468,8 @@ async function processUpload(input, deps = {}) {
40391
40468
  const quarantineDir = join18(storageDir, ".quarantine");
40392
40469
  await mkdir4(quarantineDir, { recursive: true, mode: 448 });
40393
40470
  await mkdir4(storageDir, { recursive: true, mode: 448 });
40394
- const quarantine = join18(quarantineDir, `${randomUUID6()}.upload`);
40395
- const storageKey = `${randomUUID6()}${type.extension}`;
40471
+ const quarantine = join18(quarantineDir, `${randomUUID7()}.upload`);
40472
+ const storageKey = `${randomUUID7()}${type.extension}`;
40396
40473
  await writeFile3(quarantine, normalized, { mode: 384, flag: "wx" });
40397
40474
  try {
40398
40475
  await timeout2((deps.scanner ?? scannerFromEnv)(quarantine), UPLOAD_LIMITS.scanMs, "UPLOAD_SCAN");
@@ -40580,6 +40657,7 @@ async function help_default(app2) {
40580
40657
 
40581
40658
  // src/routes/tickets.ts
40582
40659
  init_db();
40660
+ init_uploads();
40583
40661
 
40584
40662
  // src/services/ticket-accept.ts
40585
40663
  init_db();
@@ -41516,7 +41594,7 @@ init_config2();
41516
41594
  init_pty();
41517
41595
  init_session_sandbox();
41518
41596
  import { execFile as execFile10 } from "node:child_process";
41519
- import { randomUUID as randomUUID7 } from "node:crypto";
41597
+ import { randomUUID as randomUUID8 } from "node:crypto";
41520
41598
  import { mkdirSync as mkdirSync9, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "node:fs";
41521
41599
  import { tmpdir as tmpdir4 } from "node:os";
41522
41600
  import { join as join19 } from "node:path";
@@ -41549,9 +41627,9 @@ function leadProcess(prompt, o, env = process.env) {
41549
41627
  } };
41550
41628
  const promptDir = join19(tmpdir4(), "hanoman-prompts");
41551
41629
  mkdirSync9(promptDir, { recursive: true, mode: 448 });
41552
- const promptFile = join19(promptDir, `oneshot-${randomUUID7()}`);
41630
+ const promptFile = join19(promptDir, `oneshot-${randomUUID8()}`);
41553
41631
  writeFileSync5(promptFile, prompt, { flag: "wx", mode: 384 });
41554
- const workspace = o.cwd ?? join19(tmpdir4(), `hanoman-oneshot-${randomUUID7()}`);
41632
+ const workspace = o.cwd ?? join19(tmpdir4(), `hanoman-oneshot-${randomUUID8()}`);
41555
41633
  if (!o.cwd) mkdirSync9(workspace, { recursive: false, mode: 448 });
41556
41634
  try {
41557
41635
  const argsWithoutPrompt = directArgs.slice(0, -1);
@@ -45299,7 +45377,12 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45299
45377
  done(err, void 0);
45300
45378
  }
45301
45379
  });
45302
- app2.register(websocket, { options: { maxPayload: MAX_WS_MESSAGE_BYTES } });
45380
+ app2.register(websocket, {
45381
+ options: {
45382
+ maxPayload: MAX_WS_MESSAGE_BYTES,
45383
+ perMessageDeflate: { zlibDeflateOptions: { level: 6, memLevel: 7 }, concurrencyLimit: 10 }
45384
+ }
45385
+ });
45303
45386
  app2.addHook("onClose", async () => {
45304
45387
  await stopTelegramRuntime();
45305
45388
  detachAll();
@@ -45745,7 +45828,7 @@ init_tap();
45745
45828
  // src/services/webhooks/emit.ts
45746
45829
  init_src();
45747
45830
  init_db();
45748
- import { randomUUID as randomUUID8 } from "node:crypto";
45831
+ import { randomUUID as randomUUID9 } from "node:crypto";
45749
45832
  function skipped(def2, row) {
45750
45833
  if (!def2.skipWhen || !row) return false;
45751
45834
  return row[def2.skipWhen.field] === def2.skipWhen.equals;
@@ -45811,7 +45894,7 @@ async function emitWebhook(i) {
45811
45894
  const targets = matchingEndpoints(type, projectId);
45812
45895
  if (!targets.length) return;
45813
45896
  const name2 = projectId ? (await prisma.project.findUnique({ where: { id: projectId }, select: { name: true } }))?.name ?? null : null;
45814
- const env = buildEnvelope(i, name2, (/* @__PURE__ */ new Date()).toISOString(), `evt_${randomUUID8().replace(/-/g, "")}`);
45897
+ const env = buildEnvelope(i, name2, (/* @__PURE__ */ new Date()).toISOString(), `evt_${randomUUID9().replace(/-/g, "")}`);
45815
45898
  if (!env) return;
45816
45899
  await enqueueEnvelope(env, targets);
45817
45900
  } catch (e) {
@@ -46161,6 +46244,7 @@ async function secureHanomanHome(opts) {
46161
46244
 
46162
46245
  // src/services/retention.ts
46163
46246
  init_db();
46247
+ init_uploads();
46164
46248
  var RETENTION_DAYS = {
46165
46249
  tickets: 90,
46166
46250
  newTickets: 180,
@@ -46275,6 +46359,7 @@ function startRetentionSweep() {
46275
46359
  }
46276
46360
 
46277
46361
  // src/server.ts
46362
+ init_uploads();
46278
46363
  var app = buildApp();
46279
46364
  var port = Number(process.env.PORT ?? 8787);
46280
46365
  var host = process.env.HOST ?? "127.0.0.1";