hanoman 0.1.42 → 0.1.44

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
@@ -6092,6 +6092,9 @@ var init_api = __esm({
6092
6092
  ideCompare: (id, from, to) => `${API}/projects/${id}/compare?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
6093
6093
  ideCompareFile: (id, from, to, path2) => `${API}/projects/${id}/compare/file?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&path=${encodeURIComponent(path2)}`,
6094
6094
  ideGit: (id) => `${API}/projects/${id}/git`,
6095
+ // ADR-0121 · operasi berkas Explorer: satu path untuk buat/rename/hapus, satu untuk unggah.
6096
+ ideEntry: (id, path2) => `${API}/projects/${id}/entry${path2 ? `?path=${encodeURIComponent(path2)}` : ""}`,
6097
+ ideUpload: (id) => `${API}/projects/${id}/upload`,
6095
6098
  ideGitMerge: (id) => `${API}/projects/${id}/git/merge`,
6096
6099
  // SPEC-229 · merge git graph isolasi
6097
6100
  // SPEC-234 · status working tree (staged/unstaged) + diff satu file working tree
@@ -6119,6 +6122,9 @@ var init_api = __esm({
6119
6122
  sessionReviewFile: (id, path2) => `${API}/terminal/sessions/${id}/review/${path2}`,
6120
6123
  sessionIntegrate: (id) => `${API}/terminal/sessions/${id}/integrate`,
6121
6124
  terminalWs: (id) => `${API}/terminal/sessions/${id}/ws`,
6125
+ // SPEC-816 · lampiran gambar sesi (multipart). Di bawah prefix /terminal supaya ikut capability
6126
+ // `sessions` yang sudah ada — POST menurunkan cabang tulisnya tanpa perubahan peta.
6127
+ terminalAttachments: (id) => `${API}/terminal/sessions/${id}/attachments`,
6122
6128
  // SPEC-362 · ADR-0079 · riwayat sesi. Di bawah prefix /terminal supaya ikut capability
6123
6129
  // `sessions` yang sudah ada (services/agent-capabilities.ts) tanpa menambah domain baru.
6124
6130
  sessionHistory: (qs = "") => `${API}/terminal/history${qs}`,
@@ -6263,11 +6269,11 @@ function isExternalLink(target2) {
6263
6269
  return !target2 || /^(https?:|#|mailto:)/.test(target2);
6264
6270
  }
6265
6271
  function resolveLink(fromRel, target2) {
6266
- const clean = target2.trim().split(/\s+/)[0].split("#")[0].split("\\").join("/");
6267
- if (!clean) return "";
6268
- if (clean.startsWith("/")) return clean.slice(1).split("/").filter((p) => p && p !== ".").join("/");
6272
+ const clean2 = target2.trim().split(/\s+/)[0].split("#")[0].split("\\").join("/");
6273
+ if (!clean2) return "";
6274
+ if (clean2.startsWith("/")) return clean2.slice(1).split("/").filter((p) => p && p !== ".").join("/");
6269
6275
  const dir2 = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
6270
- const parts = (dir2 ? dir2.split("/") : []).concat(clean.replace(/^\.\//, "").split("/"));
6276
+ const parts = (dir2 ? dir2.split("/") : []).concat(clean2.replace(/^\.\//, "").split("/"));
6271
6277
  const out3 = [];
6272
6278
  for (const p of parts) {
6273
6279
  if (p === "" || p === ".") continue;
@@ -9362,6 +9368,400 @@ var init_session_id = __esm({
9362
9368
  }
9363
9369
  });
9364
9370
 
9371
+ // src/services/secret-box.ts
9372
+ import { chmodSync as chmodSync2, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync } from "node:fs";
9373
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9374
+ import { dirname as dirname6, join as join4 } from "node:path";
9375
+ function isEncrypted(value) {
9376
+ return value.startsWith(ENC_PREFIX);
9377
+ }
9378
+ function encryptWithKey(plain, key) {
9379
+ const iv = randomBytes(IV_BYTES);
9380
+ const cipher = createCipheriv(ALGO, key, iv);
9381
+ const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
9382
+ const tag2 = cipher.getAuthTag();
9383
+ return `${ENC_PREFIX}${iv.toString("base64url")}:${tag2.toString("base64url")}:${body.toString("base64url")}`;
9384
+ }
9385
+ function decryptWithKey(value, key) {
9386
+ if (!isEncrypted(value)) return value;
9387
+ const parts = value.slice(ENC_PREFIX.length).split(":");
9388
+ if (parts.length !== 3) return null;
9389
+ const [ivRaw, tagRaw, bodyRaw] = parts;
9390
+ try {
9391
+ const decipher = createDecipheriv(ALGO, key, Buffer.from(ivRaw, "base64url"));
9392
+ decipher.setAuthTag(Buffer.from(tagRaw, "base64url"));
9393
+ return Buffer.concat([decipher.update(Buffer.from(bodyRaw, "base64url")), decipher.final()]).toString("utf8");
9394
+ } catch {
9395
+ return null;
9396
+ }
9397
+ }
9398
+ function fromEnv(raw2) {
9399
+ for (const enc2 of ["base64url", "base64", "hex"]) {
9400
+ const buf = Buffer.from(raw2, enc2);
9401
+ if (buf.length === KEY_BYTES) return buf;
9402
+ }
9403
+ return null;
9404
+ }
9405
+ function secretKeyPath() {
9406
+ return join4(resolveHome(), "secret.key");
9407
+ }
9408
+ function secretKey() {
9409
+ if (cached) return cached;
9410
+ const override = process.env.HANOMAN_SECRET_KEY?.trim();
9411
+ if (override) {
9412
+ const key2 = fromEnv(override);
9413
+ if (!key2) throw new Error(`HANOMAN_SECRET_KEY harus 32 byte (hex/base64), dapat ${override.length} karakter`);
9414
+ cached = key2;
9415
+ return key2;
9416
+ }
9417
+ const path2 = secretKeyPath();
9418
+ if (existsSync5(path2)) {
9419
+ const key2 = Buffer.from(readFileSync4(path2, "utf8").trim(), "base64url");
9420
+ if (key2.length !== KEY_BYTES) throw new Error(`kunci di ${path2} rusak \u2014 panjangnya bukan 32 byte`);
9421
+ cached = key2;
9422
+ return key2;
9423
+ }
9424
+ const key = randomBytes(KEY_BYTES);
9425
+ mkdirSync3(dirname6(path2), { recursive: true });
9426
+ writeFileSync(path2, key.toString("base64url"), { mode: 384 });
9427
+ chmodSync2(path2, 384);
9428
+ cached = key;
9429
+ return key;
9430
+ }
9431
+ function encryptSecret(plain) {
9432
+ return encryptWithKey(plain, secretKey());
9433
+ }
9434
+ function decryptSecret(value) {
9435
+ return decryptWithKey(value, secretKey());
9436
+ }
9437
+ var ENC_PREFIX, ALGO, IV_BYTES, KEY_BYTES, cached;
9438
+ var init_secret_box = __esm({
9439
+ "src/services/secret-box.ts"() {
9440
+ "use strict";
9441
+ init_src2();
9442
+ ENC_PREFIX = "enc:v1:";
9443
+ ALGO = "aes-256-gcm";
9444
+ IV_BYTES = 12;
9445
+ KEY_BYTES = 32;
9446
+ cached = null;
9447
+ }
9448
+ });
9449
+
9450
+ // src/config.ts
9451
+ var config_exports = {};
9452
+ __export(config_exports, {
9453
+ clearConfig: () => clearConfig,
9454
+ effectiveBool: () => effectiveBool,
9455
+ effectiveInt: () => effectiveInt,
9456
+ effectiveStr: () => effectiveStr,
9457
+ loadConfig: () => loadConfig,
9458
+ rawDbValue: () => rawDbValue,
9459
+ setConfig: () => setConfig,
9460
+ setConfigsAtomic: () => setConfigsAtomic,
9461
+ sourceOf: () => sourceOf
9462
+ });
9463
+ async function loadConfig() {
9464
+ const rows = await prisma.runtimeConfig.findMany();
9465
+ const next = /* @__PURE__ */ new Map();
9466
+ for (const r of rows) {
9467
+ if (!isSecret(r.key)) {
9468
+ next.set(r.key, r.value);
9469
+ continue;
9470
+ }
9471
+ const plain = decryptSecret(r.value);
9472
+ if (plain === null) {
9473
+ console.error(`config: nilai '${r.key}' tak bisa didekripsi \u2014 diabaikan`);
9474
+ continue;
9475
+ }
9476
+ next.set(r.key, plain);
9477
+ }
9478
+ cache = next;
9479
+ }
9480
+ function rawDbValue(key) {
9481
+ return cache.get(key);
9482
+ }
9483
+ function effectiveStr(key) {
9484
+ return cache.get(key) ?? process.env[key] ?? configEntry(key)?.default;
9485
+ }
9486
+ function effectiveInt(key) {
9487
+ const v = effectiveStr(key);
9488
+ return v === void 0 ? void 0 : Number(v);
9489
+ }
9490
+ function effectiveBool(key) {
9491
+ const v = effectiveStr(key);
9492
+ return v === "1" || v === "true";
9493
+ }
9494
+ function sourceOf(key) {
9495
+ if (cache.has(key)) return "db";
9496
+ if (process.env[key] !== void 0) return "env";
9497
+ return "default";
9498
+ }
9499
+ async function setConfig(key, value) {
9500
+ const stored = isSecret(key) ? encryptSecret(value) : value;
9501
+ await prisma.runtimeConfig.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored } });
9502
+ cache.set(key, value);
9503
+ }
9504
+ async function setConfigsAtomic(values) {
9505
+ await prisma.$transaction(Object.entries(values).map(([key, value]) => {
9506
+ const stored = isSecret(key) ? encryptSecret(value) : value;
9507
+ return prisma.runtimeConfig.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored } });
9508
+ }));
9509
+ for (const [key, value] of Object.entries(values)) cache.set(key, value);
9510
+ }
9511
+ async function clearConfig(key) {
9512
+ await prisma.runtimeConfig.deleteMany({ where: { key } });
9513
+ cache.delete(key);
9514
+ }
9515
+ var cache, isSecret;
9516
+ var init_config2 = __esm({
9517
+ "src/config.ts"() {
9518
+ "use strict";
9519
+ init_db();
9520
+ init_src();
9521
+ init_secret_box();
9522
+ cache = /* @__PURE__ */ new Map();
9523
+ isSecret = (key) => configEntry(key)?.kind === "secret";
9524
+ }
9525
+ });
9526
+
9527
+ // src/services/webhooks/ssrf.ts
9528
+ import { lookup as dnsLookup } from "node:dns/promises";
9529
+ import { isIPv4 } from "node:net";
9530
+ function validateWebhookUrl(raw2) {
9531
+ let url2;
9532
+ try {
9533
+ url2 = new URL(raw2.trim());
9534
+ } catch {
9535
+ return { ok: false, error: "URL tak valid" };
9536
+ }
9537
+ if (url2.protocol !== "http:" && url2.protocol !== "https:")
9538
+ return { ok: false, error: "hanya http atau https" };
9539
+ if (url2.username || url2.password)
9540
+ return { ok: false, error: "kredensial di URL tak diizinkan" };
9541
+ if (!url2.hostname) return { ok: false, error: "hostname kosong" };
9542
+ return { ok: true, url: url2 };
9543
+ }
9544
+ function blockedV4(ip) {
9545
+ const p = ip.split(".").map(Number);
9546
+ if (p.length !== 4 || p.some((n2) => !Number.isInteger(n2) || n2 < 0 || n2 > 255)) return true;
9547
+ const [a, b] = p;
9548
+ if (a === 0 || a === 127) return true;
9549
+ if (a === 10) return true;
9550
+ if (a === 172 && b >= 16 && b <= 31) return true;
9551
+ if (a === 192 && b === 168) return true;
9552
+ if (a === 169 && b === 254) return true;
9553
+ if (a === 100 && b >= 64 && b <= 127) return true;
9554
+ if (a === 192 && b === 0) return true;
9555
+ if (a >= 224) return true;
9556
+ return false;
9557
+ }
9558
+ function isBlockedAddress(ip) {
9559
+ const v = ip.trim().toLowerCase();
9560
+ if (!v) return true;
9561
+ const mapped = v.startsWith("::ffff:") ? v.slice(7) : v;
9562
+ if (isIPv4(mapped)) return blockedV4(mapped);
9563
+ if (!v.includes(":")) return true;
9564
+ if (v === "::" || v === "::1") return true;
9565
+ if (v.startsWith("fe80") || v.startsWith("fec0")) return true;
9566
+ if (/^f[cd]/.test(v)) return true;
9567
+ if (v.startsWith("ff")) return true;
9568
+ return false;
9569
+ }
9570
+ function checkUrlShape(raw2, allowPrivate) {
9571
+ const parsed = validateWebhookUrl(raw2);
9572
+ if (!parsed.ok) return parsed;
9573
+ if (allowPrivate) return parsed;
9574
+ const host2 = parsed.url.hostname.replace(/^\[|\]$/g, "");
9575
+ const literal = isIPv4(host2) || host2.includes(":");
9576
+ if (literal && isBlockedAddress(host2))
9577
+ return {
9578
+ ok: false,
9579
+ error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
9580
+ };
9581
+ if (host2 === "localhost" || host2.endsWith(".localhost"))
9582
+ return {
9583
+ ok: false,
9584
+ error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
9585
+ };
9586
+ return parsed;
9587
+ }
9588
+ async function checkDestination(url2, allowPrivate, lookup = defaultLookup) {
9589
+ if (allowPrivate) return { ok: true };
9590
+ const host2 = url2.hostname.replace(/^\[|\]$/g, "");
9591
+ let addrs;
9592
+ try {
9593
+ addrs = await lookup(host2);
9594
+ } catch (e) {
9595
+ return { ok: false, error: `DNS gagal: ${e.message}` };
9596
+ }
9597
+ if (!addrs.length) return { ok: false, error: "DNS tak mengembalikan alamat" };
9598
+ for (const a of addrs)
9599
+ if (isBlockedAddress(a.address))
9600
+ return {
9601
+ ok: false,
9602
+ error: `alamat internal ditolak (${a.address}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
9603
+ };
9604
+ return { ok: true };
9605
+ }
9606
+ var defaultLookup;
9607
+ var init_ssrf = __esm({
9608
+ "src/services/webhooks/ssrf.ts"() {
9609
+ "use strict";
9610
+ defaultLookup = (host2) => dnsLookup(host2, { all: true });
9611
+ }
9612
+ });
9613
+
9614
+ // src/services/safe-outbound-request.ts
9615
+ import { lookup as dnsLookup2 } from "node:dns/promises";
9616
+ import { request as httpRequest } from "node:http";
9617
+ import { request as httpsRequest } from "node:https";
9618
+ async function pinnedRequest(input) {
9619
+ return new Promise((resolve19, reject2) => {
9620
+ const transport = input.url.protocol === "https:" ? httpsRequest : httpRequest;
9621
+ const req = transport({
9622
+ protocol: input.url.protocol,
9623
+ hostname: input.url.hostname,
9624
+ port: input.url.port || void 0,
9625
+ method: input.method,
9626
+ path: `${input.url.pathname}${input.url.search}`,
9627
+ headers: input.headers,
9628
+ servername: input.url.hostname,
9629
+ // Node >= 20 menyalakan `autoSelectFamily` secara default: socket memanggil `lookup` dengan
9630
+ // `all: true` dan membaca `addresses[0].address` dari hasilnya. Menjawab dalam bentuk skalar
9631
+ // di situ memberi `undefined` → ERR_INVALID_IP_ADDRESS sebelum satu paket pun keluar, dan
9632
+ // pemanggil (sync tick, webhook) menelannya sebagai "offline". Kedua bentuk dijawab supaya
9633
+ // pinning tetap benar apa pun setelan family-nya.
9634
+ lookup: (_host, opts, callback) => opts.all ? callback(null, [{ address: input.address, family: input.family }]) : callback(null, input.address, input.family),
9635
+ timeout: input.connectMs
9636
+ }, (response) => {
9637
+ const chunks = [];
9638
+ let bytes = 0;
9639
+ response.on("data", (chunk) => {
9640
+ bytes += chunk.length;
9641
+ if (bytes > input.maxResponseBytes) {
9642
+ response.destroy(new Error("outbound response terlalu besar"));
9643
+ return;
9644
+ }
9645
+ chunks.push(chunk);
9646
+ });
9647
+ response.on("end", () => resolve19({
9648
+ status: response.statusCode ?? 0,
9649
+ headers: response.headers,
9650
+ body: Buffer.concat(chunks)
9651
+ }));
9652
+ });
9653
+ const timer9 = setTimeout(() => req.destroy(Object.assign(new Error("outbound timeout"), { name: "AbortError" })), input.totalMs);
9654
+ timer9.unref?.();
9655
+ req.once("close", () => clearTimeout(timer9));
9656
+ req.once("error", reject2);
9657
+ if (input.body) req.write(input.body);
9658
+ req.end();
9659
+ });
9660
+ }
9661
+ async function safeRequest(options2, deps = {}) {
9662
+ if (options2.url.protocol !== "http:" && options2.url.protocol !== "https:") throw new Error("outbound scheme ditolak");
9663
+ if (options2.url.username || options2.url.password) throw new Error("outbound credential URL ditolak");
9664
+ const addresses = await (deps.lookupAll ?? defaultLookup2)(options2.url.hostname);
9665
+ if (!addresses.length) throw new Error("DNS tak mengembalikan alamat");
9666
+ if (!options2.allowPrivate) {
9667
+ const blocked = addresses.find((row) => isBlockedAddress(row.address));
9668
+ if (blocked) throw new Error(`alamat internal ditolak (${blocked.address})`);
9669
+ }
9670
+ const selected = addresses[0];
9671
+ return (deps.request ?? pinnedRequest)({ ...options2, address: selected.address, family: selected.family });
9672
+ }
9673
+ var defaultLookup2;
9674
+ var init_safe_outbound_request = __esm({
9675
+ "src/services/safe-outbound-request.ts"() {
9676
+ "use strict";
9677
+ init_ssrf();
9678
+ defaultLookup2 = async (host2) => (await dnsLookup2(host2, { all: true })).map((row) => ({ address: row.address, family: row.family }));
9679
+ }
9680
+ });
9681
+
9682
+ // src/services/uploads.ts
9683
+ import { randomUUID } from "node:crypto";
9684
+ import { mkdir, writeFile, readFile as readFile2, unlink, rm } from "node:fs/promises";
9685
+ import { join as join5, resolve as resolve8 } from "node:path";
9686
+ function extFor(mimeType) {
9687
+ return EXT[mimeType] ?? ".bin";
9688
+ }
9689
+ function uploadDir() {
9690
+ return resolve8(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join5(resolveHome(), "uploads"));
9691
+ }
9692
+ function sessionUploadDir(sessionId2) {
9693
+ if (!SESSION_ID.test(sessionId2)) throw new Error(`sessionId tak sah: ${sessionId2}`);
9694
+ return join5(uploadDir(), "terminal", sessionId2);
9695
+ }
9696
+ async function saveSessionUpload(sessionId2, buf, mimeType) {
9697
+ const dir2 = sessionUploadDir(sessionId2);
9698
+ await mkdir(dir2, { recursive: true, mode: 448 });
9699
+ const path2 = join5(dir2, `${randomUUID()}${extFor(mimeType)}`);
9700
+ await writeFile(path2, buf, { mode: 384 });
9701
+ return { path: path2, size: buf.length };
9702
+ }
9703
+ async function dropSessionUploads(sessionId2) {
9704
+ let dir2;
9705
+ try {
9706
+ dir2 = sessionUploadDir(sessionId2);
9707
+ } catch {
9708
+ return;
9709
+ }
9710
+ await rm(dir2, { recursive: true, force: true }).catch(() => {
9711
+ });
9712
+ }
9713
+ async function readUpload(storageKey) {
9714
+ const safe = storageKey.replace(/[/\\]/g, "");
9715
+ return readFile2(join5(uploadDir(), safe));
9716
+ }
9717
+ async function readUploadOrFetch(storageKey) {
9718
+ const safe = storageKey.replace(/[/\\]/g, "");
9719
+ const target2 = join5(uploadDir(), safe);
9720
+ try {
9721
+ return await readFile2(target2);
9722
+ } catch {
9723
+ const base2 = effectiveStr("SYNC_SERVER_URL");
9724
+ const token = effectiveStr("SYNC_DEVICE_TOKEN");
9725
+ if (!base2 || !token) throw new Error(`lampiran ${safe} tak ada lokal & bukan client sync`);
9726
+ const url2 = new URL(`/api/sync/attachments/${encodeURIComponent(safe)}`, `${base2.replace(/\/$/, "")}/`);
9727
+ const loopback = ["localhost", "127.0.0.1", "::1"].includes(url2.hostname);
9728
+ const res = await safeRequest({
9729
+ url: url2,
9730
+ method: "GET",
9731
+ headers: { authorization: `Bearer ${token}` },
9732
+ allowPrivate: process.env.NODE_ENV !== "production" && loopback,
9733
+ connectMs: 5e3,
9734
+ totalMs: 15e3,
9735
+ maxResponseBytes: 5 * 1024 * 1024
9736
+ });
9737
+ if (res.status < 200 || res.status >= 300) throw new Error(`fetch lampiran hub gagal: ${res.status}`);
9738
+ const buf = res.body;
9739
+ await mkdir(uploadDir(), { recursive: true, mode: 448 });
9740
+ await writeFile(target2, buf, { mode: 384 });
9741
+ return buf;
9742
+ }
9743
+ }
9744
+ async function deleteUpload(storageKey) {
9745
+ const safe = storageKey.replace(/[/\\]/g, "");
9746
+ await unlink(join5(uploadDir(), safe)).catch(() => {
9747
+ });
9748
+ }
9749
+ var EXT, SESSION_ID;
9750
+ var init_uploads = __esm({
9751
+ "src/services/uploads.ts"() {
9752
+ "use strict";
9753
+ init_config2();
9754
+ init_safe_outbound_request();
9755
+ init_src2();
9756
+ EXT = {
9757
+ "image/png": ".png",
9758
+ "image/jpeg": ".jpg",
9759
+ "image/webp": ".webp"
9760
+ };
9761
+ SESSION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
9762
+ }
9763
+ });
9764
+
9365
9765
  // src/services/tui-dialog.ts
9366
9766
  function readChoiceDialog(paneText2) {
9367
9767
  const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
@@ -9594,162 +9994,6 @@ var init_tui_dialog = __esm({
9594
9994
  }
9595
9995
  });
9596
9996
 
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
9997
  // src/services/session-sandbox.ts
9754
9998
  function assertRuntimeBoundary(env, runtime) {
9755
9999
  if (env.NODE_ENV !== "production") return;
@@ -9836,7 +10080,7 @@ var init_session_sandbox = __esm({
9836
10080
  import { spawn } from "node-pty";
9837
10081
  import { execFileSync } from "node:child_process";
9838
10082
  import { createRequire } from "node:module";
9839
- import { randomUUID } from "node:crypto";
10083
+ import { randomUUID as randomUUID2 } from "node:crypto";
9840
10084
  import { mkdirSync as mkdirSync4, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
9841
10085
  import { dirname as dirname7 } from "node:path";
9842
10086
  import { tmpdir } from "node:os";
@@ -10283,6 +10527,8 @@ function killSession(id) {
10283
10527
  drop(id);
10284
10528
  tmux("kill-session", "-t", name(id));
10285
10529
  emitDeath({ sessionId: id, exitCode: p.exited ? p.code : null, transcript });
10530
+ void dropSessionUploads(id).catch(() => {
10531
+ });
10286
10532
  return true;
10287
10533
  }
10288
10534
  function detachAll() {
@@ -10313,6 +10559,7 @@ var init_pty = __esm({
10313
10559
  init_src();
10314
10560
  init_session_phases();
10315
10561
  init_session_id();
10562
+ init_uploads();
10316
10563
  init_tui_dialog();
10317
10564
  init_config2();
10318
10565
  init_session_sandbox();
@@ -10348,7 +10595,7 @@ var init_pty = __esm({
10348
10595
  }
10349
10596
  };
10350
10597
  sq = (s2) => `'${s2.split("'").join("'\\''")}'`;
10351
- idFor = (specId) => specId ? sessionIdForSpec(specId) : randomUUID().slice(0, 8);
10598
+ idFor = (specId) => specId ? sessionIdForSpec(specId) : randomUUID2().slice(0, 8);
10352
10599
  FMT = [
10353
10600
  "#{session_name}",
10354
10601
  "#{@hanoman_project}",
@@ -11382,9 +11629,9 @@ var init_config3 = __esm({
11382
11629
  // src/services/telegram/protocol.ts
11383
11630
  import { createHash as createHash2 } from "node:crypto";
11384
11631
  function normalizeTelegramCommand(text) {
11385
- const clean = text.trim();
11386
- const match = clean.match(/^\/([a-z0-9_]+)(?:@[a-z0-9_]+)?(?:\s+([\s\S]*))?$/i);
11387
- if (!match) return clean;
11632
+ const clean2 = text.trim();
11633
+ const match = clean2.match(/^\/([a-z0-9_]+)(?:@[a-z0-9_]+)?(?:\s+([\s\S]*))?$/i);
11634
+ if (!match) return clean2;
11388
11635
  const args = match[2]?.trim().replace(/\s+/g, " ");
11389
11636
  return `/${match[1].toLowerCase()}${args ? ` ${args}` : ""}`;
11390
11637
  }
@@ -11450,19 +11697,19 @@ function inboundDigest(input) {
11450
11697
  return createHash2("sha256").update(`${input.updateId}\0${input.chatId}\0${input.userId}\0${input.kind}\0${input.text}`, "utf8").digest("hex");
11451
11698
  }
11452
11699
  function sanitizeTelegramOutput(text, exactSecrets = []) {
11453
- let clean = text.replace(ANSI, "").replace(CONTROLS, "");
11700
+ let clean2 = text.replace(ANSI, "").replace(CONTROLS, "");
11454
11701
  for (const secret of [...exactSecrets].filter(Boolean).sort((a, b) => b.length - a.length)) {
11455
- clean = clean.split(secret).join("[REDACTED]");
11702
+ clean2 = clean2.split(secret).join("[REDACTED]");
11456
11703
  }
11457
- for (const pattern of CREDENTIAL_PATTERNS) clean = clean.replace(pattern, "[REDACTED]");
11458
- return clean.trim();
11704
+ for (const pattern of CREDENTIAL_PATTERNS) clean2 = clean2.replace(pattern, "[REDACTED]");
11705
+ return clean2.trim();
11459
11706
  }
11460
11707
  function splitTelegramText(text, max = 4096, exactSecrets = []) {
11461
11708
  if (!Number.isInteger(max) || max < 1) throw new Error("max harus integer positif");
11462
- const clean = sanitizeTelegramOutput(text, exactSecrets);
11463
- if (!clean) return [];
11709
+ const clean2 = sanitizeTelegramOutput(text, exactSecrets);
11710
+ if (!clean2) return [];
11464
11711
  const chunks = [];
11465
- let rest = clean;
11712
+ let rest = clean2;
11466
11713
  while (rest.length > max) {
11467
11714
  let cut = max;
11468
11715
  const before2 = rest.charCodeAt(cut - 1);
@@ -12584,161 +12831,6 @@ var init_conflicts = __esm({
12584
12831
  }
12585
12832
  });
12586
12833
 
12587
- // src/services/webhooks/ssrf.ts
12588
- import { lookup as dnsLookup } from "node:dns/promises";
12589
- import { isIPv4 } from "node:net";
12590
- function validateWebhookUrl(raw2) {
12591
- let url2;
12592
- try {
12593
- url2 = new URL(raw2.trim());
12594
- } catch {
12595
- return { ok: false, error: "URL tak valid" };
12596
- }
12597
- if (url2.protocol !== "http:" && url2.protocol !== "https:")
12598
- return { ok: false, error: "hanya http atau https" };
12599
- if (url2.username || url2.password)
12600
- return { ok: false, error: "kredensial di URL tak diizinkan" };
12601
- if (!url2.hostname) return { ok: false, error: "hostname kosong" };
12602
- return { ok: true, url: url2 };
12603
- }
12604
- function blockedV4(ip) {
12605
- const p = ip.split(".").map(Number);
12606
- if (p.length !== 4 || p.some((n2) => !Number.isInteger(n2) || n2 < 0 || n2 > 255)) return true;
12607
- const [a, b] = p;
12608
- if (a === 0 || a === 127) return true;
12609
- if (a === 10) return true;
12610
- if (a === 172 && b >= 16 && b <= 31) return true;
12611
- if (a === 192 && b === 168) return true;
12612
- if (a === 169 && b === 254) return true;
12613
- if (a === 100 && b >= 64 && b <= 127) return true;
12614
- if (a === 192 && b === 0) return true;
12615
- if (a >= 224) return true;
12616
- return false;
12617
- }
12618
- function isBlockedAddress(ip) {
12619
- const v = ip.trim().toLowerCase();
12620
- if (!v) return true;
12621
- const mapped = v.startsWith("::ffff:") ? v.slice(7) : v;
12622
- if (isIPv4(mapped)) return blockedV4(mapped);
12623
- if (!v.includes(":")) return true;
12624
- if (v === "::" || v === "::1") return true;
12625
- if (v.startsWith("fe80") || v.startsWith("fec0")) return true;
12626
- if (/^f[cd]/.test(v)) return true;
12627
- if (v.startsWith("ff")) return true;
12628
- return false;
12629
- }
12630
- function checkUrlShape(raw2, allowPrivate) {
12631
- const parsed = validateWebhookUrl(raw2);
12632
- if (!parsed.ok) return parsed;
12633
- if (allowPrivate) return parsed;
12634
- const host2 = parsed.url.hostname.replace(/^\[|\]$/g, "");
12635
- const literal = isIPv4(host2) || host2.includes(":");
12636
- if (literal && isBlockedAddress(host2))
12637
- return {
12638
- ok: false,
12639
- error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
12640
- };
12641
- if (host2 === "localhost" || host2.endsWith(".localhost"))
12642
- return {
12643
- ok: false,
12644
- error: `alamat internal ditolak (${host2}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
12645
- };
12646
- return parsed;
12647
- }
12648
- async function checkDestination(url2, allowPrivate, lookup = defaultLookup) {
12649
- if (allowPrivate) return { ok: true };
12650
- const host2 = url2.hostname.replace(/^\[|\]$/g, "");
12651
- let addrs;
12652
- try {
12653
- addrs = await lookup(host2);
12654
- } catch (e) {
12655
- return { ok: false, error: `DNS gagal: ${e.message}` };
12656
- }
12657
- if (!addrs.length) return { ok: false, error: "DNS tak mengembalikan alamat" };
12658
- for (const a of addrs)
12659
- if (isBlockedAddress(a.address))
12660
- return {
12661
- ok: false,
12662
- error: `alamat internal ditolak (${a.address}) \u2014 nyalakan "izinkan alamat internal" bila memang disengaja`
12663
- };
12664
- return { ok: true };
12665
- }
12666
- var defaultLookup;
12667
- var init_ssrf = __esm({
12668
- "src/services/webhooks/ssrf.ts"() {
12669
- "use strict";
12670
- defaultLookup = (host2) => dnsLookup(host2, { all: true });
12671
- }
12672
- });
12673
-
12674
- // src/services/safe-outbound-request.ts
12675
- import { lookup as dnsLookup2 } from "node:dns/promises";
12676
- import { request as httpRequest } from "node:http";
12677
- import { request as httpsRequest } from "node:https";
12678
- async function pinnedRequest(input) {
12679
- return new Promise((resolve19, reject2) => {
12680
- const transport = input.url.protocol === "https:" ? httpsRequest : httpRequest;
12681
- const req = transport({
12682
- protocol: input.url.protocol,
12683
- hostname: input.url.hostname,
12684
- port: input.url.port || void 0,
12685
- method: input.method,
12686
- path: `${input.url.pathname}${input.url.search}`,
12687
- headers: input.headers,
12688
- servername: input.url.hostname,
12689
- // Node >= 20 menyalakan `autoSelectFamily` secara default: socket memanggil `lookup` dengan
12690
- // `all: true` dan membaca `addresses[0].address` dari hasilnya. Menjawab dalam bentuk skalar
12691
- // di situ memberi `undefined` → ERR_INVALID_IP_ADDRESS sebelum satu paket pun keluar, dan
12692
- // pemanggil (sync tick, webhook) menelannya sebagai "offline". Kedua bentuk dijawab supaya
12693
- // pinning tetap benar apa pun setelan family-nya.
12694
- lookup: (_host, opts, callback) => opts.all ? callback(null, [{ address: input.address, family: input.family }]) : callback(null, input.address, input.family),
12695
- timeout: input.connectMs
12696
- }, (response) => {
12697
- const chunks = [];
12698
- let bytes = 0;
12699
- response.on("data", (chunk) => {
12700
- bytes += chunk.length;
12701
- if (bytes > input.maxResponseBytes) {
12702
- response.destroy(new Error("outbound response terlalu besar"));
12703
- return;
12704
- }
12705
- chunks.push(chunk);
12706
- });
12707
- response.on("end", () => resolve19({
12708
- status: response.statusCode ?? 0,
12709
- headers: response.headers,
12710
- body: Buffer.concat(chunks)
12711
- }));
12712
- });
12713
- const timer9 = setTimeout(() => req.destroy(Object.assign(new Error("outbound timeout"), { name: "AbortError" })), input.totalMs);
12714
- timer9.unref?.();
12715
- req.once("close", () => clearTimeout(timer9));
12716
- req.once("error", reject2);
12717
- if (input.body) req.write(input.body);
12718
- req.end();
12719
- });
12720
- }
12721
- async function safeRequest(options2, deps = {}) {
12722
- if (options2.url.protocol !== "http:" && options2.url.protocol !== "https:") throw new Error("outbound scheme ditolak");
12723
- if (options2.url.username || options2.url.password) throw new Error("outbound credential URL ditolak");
12724
- const addresses = await (deps.lookupAll ?? defaultLookup2)(options2.url.hostname);
12725
- if (!addresses.length) throw new Error("DNS tak mengembalikan alamat");
12726
- if (!options2.allowPrivate) {
12727
- const blocked = addresses.find((row) => isBlockedAddress(row.address));
12728
- if (blocked) throw new Error(`alamat internal ditolak (${blocked.address})`);
12729
- }
12730
- const selected = addresses[0];
12731
- return (deps.request ?? pinnedRequest)({ ...options2, address: selected.address, family: selected.family });
12732
- }
12733
- var defaultLookup2;
12734
- var init_safe_outbound_request = __esm({
12735
- "src/services/safe-outbound-request.ts"() {
12736
- "use strict";
12737
- init_ssrf();
12738
- defaultLookup2 = async (host2) => (await dnsLookup2(host2, { all: true })).map((row) => ({ address: row.address, family: row.family }));
12739
- }
12740
- });
12741
-
12742
12834
  // ../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js
12743
12835
  var require_constants = __commonJS({
12744
12836
  "../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js"(exports, module) {
@@ -18469,11 +18561,11 @@ var require_clean = __commonJS({
18469
18561
  "../node_modules/.pnpm/semver@7.8.5/node_modules/semver/functions/clean.js"(exports, module) {
18470
18562
  "use strict";
18471
18563
  var parse3 = require_parse();
18472
- var clean = (version2, options2) => {
18564
+ var clean2 = (version2, options2) => {
18473
18565
  const s2 = parse3(version2.trim().replace(/^[=v]+/, ""), options2);
18474
18566
  return s2 ? s2.version : null;
18475
18567
  };
18476
- module.exports = clean;
18568
+ module.exports = clean2;
18477
18569
  }
18478
18570
  });
18479
18571
 
@@ -19870,7 +19962,7 @@ var require_semver2 = __commonJS({
19870
19962
  var identifiers = require_identifiers();
19871
19963
  var parse3 = require_parse();
19872
19964
  var valid = require_valid();
19873
- var clean = require_clean();
19965
+ var clean2 = require_clean();
19874
19966
  var inc = require_inc();
19875
19967
  var diff = require_diff();
19876
19968
  var major = require_major();
@@ -19909,7 +20001,7 @@ var require_semver2 = __commonJS({
19909
20001
  module.exports = {
19910
20002
  parse: parse3,
19911
20003
  valid,
19912
- clean,
20004
+ clean: clean2,
19913
20005
  inc,
19914
20006
  diff,
19915
20007
  major,
@@ -24267,7 +24359,7 @@ var require_multipart2 = __commonJS({
24267
24359
  var Busboy = require_main();
24268
24360
  var os = __require("node:os");
24269
24361
  var fp = require_fastify_plugin();
24270
- var { createWriteStream } = __require("node:fs");
24362
+ var { createWriteStream: createWriteStream2 } = __require("node:fs");
24271
24363
  var { unlink: unlink6 } = __require("node:fs/promises");
24272
24364
  var path2 = __require("node:path");
24273
24365
  var { generateId } = require_generateId();
@@ -24647,7 +24739,7 @@ var require_multipart2 = __commonJS({
24647
24739
  continue;
24648
24740
  }
24649
24741
  const filepath = path2.join(tmpdir5, generateId() + path2.extname(part.filename || "file" + i++));
24650
- const target2 = createWriteStream(filepath);
24742
+ const target2 = createWriteStream2(filepath);
24651
24743
  try {
24652
24744
  this.tmpUploads.push(filepath);
24653
24745
  await pump2(part.file, target2);
@@ -24824,13 +24916,13 @@ init_src();
24824
24916
  import { execFile } from "node:child_process";
24825
24917
  import { promisify } from "node:util";
24826
24918
  import { existsSync as existsSync6, rmSync as rmSync2 } from "node:fs";
24827
- import { resolve as resolve9 } from "node:path";
24919
+ import { resolve as resolve10 } from "node:path";
24828
24920
 
24829
24921
  // src/services/safe-repo-path.ts
24830
24922
  import { constants } from "node:fs";
24831
- import { lstat, mkdir, open as open2, realpath, rename, unlink } from "node:fs/promises";
24832
- import { isAbsolute as isAbsolute3, join as join5, relative, resolve as resolve8, sep as sep2 } from "node:path";
24833
- import { randomUUID as randomUUID2 } from "node:crypto";
24923
+ import { lstat, mkdir as mkdir2, open as open2, realpath, rename, unlink as unlink2 } from "node:fs/promises";
24924
+ import { isAbsolute as isAbsolute3, join as join6, relative, resolve as resolve9, sep as sep2 } from "node:path";
24925
+ import { randomUUID as randomUUID3 } from "node:crypto";
24834
24926
  import { closeSync, fstatSync, lstatSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync5, realpathSync as realpathSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
24835
24927
  var PathContainmentError = class extends Error {
24836
24928
  code = "PATH_CONTAINMENT";
@@ -24859,9 +24951,9 @@ async function ensureRepoParents(root, rel) {
24859
24951
  const parts = components(rel).slice(0, -1);
24860
24952
  let current = base2;
24861
24953
  for (const part of parts) {
24862
- current = join5(current, part);
24954
+ current = join6(current, part);
24863
24955
  try {
24864
- await mkdir(current, { mode: 448 });
24956
+ await mkdir2(current, { mode: 448 });
24865
24957
  } catch (error) {
24866
24958
  if (error.code !== "EEXIST") throw error;
24867
24959
  }
@@ -24876,7 +24968,7 @@ function ensureRepoParentsSync(root, rel) {
24876
24968
  const parts = components(rel).slice(0, -1);
24877
24969
  let current = base2;
24878
24970
  for (const part of parts) {
24879
- current = join5(current, part);
24971
+ current = join6(current, part);
24880
24972
  try {
24881
24973
  mkdirSync5(current, { mode: 448 });
24882
24974
  } catch (error) {
@@ -24892,8 +24984,9 @@ async function resolveRepoEntry(root, rel, opts = {}) {
24892
24984
  const parts = components(rel);
24893
24985
  let current = base2;
24894
24986
  for (let i = 0; i < parts.length; i++) {
24895
- current = join5(current, parts[i]);
24987
+ current = join6(current, parts[i]);
24896
24988
  const stat2 = await lstat(current).catch((error) => {
24989
+ if (error.code === "ENOENT" && opts.allowMissingTail) return null;
24897
24990
  if (error.code === "ENOENT" && opts.allowMissingFinal && i === parts.length - 1) return null;
24898
24991
  return denied("komponen tidak ada");
24899
24992
  });
@@ -24901,8 +24994,8 @@ async function resolveRepoEntry(root, rel, opts = {}) {
24901
24994
  if (stat2.isSymbolicLink()) denied("symlink");
24902
24995
  if (i < parts.length - 1 && !stat2.isDirectory()) denied("parent bukan direktori");
24903
24996
  }
24904
- if (!beneath(base2, resolve8(current))) denied("keluar root");
24905
- const parent = resolve8(current, "..");
24997
+ if (!beneath(base2, resolve9(current))) denied("keluar root");
24998
+ const parent = resolve9(current, "..");
24906
24999
  const parentReal = await realpath(parent).catch(() => denied("parent tidak ada"));
24907
25000
  if (!beneath(base2, parentReal)) denied("parent keluar root");
24908
25001
  return { root: base2, path: current, parent: parentReal };
@@ -24912,7 +25005,7 @@ function assertSafeRepoPathSync(root, rel, allowMissingFinal = false, allowMissi
24912
25005
  const parts = components(rel);
24913
25006
  let current = base2;
24914
25007
  for (let i = 0; i < parts.length; i++) {
24915
- current = join5(current, parts[i]);
25008
+ current = join6(current, parts[i]);
24916
25009
  try {
24917
25010
  const stat2 = lstatSync(current);
24918
25011
  if (stat2.isSymbolicLink()) denied("symlink");
@@ -24923,7 +25016,7 @@ function assertSafeRepoPathSync(root, rel, allowMissingFinal = false, allowMissi
24923
25016
  throw error;
24924
25017
  }
24925
25018
  }
24926
- if (!beneath(base2, resolve8(current))) denied("keluar root");
25019
+ if (!beneath(base2, resolve9(current))) denied("keluar root");
24927
25020
  return current;
24928
25021
  }
24929
25022
  function readRepoFileSync(root, rel) {
@@ -24939,10 +25032,10 @@ function readRepoFileSync(root, rel) {
24939
25032
  function writeRepoFileAtomicSync(root, rel, data) {
24940
25033
  ensureRepoParentsSync(root, rel);
24941
25034
  const path2 = assertSafeRepoPathSync(root, rel, true);
24942
- const parent = resolve8(path2, "..");
25035
+ const parent = resolve9(path2, "..");
24943
25036
  const parentRel = relative(realpathSync2(root), parent);
24944
25037
  if (parentRel) assertSafeRepoPathSync(root, parentRel);
24945
- const temp = join5(parent, `.hanoman-${randomUUID2()}.tmp`);
25038
+ const temp = join6(parent, `.hanoman-${randomUUID3()}.tmp`);
24946
25039
  const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 384);
24947
25040
  try {
24948
25041
  writeFileSync3(fd, data);
@@ -24986,10 +25079,10 @@ async function writeRepoFileAtomic(root, rel, data) {
24986
25079
  return denied("final component tak terbaca");
24987
25080
  });
24988
25081
  if (current?.isSymbolicLink() || current && !current.isFile()) denied("target bukan file regular");
24989
- await mkdir(entry.parent, { recursive: false, mode: 448 }).catch((error) => {
25082
+ await mkdir2(entry.parent, { recursive: false, mode: 448 }).catch((error) => {
24990
25083
  if (error.code !== "EEXIST") throw error;
24991
25084
  });
24992
- const temp = join5(entry.parent, `.hanoman-${randomUUID2()}.tmp`);
25085
+ const temp = join6(entry.parent, `.hanoman-${randomUUID3()}.tmp`);
24993
25086
  const handle = await open2(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 384);
24994
25087
  try {
24995
25088
  await handle.writeFile(data);
@@ -25004,7 +25097,7 @@ async function writeRepoFileAtomic(root, rel, data) {
25004
25097
  if (finalAgain?.isSymbolicLink() || finalAgain && !finalAgain.isFile()) denied("target berubah");
25005
25098
  await rename(temp, entry.path);
25006
25099
  } catch (error) {
25007
- await unlink(temp).catch(() => {
25100
+ await unlink2(temp).catch(() => {
25008
25101
  });
25009
25102
  throw error;
25010
25103
  }
@@ -25034,7 +25127,7 @@ function docsDirOf(repoDir) {
25034
25127
  }
25035
25128
  function resolveIndex(repoDir, docsDir) {
25036
25129
  const rel = `${docsDir}/README.md`;
25037
- return existsSync6(resolve9(repoDir, rel)) ? rel : "";
25130
+ return existsSync6(resolve10(repoDir, rel)) ? rel : "";
25038
25131
  }
25039
25132
  var catOf = (rel) => rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ".";
25040
25133
  var nameOf = (rel) => rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
@@ -25411,7 +25504,7 @@ import { existsSync as existsSync7 } from "node:fs";
25411
25504
  import { execFile as execFile3 } from "node:child_process";
25412
25505
  import { promisify as promisify3 } from "node:util";
25413
25506
  import { rmSync as rmSync3 } from "node:fs";
25414
- import { join as join6 } from "node:path";
25507
+ import { join as join7 } from "node:path";
25415
25508
  var sanitize = (id) => id.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25416
25509
  var sourceBranch = (specId) => `hanoman/${sanitize(specId)}`;
25417
25510
  var exec3 = promisify3(execFile3);
@@ -25455,7 +25548,7 @@ async function integrateBranch(repoDir, src, op, target2) {
25455
25548
  const tgt = await resolveTarget(repoDir, target2);
25456
25549
  if (!tgt) return { status: "error", code: 400, error: `target "${target2}" tidak dikenal` };
25457
25550
  await sh(repoDir, ["fetch", "origin"]);
25458
- const wt = join6(repoDir, ".worktrees", `merge-${sanitize(src.mergeId)}`);
25551
+ const wt = join7(repoDir, ".worktrees", `merge-${sanitize(src.mergeId)}`);
25459
25552
  await reclaim(repoDir, wt);
25460
25553
  const baseRef = op === "merge" ? tgt.ref : source;
25461
25554
  const baseSha = await out(repoDir, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]);
@@ -25516,7 +25609,7 @@ async function mergeIntoCurrent(repoDir, source, opts = {}) {
25516
25609
  const src = await resolveGraphSource(repoDir, source);
25517
25610
  if (!src) return { status: "error", code: 400, error: `source "${source}" tak dikenal` };
25518
25611
  await sh(repoDir, ["fetch", "origin"]);
25519
- const wt = join6(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25612
+ const wt = join7(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25520
25613
  await reclaim(repoDir, wt);
25521
25614
  const baseSha = await out(repoDir, ["rev-parse", "--verify", "--end-of-options", `refs/heads/${current}^{commit}`]);
25522
25615
  if (!await ok(repoDir, ["worktree", "add", "--detach", "-q", wt, baseSha]))
@@ -25554,7 +25647,7 @@ async function replayCurrent(repoDir, source, cmd) {
25554
25647
  const current = await out(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
25555
25648
  if (!current || current === "HEAD")
25556
25649
  return { status: "error", code: 409, error: "HEAD detached \u2014 checkout sebuah branch dulu" };
25557
- const wt = join6(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25650
+ const wt = join7(repoDir, ".worktrees", `merge-${sanitize(current)}`);
25558
25651
  await reclaim(repoDir, wt);
25559
25652
  const baseSha = await out(repoDir, ["rev-parse", "--verify", "--end-of-options", `refs/heads/${current}^{commit}`]);
25560
25653
  if (!await ok(repoDir, ["worktree", "add", "--detach", "-q", wt, baseSha]))
@@ -25601,24 +25694,24 @@ init_db();
25601
25694
  // src/services/spec-review.ts
25602
25695
  import { execFile as execFile4 } from "node:child_process";
25603
25696
  import { promisify as promisify4 } from "node:util";
25604
- import { mkdtemp, copyFile, rm } from "node:fs/promises";
25697
+ import { mkdtemp, copyFile, rm as rm2 } from "node:fs/promises";
25605
25698
  import { tmpdir as tmpdir2 } from "node:os";
25606
- import { join as join7, resolve as resolve10 } from "node:path";
25699
+ import { join as join8, resolve as resolve11 } from "node:path";
25607
25700
  var exec4 = promisify4(execFile4);
25608
25701
  var GIT3 = { maxBuffer: 1 << 24 };
25609
25702
  var MAX = 256 * 1024;
25610
- var worktreeDir = (repoDir, specId) => join7(repoDir, ".worktrees", specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_"));
25703
+ var worktreeDir = (repoDir, specId) => join8(repoDir, ".worktrees", specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_"));
25611
25704
  async function withTempIndex(wt, fn2) {
25612
25705
  const idx = (await exec4("git", ["rev-parse", "--git-path", "index"], { cwd: wt, ...GIT3 })).stdout.trim();
25613
- const dir2 = await mkdtemp(join7(tmpdir2(), "hanoman-idx-"));
25614
- const tmp = join7(dir2, "index");
25615
- await copyFile(resolve10(wt, idx), tmp);
25706
+ const dir2 = await mkdtemp(join8(tmpdir2(), "hanoman-idx-"));
25707
+ const tmp = join8(dir2, "index");
25708
+ await copyFile(resolve11(wt, idx), tmp);
25616
25709
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
25617
25710
  try {
25618
25711
  await exec4("git", ["add", "-A", "-N"], { cwd: wt, env, ...GIT3 });
25619
25712
  return await fn2(env);
25620
25713
  } finally {
25621
- await rm(dir2, { recursive: true, force: true });
25714
+ await rm2(dir2, { recursive: true, force: true });
25622
25715
  }
25623
25716
  }
25624
25717
  var splitZ = (s2) => s2.split("\0").filter(Boolean);
@@ -25822,7 +25915,7 @@ init_notifications();
25822
25915
 
25823
25916
  // src/services/session-result.ts
25824
25917
  init_db();
25825
- import { randomUUID as randomUUID3 } from "node:crypto";
25918
+ import { randomUUID as randomUUID4 } from "node:crypto";
25826
25919
  var WHITELIST = [
25827
25920
  "projectId",
25828
25921
  "specId",
@@ -25836,7 +25929,7 @@ var WHITELIST = [
25836
25929
  "author"
25837
25930
  ];
25838
25931
  async function recordSessionResult(input) {
25839
- const id = randomUUID3();
25932
+ const id = randomUUID4();
25840
25933
  const data = { id };
25841
25934
  for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
25842
25935
  if (data.status === void 0) data.status = "done";
@@ -29185,7 +29278,7 @@ import { promisify as promisify7 } from "node:util";
29185
29278
  import { execFile as execFile6 } from "node:child_process";
29186
29279
  import { promisify as promisify6 } from "node:util";
29187
29280
  import { existsSync as existsSync9 } from "node:fs";
29188
- import { resolve as resolve11 } from "node:path";
29281
+ import { resolve as resolve12 } from "node:path";
29189
29282
  var exec6 = promisify6(execFile6);
29190
29283
  var GIT5 = { maxBuffer: 1 << 24 };
29191
29284
  var MAX2 = 256 * 1024;
@@ -29193,7 +29286,7 @@ var splitZ2 = (s2) => s2.split("\0").filter(Boolean);
29193
29286
  function repoAbsPath(repoDir, rel, allowMissingFinal = false) {
29194
29287
  if (rel.split(/[\\/]/).includes(".git")) throw new Error("tidak boleh menyentuh .git");
29195
29288
  assertSafeRepoPathSync(repoDir, rel, allowMissingFinal, true);
29196
- return resolve11(repoDir, rel);
29289
+ return resolve12(repoDir, rel);
29197
29290
  }
29198
29291
  async function listRepoTree(repoDir, ref = "") {
29199
29292
  if (!repoDir || !existsSync9(repoDir)) return [];
@@ -29284,10 +29377,10 @@ async function listGraph(repoDir, limit = 200, opts = {}) {
29284
29377
  const [sha, parents, author, at, subject, refs2] = line.split(US);
29285
29378
  const tags = [], branchRefs = [];
29286
29379
  for (const d of (refs2 ?? "").split(",").map((r) => r.trim()).filter(Boolean)) {
29287
- const clean = d.replace(/^HEAD -> /, "");
29288
- if (clean === "HEAD") continue;
29289
- if (clean.startsWith("tag: ")) tags.push(clean.slice(5));
29290
- else branchRefs.push(clean);
29380
+ const clean2 = d.replace(/^HEAD -> /, "");
29381
+ if (clean2 === "HEAD") continue;
29382
+ if (clean2.startsWith("tag: ")) tags.push(clean2.slice(5));
29383
+ else branchRefs.push(clean2);
29291
29384
  }
29292
29385
  return {
29293
29386
  sha,
@@ -29763,6 +29856,85 @@ async function deleteBranches(repoDir, names, opts) {
29763
29856
  return { base: report.base, results };
29764
29857
  }
29765
29858
 
29859
+ // src/services/repo-fs.ts
29860
+ import { lstat as lstat2, rename as rename2, rm as rm3 } from "node:fs/promises";
29861
+ import { createWriteStream } from "node:fs";
29862
+ import { join as join9 } from "node:path";
29863
+ import { randomUUID as randomUUID5 } from "node:crypto";
29864
+ import { pipeline } from "node:stream/promises";
29865
+ var EntryExistsError = class extends Error {
29866
+ code = "ENTRY_EXISTS";
29867
+ };
29868
+ var EntryMissingError = class extends Error {
29869
+ code = "ENTRY_MISSING";
29870
+ };
29871
+ var EntryTargetInsideError = class extends Error {
29872
+ code = "ENTRY_TARGET_INSIDE";
29873
+ };
29874
+ function assertNotGit(rel) {
29875
+ if (rel.split(/[\\/]/).includes(".git"))
29876
+ throw new PathContainmentError("repository path ditolak: tidak boleh menyentuh .git");
29877
+ }
29878
+ var clean = (s2) => s2.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
29879
+ function joinRel(dir2, name2) {
29880
+ const d = clean(dir2), n2 = clean(name2);
29881
+ if (!n2) throw new PathContainmentError("repository path ditolak: nama berkas kosong");
29882
+ return d ? `${d}/${n2}` : n2;
29883
+ }
29884
+ async function entryKind(repoDir, rel) {
29885
+ assertNotGit(rel);
29886
+ const { path: path2 } = await resolveRepoEntry(repoDir, rel, { allowMissingTail: true });
29887
+ const st = await lstat2(path2).catch(() => null);
29888
+ if (!st) return null;
29889
+ return st.isDirectory() ? "dir" : "file";
29890
+ }
29891
+ async function createEntry(repoDir, rel, kind) {
29892
+ if (await entryKind(repoDir, rel)) throw new EntryExistsError(`sudah ada: ${rel}`);
29893
+ await writeRepoFileAtomic(repoDir, kind === "dir" ? `${clean(rel)}/.gitkeep` : rel, "");
29894
+ return { path: rel };
29895
+ }
29896
+ async function renameEntry(repoDir, from, to) {
29897
+ const kind = await entryKind(repoDir, from);
29898
+ if (!kind) throw new EntryMissingError(`tidak ada: ${from}`);
29899
+ if (await entryKind(repoDir, to)) throw new EntryExistsError(`sudah ada: ${to}`);
29900
+ if (kind === "dir" && `${clean(to)}/`.startsWith(`${clean(from)}/`))
29901
+ throw new EntryTargetInsideError("tujuan di dalam sumber");
29902
+ await ensureRepoParents(repoDir, to);
29903
+ const src = await resolveRepoEntry(repoDir, from);
29904
+ const dst = await resolveRepoEntry(repoDir, to, { allowMissingFinal: true });
29905
+ await rename2(src.path, dst.path);
29906
+ return { from, to };
29907
+ }
29908
+ async function deleteEntry(repoDir, rel) {
29909
+ const kind = await entryKind(repoDir, rel);
29910
+ if (!kind) throw new EntryMissingError(`tidak ada: ${rel}`);
29911
+ const { path: path2 } = await resolveRepoEntry(repoDir, rel);
29912
+ await rm3(path2, { recursive: kind === "dir", force: false });
29913
+ return { path: rel, kind };
29914
+ }
29915
+ async function saveUpload(repoDir, rel, source, opts = {}) {
29916
+ assertNotGit(rel);
29917
+ await ensureRepoParents(repoDir, rel);
29918
+ const entry = await resolveRepoEntry(repoDir, rel, { allowMissingFinal: true });
29919
+ const current = await lstat2(entry.path).catch(() => null);
29920
+ if (current && !opts.overwrite) return { status: "exists" };
29921
+ if (current && !current.isFile())
29922
+ throw new PathContainmentError("repository path ditolak: target bukan file regular");
29923
+ const temp = join9(entry.parent, `.hanoman-${randomUUID5()}.tmp`);
29924
+ try {
29925
+ await pipeline(source, createWriteStream(temp, { flags: "wx", mode: 384 }));
29926
+ if (opts.isTruncated?.()) {
29927
+ await rm3(temp, { force: true });
29928
+ return { status: "too-large" };
29929
+ }
29930
+ await rename2(temp, entry.path);
29931
+ return { status: "written" };
29932
+ } catch (error) {
29933
+ await rm3(temp, { force: true });
29934
+ throw error;
29935
+ }
29936
+ }
29937
+
29766
29938
  // src/routes/ide.ts
29767
29939
  async function repoOf(id) {
29768
29940
  const p = await prisma.project.findUnique({ where: { id } });
@@ -29781,6 +29953,19 @@ async function lockInputs(id) {
29781
29953
  sessionBranches: new Set(sessions)
29782
29954
  };
29783
29955
  }
29956
+ function entryError(reply, e) {
29957
+ if (e instanceof EntryMissingError) return reply.code(404).send({ error: "not found" });
29958
+ if (e instanceof EntryExistsError) return reply.code(409).send({ error: "sudah ada" });
29959
+ if (e instanceof EntryTargetInsideError) return reply.code(400).send({ error: "tujuan di dalam sumber" });
29960
+ return reply.code(400).send({ error: e.message });
29961
+ }
29962
+ var UPLOAD_LIMITS = {
29963
+ fileSize: 100 * 1024 * 1024,
29964
+ files: 1e3,
29965
+ fields: 10,
29966
+ fieldSize: 1024 * 1024
29967
+ };
29968
+ var UPLOAD_TOTAL_MAX = 2 * 1024 * 1024 * 1024;
29784
29969
  async function ide_default(app2) {
29785
29970
  app2.get("/projects/:id/tree", async (req, reply) => {
29786
29971
  const repoDir = await repoOf(req.params.id);
@@ -29845,6 +30030,102 @@ async function ide_default(app2) {
29845
30030
  return reply.code(400).send({ error: e.message });
29846
30031
  }
29847
30032
  });
30033
+ app2.post("/projects/:id/entry", async (req, reply) => {
30034
+ const repoDir = await repoOf(req.params.id);
30035
+ if (repoDir === void 0) return reply.code(404).send({ error: "not found" });
30036
+ if (!repoDir) return reply.code(400).send({ error: "project tidak punya repoDir" });
30037
+ const b = req.body;
30038
+ if (!b?.path || typeof b.path !== "string") return reply.code(400).send({ error: "path wajib" });
30039
+ if (b.kind !== "file" && b.kind !== "dir") return reply.code(400).send({ error: "kind harus file atau dir" });
30040
+ try {
30041
+ return reply.code(201).send(await createEntry(repoDir, b.path, b.kind));
30042
+ } catch (e) {
30043
+ return entryError(reply, e);
30044
+ }
30045
+ });
30046
+ app2.patch("/projects/:id/entry", async (req, reply) => {
30047
+ const repoDir = await repoOf(req.params.id);
30048
+ if (repoDir === void 0) return reply.code(404).send({ error: "not found" });
30049
+ if (!repoDir) return reply.code(400).send({ error: "project tidak punya repoDir" });
30050
+ const b = req.body;
30051
+ if (!b?.from || !b?.to || typeof b.from !== "string" || typeof b.to !== "string")
30052
+ return reply.code(400).send({ error: "from & to wajib" });
30053
+ try {
30054
+ return await renameEntry(repoDir, b.from, b.to);
30055
+ } catch (e) {
30056
+ return entryError(reply, e);
30057
+ }
30058
+ });
30059
+ app2.delete("/projects/:id/entry", async (req, reply) => {
30060
+ const repoDir = await repoOf(req.params.id);
30061
+ if (repoDir === void 0) return reply.code(404).send({ error: "not found" });
30062
+ if (!repoDir) return reply.code(400).send({ error: "project tidak punya repoDir" });
30063
+ const { path: path2 } = req.query;
30064
+ if (!path2) return reply.code(400).send({ error: "path wajib" });
30065
+ try {
30066
+ return await deleteEntry(repoDir, path2);
30067
+ } catch (e) {
30068
+ return entryError(reply, e);
30069
+ }
30070
+ });
30071
+ app2.post("/projects/:id/upload", async (req, reply) => {
30072
+ const repoDir = await repoOf(req.params.id);
30073
+ if (repoDir === void 0) return reply.code(404).send({ error: "not found" });
30074
+ if (!repoDir) return reply.code(400).send({ error: "project tidak punya repoDir" });
30075
+ if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
30076
+ const totalMax = Number(process.env.HANOMAN_IDE_UPLOAD_MAX_BYTES) || UPLOAD_TOTAL_MAX;
30077
+ let dir2 = "", overwrite = false, manifest = null, seen = 0, total = 0;
30078
+ const written = [];
30079
+ const skipped2 = [];
30080
+ for await (const part of req.parts({ limits: UPLOAD_LIMITS })) {
30081
+ if (part.type === "field") {
30082
+ if (part.fieldname === "dir") dir2 = String(part.value ?? "");
30083
+ else if (part.fieldname === "overwrite") overwrite = part.value === "1" || part.value === "true";
30084
+ else if (part.fieldname === "manifest") {
30085
+ try {
30086
+ const parsed = JSON.parse(String(part.value));
30087
+ if (!Array.isArray(parsed) || parsed.some((p) => typeof p !== "string")) throw new Error("bentuk");
30088
+ manifest = parsed;
30089
+ } catch {
30090
+ return reply.code(400).send({ error: "manifest tak sah" });
30091
+ }
30092
+ }
30093
+ continue;
30094
+ }
30095
+ const name2 = manifest ? manifest[seen] : part.filename;
30096
+ seen++;
30097
+ if (name2 === void 0) {
30098
+ part.file.resume();
30099
+ return reply.code(400).send({ error: "manifest tak cocok dengan berkas" });
30100
+ }
30101
+ let rel = name2;
30102
+ try {
30103
+ rel = joinRel(dir2, name2);
30104
+ if (total >= totalMax) {
30105
+ part.file.resume();
30106
+ skipped2.push({ path: rel, reason: "budget" });
30107
+ continue;
30108
+ }
30109
+ const r = await saveUpload(repoDir, rel, part.file, {
30110
+ overwrite,
30111
+ isTruncated: () => part.file.truncated === true
30112
+ });
30113
+ if (r.status === "written") {
30114
+ written.push(rel);
30115
+ total += Number(part.file.bytesRead ?? 0);
30116
+ } else {
30117
+ part.file.resume();
30118
+ skipped2.push({ path: rel, reason: r.status });
30119
+ }
30120
+ } catch {
30121
+ part.file.resume();
30122
+ skipped2.push({ path: name2, reason: "denied" });
30123
+ }
30124
+ }
30125
+ if (manifest && seen !== manifest.length)
30126
+ return reply.code(400).send({ error: "manifest tak cocok dengan berkas" });
30127
+ return { written, skipped: skipped2 };
30128
+ });
29848
30129
  app2.get("/projects/:id/graph", async (req, reply) => {
29849
30130
  const repoDir = await repoOf(req.params.id);
29850
30131
  if (repoDir === void 0) return reply.code(404).send({ error: "not found" });
@@ -30083,14 +30364,14 @@ async function finishGraphOp(reply, id, repoDir, r, verb) {
30083
30364
  // src/routes/fs.ts
30084
30365
  import { readdir } from "node:fs/promises";
30085
30366
  import { homedir as homedir4 } from "node:os";
30086
- import { resolve as resolve12, dirname as dirname9, join as join8 } from "node:path";
30367
+ import { resolve as resolve13, dirname as dirname9, join as join10 } from "node:path";
30087
30368
  async function fs_default(app2) {
30088
30369
  app2.get("/fs/browse", async (req, reply) => {
30089
30370
  const q = req.query.path;
30090
- const dir2 = q && q.trim() ? resolve12(q.trim()) : homedir4();
30371
+ const dir2 = q && q.trim() ? resolve13(q.trim()) : homedir4();
30091
30372
  try {
30092
30373
  const ents = await readdir(dir2, { withFileTypes: true });
30093
- 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));
30374
+ const entries = ents.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, path: join10(dir2, e.name) })).sort((a, b) => a.name.localeCompare(b.name));
30094
30375
  const parent = dirname9(dir2);
30095
30376
  return { path: dir2, parent: parent === dir2 ? null : parent, entries };
30096
30377
  } catch {
@@ -30524,11 +30805,11 @@ function installCommand(m, agent, shell = shellBin()) {
30524
30805
  }
30525
30806
 
30526
30807
  // src/services/session-worktree.ts
30527
- import { resolve as resolve13, sep as sep3 } from "node:path";
30808
+ import { resolve as resolve14, sep as sep3 } from "node:path";
30528
30809
  function ownsWorktree(repoDir, cwd) {
30529
- const base2 = resolve13(repoDir);
30530
- const dir2 = resolve13(cwd);
30531
- const container = resolve13(base2, ".worktrees");
30810
+ const base2 = resolve14(repoDir);
30811
+ const dir2 = resolve14(cwd);
30812
+ const container = resolve14(base2, ".worktrees");
30532
30813
  return dir2 !== base2 && dir2 !== container && dir2.startsWith(container + sep3);
30533
30814
  }
30534
30815
 
@@ -30536,15 +30817,15 @@ function ownsWorktree(repoDir, cwd) {
30536
30817
  init_src2();
30537
30818
  init_db();
30538
30819
  import { execFile as execFile8 } from "node:child_process";
30539
- import { readdir as readdir2, rm as rm2 } from "node:fs/promises";
30820
+ import { readdir as readdir2, rm as rm4 } from "node:fs/promises";
30540
30821
  import { promisify as promisify9 } from "node:util";
30541
- import { join as join9, resolve as resolve14 } from "node:path";
30822
+ import { join as join11, resolve as resolve15 } from "node:path";
30542
30823
  init_notifications();
30543
30824
  var TICK_MS = 6e4;
30544
- var trashDirOf = (repoDir) => resolve14(repoDir, ".worktrees", ".trash");
30825
+ var trashDirOf = (repoDir) => resolve15(repoDir, ".worktrees", ".trash");
30545
30826
  var exec8 = promisify9(execFile8);
30546
30827
  var prodReaperDeps = {
30547
- rm: (path2) => rm2(path2, { recursive: true, force: true }),
30828
+ rm: (path2) => rm4(path2, { recursive: true, force: true }),
30548
30829
  prune: async (repoDir) => {
30549
30830
  try {
30550
30831
  await exec8("git", ["worktree", "prune"], { cwd: repoDir, timeout: 3e4 });
@@ -30598,7 +30879,7 @@ async function sweepRepo(repoDir, projectId, deps = prodReaperDeps) {
30598
30879
  }
30599
30880
  let removed = 0;
30600
30881
  for (const entry of entries) {
30601
- const path2 = join9(dir2, entry);
30882
+ const path2 = join11(dir2, entry);
30602
30883
  const known = pending.get(path2);
30603
30884
  const row = known ?? { path: path2, repoDir, projectId, entry, sessionId: sessionIdOf(entry), since: Date.now() };
30604
30885
  pending.set(path2, row);
@@ -30642,6 +30923,8 @@ function startWorktreeReaper(deps = prodReaperDeps) {
30642
30923
  init_notifications();
30643
30924
  init_stage_machine();
30644
30925
  init_pty();
30926
+ init_uploads();
30927
+ var ATTACHMENT_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
30645
30928
  var TERMINAL_WS_MESSAGES_PER_MINUTE = 6e3;
30646
30929
  async function advanceStage(specId, repoDir, sessionId2, flow, worktree) {
30647
30930
  const next = stageForRun(readPhases(phaseFilePath(repoDir, sessionId2), flow), worktree, specId);
@@ -30965,6 +31248,18 @@ async function terminal_default(app2, opts) {
30965
31248
  killSession(id);
30966
31249
  return reply.code(202).send({ cleanup: null });
30967
31250
  });
31251
+ app2.post("/terminal/sessions/:id/attachments", async (req, reply) => {
31252
+ const { id } = req.params;
31253
+ if (!getSession(id)) return reply.code(404).send({ error: "not found" });
31254
+ if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
31255
+ const part = await req.file?.();
31256
+ if (!part) return reply.code(400).send({ error: "unggahan tak valid" });
31257
+ const buf = await part.toBuffer();
31258
+ if (part.file?.truncated) return reply.code(413).send({ error: "berkas melebihi 5 MB" });
31259
+ if (!ATTACHMENT_MIME.has(part.mimetype)) return reply.code(415).send({ error: "tipe berkas tak didukung" });
31260
+ const { path: path2 } = await saveSessionUpload(id, buf, part.mimetype);
31261
+ return { path: path2 };
31262
+ });
30968
31263
  app2.get("/terminal/sessions/:id/ws", {
30969
31264
  websocket: true,
30970
31265
  preValidation: async (req, reply) => {
@@ -31203,7 +31498,7 @@ init_config2();
31203
31498
  import { spawn as spawn3 } from "node:child_process";
31204
31499
  import { mkdtempSync, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
31205
31500
  import { tmpdir as tmpdir3 } from "node:os";
31206
- import { dirname as dirname10, join as join10 } from "node:path";
31501
+ import { dirname as dirname10, join as join12 } from "node:path";
31207
31502
  var sshBin = () => effectiveStr("HANOMAN_SSH_BIN") ?? "ssh";
31208
31503
  function consoleArgv(t) {
31209
31504
  return [
@@ -31218,8 +31513,8 @@ function consoleArgv(t) {
31218
31513
  ];
31219
31514
  }
31220
31515
  function askpassScript() {
31221
- const dir2 = mkdtempSync(join10(tmpdir3(), "hanoman-askpass-"));
31222
- const path2 = join10(dir2, "askpass.sh");
31516
+ const dir2 = mkdtempSync(join12(tmpdir3(), "hanoman-askpass-"));
31517
+ const path2 = join12(dir2, "askpass.sh");
31223
31518
  writeFileSync4(path2, `#!/bin/sh
31224
31519
  printf '%s' "$HANOMAN_SSH_PASSWORD"
31225
31520
  `, { mode: 448 });
@@ -31280,15 +31575,15 @@ function sshExec(t, remoteCmd, opts = {}) {
31280
31575
  // src/services/vps-audit.ts
31281
31576
  init_db();
31282
31577
  import { readFileSync as readFileSync7 } from "node:fs";
31283
- import { join as join12 } from "node:path";
31578
+ import { join as join14 } from "node:path";
31284
31579
 
31285
31580
  // src/runner/deps.ts
31286
31581
  import { existsSync as existsSync11 } from "node:fs";
31287
- import { dirname as dirname11, join as join11 } from "node:path";
31582
+ import { dirname as dirname11, join as join13 } from "node:path";
31288
31583
  function repoRootFrom(startDir) {
31289
31584
  let dir2 = startDir;
31290
31585
  for (let i = 0; i < 8; i++) {
31291
- if (existsSync11(join11(dir2, "pnpm-workspace.yaml"))) return dir2;
31586
+ if (existsSync11(join13(dir2, "pnpm-workspace.yaml"))) return dir2;
31292
31587
  const parent = dirname11(dir2);
31293
31588
  if (parent === dir2) break;
31294
31589
  dir2 = parent;
@@ -31394,7 +31689,7 @@ function parseHealth(out3) {
31394
31689
  return { uptime: h.uptime ?? "", disk: h.disk ?? "", mem: h.mem ?? "", load: h.load ?? "" };
31395
31690
  }
31396
31691
  var target = (v) => ({ host: v.host, port: v.port, user: v.user, keyPath: v.keyPath });
31397
- var scriptPath = (f) => join12(repoRoot(), "server", "scripts", "vps", f);
31692
+ var scriptPath = (f) => join14(repoRoot(), "server", "scripts", "vps", f);
31398
31693
  async function itemStatesOf(vpsId) {
31399
31694
  const rows = await prisma.vpsItemState.findMany({ where: { vpsId } });
31400
31695
  return Object.fromEntries(rows.map((s2) => [s2.itemId, { na: s2.na, attested: s2.attested }]));
@@ -31543,11 +31838,11 @@ init_config2();
31543
31838
  import { execFileSync as execFileSync2 } from "node:child_process";
31544
31839
  import { existsSync as existsSync12, mkdirSync as mkdirSync8, readFileSync as readFileSync9 } from "node:fs";
31545
31840
  import { homedir as homedir5 } from "node:os";
31546
- import { join as join13 } from "node:path";
31547
- var keyDir = () => effectiveStr("HANOMAN_SSH_KEY_DIR") ?? join13(homedir5(), ".hanoman");
31841
+ import { join as join15 } from "node:path";
31842
+ var keyDir = () => effectiveStr("HANOMAN_SSH_KEY_DIR") ?? join15(homedir5(), ".hanoman");
31548
31843
  function ensureHanomanKey() {
31549
31844
  const dir2 = keyDir();
31550
- const privPath = join13(dir2, "id_ed25519");
31845
+ const privPath = join15(dir2, "id_ed25519");
31551
31846
  const pubPath = `${privPath}.pub`;
31552
31847
  if (!existsSync12(privPath)) {
31553
31848
  mkdirSync8(dir2, { recursive: true, mode: 448 });
@@ -31785,7 +32080,7 @@ init_config2();
31785
32080
  import { execFileSync as execFileSync3 } from "node:child_process";
31786
32081
  import { readFileSync as readFileSync11 } from "node:fs";
31787
32082
  import { homedir as homedir7 } from "node:os";
31788
- import { join as join14 } from "node:path";
32083
+ import { join as join16 } from "node:path";
31789
32084
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
31790
32085
  var TTL_MS2 = 3e4;
31791
32086
  var lastOk = null;
@@ -31798,7 +32093,7 @@ var LABELS = {
31798
32093
  var humanize = (k) => k.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
31799
32094
  var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
31800
32095
  function credsFile() {
31801
- return join14(effectiveStr("CLAUDE_CONFIG_DIR") ?? join14(homedir7(), ".claude"), ".credentials.json");
32096
+ return join16(effectiveStr("CLAUDE_CONFIG_DIR") ?? join16(homedir7(), ".claude"), ".credentials.json");
31802
32097
  }
31803
32098
  function readAccessToken() {
31804
32099
  if (process.platform === "darwin" && !effectiveStr("CLAUDE_CONFIG_DIR")) {
@@ -31872,14 +32167,14 @@ async function getLimits() {
31872
32167
  // src/services/codex-limits.ts
31873
32168
  import { open as open3, readdir as readdir3, stat } from "node:fs/promises";
31874
32169
  import { homedir as homedir8 } from "node:os";
31875
- import { join as join15 } from "node:path";
32170
+ import { join as join17 } from "node:path";
31876
32171
  var TTL_MS3 = 3e4;
31877
32172
  var STALE_AFTER_MS = 12 * 36e5;
31878
32173
  var TAIL_BYTES = 512 * 1024;
31879
32174
  var MAX_FILES = 8;
31880
32175
  var cache2 = null;
31881
32176
  var freshUntil2 = 0;
31882
- var codexSessionsDir = () => join15(process.env.CODEX_HOME ?? join15(homedir8(), ".codex"), "sessions");
32177
+ var codexSessionsDir = () => join17(process.env.CODEX_HOME ?? join17(homedir8(), ".codex"), "sessions");
31883
32178
  var UNAVAILABLE = { status: "unavailable", windows: [], fetchedAt: null, plan: null };
31884
32179
  function windowLabel(minutes) {
31885
32180
  if (minutes === 300) return "Sesi 5 jam";
@@ -31912,7 +32207,7 @@ async function recentRollouts(dir2) {
31912
32207
  return [];
31913
32208
  }
31914
32209
  const stamped = await Promise.all(entries.map(async (rel) => {
31915
- const full = join15(dir2, rel);
32210
+ const full = join17(dir2, rel);
31916
32211
  try {
31917
32212
  return { full, mtime: (await stat(full)).mtimeMs };
31918
32213
  } catch {
@@ -32040,7 +32335,7 @@ init_src();
32040
32335
  init_src();
32041
32336
  init_config2();
32042
32337
  import { readFileSync as readFileSync12 } from "node:fs";
32043
- import { resolve as resolve15, dirname as dirname12 } from "node:path";
32338
+ import { resolve as resolve16, dirname as dirname12 } from "node:path";
32044
32339
  import { fileURLToPath as fileURLToPath3 } from "node:url";
32045
32340
  var UPDATE_COMMAND = "npm i -g hanoman@latest";
32046
32341
  function composeUpdate(x) {
@@ -32066,7 +32361,7 @@ var lastLatest = null;
32066
32361
  var lastStatus = "unavailable";
32067
32362
  function runningVersion() {
32068
32363
  const here = dirname12(fileURLToPath3(import.meta.url));
32069
- for (const p of [resolve15(here, "build-info.json"), resolve15(here, "../package.json")]) {
32364
+ for (const p of [resolve16(here, "build-info.json"), resolve16(here, "../package.json")]) {
32070
32365
  try {
32071
32366
  const v = JSON.parse(readFileSync12(p, "utf8")).version;
32072
32367
  if (typeof v === "string" && v) return v;
@@ -32417,54 +32712,7 @@ setAcceptedHook((row) => broadcastSyncLog(row));
32417
32712
  init_sync();
32418
32713
  init_sync_client();
32419
32714
  init_conflicts();
32420
-
32421
- // src/services/uploads.ts
32422
- init_config2();
32423
- init_safe_outbound_request();
32424
- init_src2();
32425
- import { mkdir as mkdir2, writeFile, readFile as readFile2, unlink as unlink2 } from "node:fs/promises";
32426
- import { join as join16, resolve as resolve16 } from "node:path";
32427
- function uploadDir() {
32428
- return resolve16(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join16(resolveHome(), "uploads"));
32429
- }
32430
- async function readUpload(storageKey) {
32431
- const safe = storageKey.replace(/[/\\]/g, "");
32432
- return readFile2(join16(uploadDir(), safe));
32433
- }
32434
- async function readUploadOrFetch(storageKey) {
32435
- const safe = storageKey.replace(/[/\\]/g, "");
32436
- const target2 = join16(uploadDir(), safe);
32437
- try {
32438
- return await readFile2(target2);
32439
- } catch {
32440
- const base2 = effectiveStr("SYNC_SERVER_URL");
32441
- const token = effectiveStr("SYNC_DEVICE_TOKEN");
32442
- if (!base2 || !token) throw new Error(`lampiran ${safe} tak ada lokal & bukan client sync`);
32443
- const url2 = new URL(`/api/sync/attachments/${encodeURIComponent(safe)}`, `${base2.replace(/\/$/, "")}/`);
32444
- const loopback = ["localhost", "127.0.0.1", "::1"].includes(url2.hostname);
32445
- const res = await safeRequest({
32446
- url: url2,
32447
- method: "GET",
32448
- headers: { authorization: `Bearer ${token}` },
32449
- allowPrivate: process.env.NODE_ENV !== "production" && loopback,
32450
- connectMs: 5e3,
32451
- totalMs: 15e3,
32452
- maxResponseBytes: 5 * 1024 * 1024
32453
- });
32454
- if (res.status < 200 || res.status >= 300) throw new Error(`fetch lampiran hub gagal: ${res.status}`);
32455
- const buf = res.body;
32456
- await mkdir2(uploadDir(), { recursive: true, mode: 448 });
32457
- await writeFile(target2, buf, { mode: 384 });
32458
- return buf;
32459
- }
32460
- }
32461
- async function deleteUpload(storageKey) {
32462
- const safe = storageKey.replace(/[/\\]/g, "");
32463
- await unlink2(join16(uploadDir(), safe)).catch(() => {
32464
- });
32465
- }
32466
-
32467
- // src/routes/sync.ts
32715
+ init_uploads();
32468
32716
  init_config2();
32469
32717
  var zPush = external_exports.object({
32470
32718
  records: external_exports.array(external_exports.object({
@@ -32615,17 +32863,17 @@ async function session_results_default(app2) {
32615
32863
  // src/services/session-history.ts
32616
32864
  init_db();
32617
32865
  init_pty();
32618
- import { randomUUID as randomUUID5 } from "node:crypto";
32866
+ import { randomUUID as randomUUID7 } from "node:crypto";
32619
32867
 
32620
32868
  // src/services/transcript-store.ts
32621
32869
  init_config2();
32622
32870
  init_src2();
32623
- import { randomUUID as randomUUID4 } from "node:crypto";
32871
+ import { randomUUID as randomUUID6 } from "node:crypto";
32624
32872
  import { mkdir as mkdir3, writeFile as writeFile2, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
32625
- import { join as join17, resolve as resolve17, basename as basename4 } from "node:path";
32873
+ import { join as join18, resolve as resolve17, basename as basename4 } from "node:path";
32626
32874
  var MAX_TRANSCRIPT_BYTES = 1024 * 1024;
32627
32875
  function transcriptDir() {
32628
- return resolve17(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join17(resolveHome(), "transcripts"));
32876
+ return resolve17(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join18(resolveHome(), "transcripts"));
32629
32877
  }
32630
32878
  function clamp(text) {
32631
32879
  const buf = Buffer.from(text, "utf8");
@@ -32642,21 +32890,21 @@ async function saveTranscript(text) {
32642
32890
  const { body, truncated } = clamp(text);
32643
32891
  const dir2 = transcriptDir();
32644
32892
  await mkdir3(dir2, { recursive: true, mode: 448 });
32645
- const key = `${randomUUID4()}.log`;
32646
- await writeFile2(join17(dir2, key), body, { encoding: "utf8", mode: 384 });
32893
+ const key = `${randomUUID6()}.log`;
32894
+ await writeFile2(join18(dir2, key), body, { encoding: "utf8", mode: 384 });
32647
32895
  return { key, bytes: Buffer.byteLength(body, "utf8"), truncated };
32648
32896
  }
32649
32897
  async function readTranscript(key) {
32650
32898
  if (!key) return null;
32651
32899
  try {
32652
- return await readFile3(join17(transcriptDir(), basename4(key)), "utf8");
32900
+ return await readFile3(join18(transcriptDir(), basename4(key)), "utf8");
32653
32901
  } catch {
32654
32902
  return null;
32655
32903
  }
32656
32904
  }
32657
32905
  async function deleteTranscript(key) {
32658
32906
  if (!key) return;
32659
- await unlink3(join17(transcriptDir(), basename4(key))).catch(() => {
32907
+ await unlink3(join18(transcriptDir(), basename4(key))).catch(() => {
32660
32908
  });
32661
32909
  }
32662
32910
 
@@ -32687,7 +32935,7 @@ async function titleFor(specId) {
32687
32935
  async function beginSession(b) {
32688
32936
  await prisma.sessionHistory.create({
32689
32937
  data: {
32690
- id: randomUUID5(),
32938
+ id: randomUUID7(),
32691
32939
  sessionId: b.sessionId,
32692
32940
  projectId: b.projectId,
32693
32941
  specId: b.specId ?? null,
@@ -32892,8 +33140,9 @@ init_db();
32892
33140
 
32893
33141
  // src/services/ticket.ts
32894
33142
  init_db();
32895
- import { createHash as createHash7, randomBytes as randomBytes6 } from "node:crypto";
33143
+ init_uploads();
32896
33144
  init_src();
33145
+ import { createHash as createHash7, randomBytes as randomBytes6 } from "node:crypto";
32897
33146
  function hashAccessKey(key) {
32898
33147
  return createHash7("sha256").update(key).digest("hex");
32899
33148
  }
@@ -32938,12 +33187,13 @@ async function pruneOldTickets(now = Date.now()) {
32938
33187
  // src/services/ticket-intake.ts
32939
33188
  init_db();
32940
33189
  init_notifications();
33190
+ init_uploads();
32941
33191
 
32942
33192
  // src/services/upload-pipeline.ts
32943
- import { randomUUID as randomUUID6 } from "node:crypto";
33193
+ import { randomUUID as randomUUID8 } from "node:crypto";
32944
33194
  import { spawn as spawn4 } from "node:child_process";
32945
- import { mkdir as mkdir4, rename as rename2, unlink as unlink4, writeFile as writeFile3 } from "node:fs/promises";
32946
- import { basename as basename5, isAbsolute as isAbsolute4, join as join18 } from "node:path";
33195
+ import { mkdir as mkdir4, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "node:fs/promises";
33196
+ import { basename as basename5, isAbsolute as isAbsolute4, join as join19 } from "node:path";
32947
33197
 
32948
33198
  // ../node_modules/.pnpm/strtok3@10.3.5/node_modules/strtok3/lib/stream/Errors.js
32949
33199
  var defaultMessages = "End-Of-Stream";
@@ -40306,7 +40556,8 @@ var dist_default = constructor_default;
40306
40556
 
40307
40557
  // src/services/upload-pipeline.ts
40308
40558
  init_db();
40309
- var UPLOAD_LIMITS = {
40559
+ init_uploads();
40560
+ var UPLOAD_LIMITS2 = {
40310
40561
  fileBytes: 5 * 1024 * 1024,
40311
40562
  ticketBytes: 10 * 1024 * 1024,
40312
40563
  projectBytes: 250 * 1024 * 1024,
@@ -40358,7 +40609,7 @@ function scannerFromEnv(path2) {
40358
40609
  const timer9 = setTimeout(() => {
40359
40610
  child.kill("SIGKILL");
40360
40611
  reject2(new UploadError("UPLOAD_SCAN", "scanner timeout"));
40361
- }, UPLOAD_LIMITS.scanMs);
40612
+ }, UPLOAD_LIMITS2.scanMs);
40362
40613
  child.once("error", (error) => {
40363
40614
  clearTimeout(timer9);
40364
40615
  reject2(error);
@@ -40375,9 +40626,9 @@ function safeFilename(input, extension2) {
40375
40626
  return `${stem || "upload"}${extension2}`;
40376
40627
  }
40377
40628
  async function processUpload(input, deps = {}) {
40378
- if (input.buffer.byteLength === 0 || input.buffer.byteLength > UPLOAD_LIMITS.fileBytes)
40629
+ if (input.buffer.byteLength === 0 || input.buffer.byteLength > UPLOAD_LIMITS2.fileBytes)
40379
40630
  throw new UploadError("UPLOAD_QUOTA", "file quota exceeded");
40380
- if (input.ticketBytes + input.buffer.byteLength > UPLOAD_LIMITS.ticketBytes)
40631
+ if (input.ticketBytes + input.buffer.byteLength > UPLOAD_LIMITS2.ticketBytes)
40381
40632
  throw new UploadError("UPLOAD_QUOTA", "ticket quota exceeded");
40382
40633
  const detected = await fileTypeFromBuffer(input.buffer);
40383
40634
  if (!detected || !(detected.mime in TYPES) || detected.mime !== input.clientMime)
@@ -40388,14 +40639,14 @@ async function processUpload(input, deps = {}) {
40388
40639
  let height;
40389
40640
  let normalized;
40390
40641
  try {
40391
- const source = dist_default(input.buffer, { failOn: "error", limitInputPixels: UPLOAD_LIMITS.pixels, sequentialRead: true });
40392
- const metadata2 = await timeout2(source.metadata(), UPLOAD_LIMITS.decodeMs, "UPLOAD_DECODE");
40642
+ const source = dist_default(input.buffer, { failOn: "error", limitInputPixels: UPLOAD_LIMITS2.pixels, sequentialRead: true });
40643
+ const metadata2 = await timeout2(source.metadata(), UPLOAD_LIMITS2.decodeMs, "UPLOAD_DECODE");
40393
40644
  width = metadata2.width ?? 0;
40394
40645
  height = metadata2.height ?? 0;
40395
- if (!width || !height || width > UPLOAD_LIMITS.dimension || height > UPLOAD_LIMITS.dimension || width * height > UPLOAD_LIMITS.pixels) throw new UploadError("UPLOAD_PIXELS", "image dimensions exceed policy");
40646
+ if (!width || !height || width > UPLOAD_LIMITS2.dimension || height > UPLOAD_LIMITS2.dimension || width * height > UPLOAD_LIMITS2.pixels) throw new UploadError("UPLOAD_PIXELS", "image dimensions exceed policy");
40396
40647
  normalized = await timeout2(
40397
- dist_default(input.buffer, { failOn: "error", limitInputPixels: UPLOAD_LIMITS.pixels, sequentialRead: true }).rotate().toFormat(type.format).toBuffer(),
40398
- UPLOAD_LIMITS.decodeMs,
40648
+ dist_default(input.buffer, { failOn: "error", limitInputPixels: UPLOAD_LIMITS2.pixels, sequentialRead: true }).rotate().toFormat(type.format).toBuffer(),
40649
+ UPLOAD_LIMITS2.decodeMs,
40399
40650
  "UPLOAD_DECODE"
40400
40651
  );
40401
40652
  } catch (error) {
@@ -40403,21 +40654,21 @@ async function processUpload(input, deps = {}) {
40403
40654
  throw new UploadError("UPLOAD_DECODE", "image decode failed");
40404
40655
  }
40405
40656
  const usage2 = await (deps.usage ?? defaultUsage)(input.projectId);
40406
- if (usage2.project + normalized.byteLength > UPLOAD_LIMITS.projectBytes || usage2.global + normalized.byteLength > UPLOAD_LIMITS.globalBytes)
40657
+ if (usage2.project + normalized.byteLength > UPLOAD_LIMITS2.projectBytes || usage2.global + normalized.byteLength > UPLOAD_LIMITS2.globalBytes)
40407
40658
  throw new UploadError("UPLOAD_QUOTA", "storage quota exceeded");
40408
40659
  const storageDir = deps.storageDir ?? uploadDir();
40409
- const quarantineDir = join18(storageDir, ".quarantine");
40660
+ const quarantineDir = join19(storageDir, ".quarantine");
40410
40661
  await mkdir4(quarantineDir, { recursive: true, mode: 448 });
40411
40662
  await mkdir4(storageDir, { recursive: true, mode: 448 });
40412
- const quarantine = join18(quarantineDir, `${randomUUID6()}.upload`);
40413
- const storageKey = `${randomUUID6()}${type.extension}`;
40663
+ const quarantine = join19(quarantineDir, `${randomUUID8()}.upload`);
40664
+ const storageKey = `${randomUUID8()}${type.extension}`;
40414
40665
  await writeFile3(quarantine, normalized, { mode: 384, flag: "wx" });
40415
40666
  try {
40416
- await timeout2((deps.scanner ?? scannerFromEnv)(quarantine), UPLOAD_LIMITS.scanMs, "UPLOAD_SCAN");
40667
+ await timeout2((deps.scanner ?? scannerFromEnv)(quarantine), UPLOAD_LIMITS2.scanMs, "UPLOAD_SCAN");
40417
40668
  const finalUsage = await (deps.usage ?? defaultUsage)(input.projectId);
40418
- if (finalUsage.project + normalized.byteLength > UPLOAD_LIMITS.projectBytes || finalUsage.global + normalized.byteLength > UPLOAD_LIMITS.globalBytes)
40669
+ if (finalUsage.project + normalized.byteLength > UPLOAD_LIMITS2.projectBytes || finalUsage.global + normalized.byteLength > UPLOAD_LIMITS2.globalBytes)
40419
40670
  throw new UploadError("UPLOAD_QUOTA", "storage quota exceeded");
40420
- await rename2(quarantine, join18(storageDir, storageKey));
40671
+ await rename3(quarantine, join19(storageDir, storageKey));
40421
40672
  } catch (error) {
40422
40673
  await unlink4(quarantine).catch(() => {
40423
40674
  });
@@ -40598,6 +40849,7 @@ async function help_default(app2) {
40598
40849
 
40599
40850
  // src/routes/tickets.ts
40600
40851
  init_db();
40852
+ init_uploads();
40601
40853
 
40602
40854
  // src/services/ticket-accept.ts
40603
40855
  init_db();
@@ -41534,10 +41786,10 @@ init_config2();
41534
41786
  init_pty();
41535
41787
  init_session_sandbox();
41536
41788
  import { execFile as execFile10 } from "node:child_process";
41537
- import { randomUUID as randomUUID7 } from "node:crypto";
41789
+ import { randomUUID as randomUUID9 } from "node:crypto";
41538
41790
  import { mkdirSync as mkdirSync9, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "node:fs";
41539
41791
  import { tmpdir as tmpdir4 } from "node:os";
41540
- import { join as join19 } from "node:path";
41792
+ import { join as join20 } from "node:path";
41541
41793
  var binFor = (agent) => agent === "codex" ? effectiveStr("HANOMAN_CODEX_BIN") ?? "codex" : effectiveStr("HANOMAN_CLAUDE_BIN") ?? "claude";
41542
41794
  function leadArgv(o) {
41543
41795
  if (o.agent === "codex") {
@@ -41565,11 +41817,11 @@ function leadProcess(prompt, o, env = process.env) {
41565
41817
  const mode = env.HANOMAN_SESSION_SANDBOX ?? (env.NODE_ENV === "production" ? "required" : "off");
41566
41818
  if (mode === "off") return { file, args: directArgs, cwd: o.cwd, cleanup: () => {
41567
41819
  } };
41568
- const promptDir = join19(tmpdir4(), "hanoman-prompts");
41820
+ const promptDir = join20(tmpdir4(), "hanoman-prompts");
41569
41821
  mkdirSync9(promptDir, { recursive: true, mode: 448 });
41570
- const promptFile = join19(promptDir, `oneshot-${randomUUID7()}`);
41822
+ const promptFile = join20(promptDir, `oneshot-${randomUUID9()}`);
41571
41823
  writeFileSync5(promptFile, prompt, { flag: "wx", mode: 384 });
41572
- const workspace = o.cwd ?? join19(tmpdir4(), `hanoman-oneshot-${randomUUID7()}`);
41824
+ const workspace = o.cwd ?? join20(tmpdir4(), `hanoman-oneshot-${randomUUID9()}`);
41573
41825
  if (!o.cwd) mkdirSync9(workspace, { recursive: false, mode: 448 });
41574
41826
  try {
41575
41827
  const argsWithoutPrompt = directArgs.slice(0, -1);
@@ -43358,7 +43610,7 @@ init_db();
43358
43610
  init_src();
43359
43611
  import { readFileSync as readFileSync13 } from "node:fs";
43360
43612
  import { homedir as homedir9 } from "node:os";
43361
- import { join as join20 } from "node:path";
43613
+ import { join as join21 } from "node:path";
43362
43614
  var home = () => process.env.HOME || homedir9();
43363
43615
  var readJson2 = (path2) => {
43364
43616
  try {
@@ -43375,7 +43627,7 @@ var serversOf = (node) => {
43375
43627
  var codexServers = () => {
43376
43628
  let text;
43377
43629
  try {
43378
- text = readFileSync13(join20(home(), ".codex", "config.toml"), "utf8");
43630
+ text = readFileSync13(join21(home(), ".codex", "config.toml"), "utf8");
43379
43631
  } catch {
43380
43632
  return [];
43381
43633
  }
@@ -43388,12 +43640,12 @@ var codexServers = () => {
43388
43640
  };
43389
43641
  function mcpServerNames(repoDir) {
43390
43642
  const names = [];
43391
- const claudeJson = readJson2(join20(home(), ".claude.json"));
43643
+ const claudeJson = readJson2(join21(home(), ".claude.json"));
43392
43644
  names.push(...serversOf(claudeJson));
43393
43645
  if (repoDir) {
43394
43646
  const projects = claudeJson?.projects;
43395
43647
  if (projects && typeof projects === "object") names.push(...serversOf(projects[repoDir]));
43396
- names.push(...serversOf(readJson2(join20(repoDir, ".mcp.json"))));
43648
+ names.push(...serversOf(readJson2(join21(repoDir, ".mcp.json"))));
43397
43649
  }
43398
43650
  names.push(...codexServers());
43399
43651
  return [...new Set(names.filter(Boolean))].sort((a, b) => a.localeCompare(b));
@@ -44846,17 +45098,17 @@ init_db();
44846
45098
 
44847
45099
  // src/services/bootstrap.ts
44848
45100
  import { createHash as createHash9, randomBytes as randomBytes9, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
44849
- import { chmod, lstat as lstat2, mkdir as mkdir5, readFile as readFile4, unlink as unlink5, writeFile as writeFile4 } from "node:fs/promises";
44850
- import { join as join21 } from "node:path";
45101
+ import { chmod, lstat as lstat3, mkdir as mkdir5, readFile as readFile4, unlink as unlink5, writeFile as writeFile4 } from "node:fs/promises";
45102
+ import { join as join22 } from "node:path";
44851
45103
  var TTL_MS5 = 15 * 6e4;
44852
45104
  var SETUP_TOKEN_FILE = "setup.token";
44853
45105
  var BootstrapError = class extends Error {
44854
45106
  code = "BOOTSTRAP_PROOF";
44855
45107
  };
44856
- var tokenPath = (home3) => join21(home3, SETUP_TOKEN_FILE);
45108
+ var tokenPath = (home3) => join22(home3, SETUP_TOKEN_FILE);
44857
45109
  async function readStored(home3) {
44858
45110
  try {
44859
- const info = await lstat2(tokenPath(home3));
45111
+ const info = await lstat3(tokenPath(home3));
44860
45112
  if (info.isSymbolicLink() || !info.isFile()) throw new BootstrapError("invalid setup proof");
44861
45113
  const [token, expires] = (await readFile4(tokenPath(home3), "utf8")).trim().split("\n");
44862
45114
  const expiresAt = Date.parse(expires ?? "");
@@ -44869,7 +45121,7 @@ async function readStored(home3) {
44869
45121
  }
44870
45122
  async function ensureSetupToken(home3, now = Date.now()) {
44871
45123
  await mkdir5(home3, { recursive: true, mode: 448 });
44872
- const homeInfo = await lstat2(home3);
45124
+ const homeInfo = await lstat3(home3);
44873
45125
  if (homeInfo.isSymbolicLink() || !homeInfo.isDirectory()) throw new BootstrapError("invalid setup home");
44874
45126
  await chmod(home3, 448);
44875
45127
  try {
@@ -45063,7 +45315,12 @@ var IDE_SUBS = /* @__PURE__ */ new Set([
45063
45315
  "remotes",
45064
45316
  "compare",
45065
45317
  "archive",
45066
- "pr-url"
45318
+ "pr-url",
45319
+ // ADR-0121 · operasi berkas Explorer. `rw()` menurunkan read/write DARI METHOD, jadi
45320
+ // POST/PATCH/DELETE menuntut ide:write — capability yang sudah memberi hak menimpa isi
45321
+ // berkas apa pun lewat PUT /file (hindari kelas bug SPEC-405: prefix tanpa lihat method).
45322
+ "entry",
45323
+ "upload"
45067
45324
  ]);
45068
45325
  function capabilityForRoute(method, path2) {
45069
45326
  const read2 = method === "GET" || method === "HEAD";
@@ -45768,7 +46025,7 @@ init_tap();
45768
46025
  // src/services/webhooks/emit.ts
45769
46026
  init_src();
45770
46027
  init_db();
45771
- import { randomUUID as randomUUID8 } from "node:crypto";
46028
+ import { randomUUID as randomUUID10 } from "node:crypto";
45772
46029
  function skipped(def2, row) {
45773
46030
  if (!def2.skipWhen || !row) return false;
45774
46031
  return row[def2.skipWhen.field] === def2.skipWhen.equals;
@@ -45834,7 +46091,7 @@ async function emitWebhook(i) {
45834
46091
  const targets = matchingEndpoints(type, projectId);
45835
46092
  if (!targets.length) return;
45836
46093
  const name2 = projectId ? (await prisma.project.findUnique({ where: { id: projectId }, select: { name: true } }))?.name ?? null : null;
45837
- const env = buildEnvelope(i, name2, (/* @__PURE__ */ new Date()).toISOString(), `evt_${randomUUID8().replace(/-/g, "")}`);
46094
+ const env = buildEnvelope(i, name2, (/* @__PURE__ */ new Date()).toISOString(), `evt_${randomUUID10().replace(/-/g, "")}`);
45838
46095
  if (!env) return;
45839
46096
  await enqueueEnvelope(env, targets);
45840
46097
  } catch (e) {
@@ -46146,14 +46403,14 @@ init_session_sandbox();
46146
46403
  init_src2();
46147
46404
 
46148
46405
  // src/services/secure-home.ts
46149
- import { chmod as chmod2, lstat as lstat3, mkdir as mkdir6 } from "node:fs/promises";
46406
+ import { chmod as chmod2, lstat as lstat4, mkdir as mkdir6 } from "node:fs/promises";
46150
46407
  import { isAbsolute as isAbsolute6, resolve as resolve18 } from "node:path";
46151
46408
  var HomePermissionError = class extends Error {
46152
46409
  code = "HOME_SYMLINK";
46153
46410
  };
46154
46411
  async function assertNoSymlink(path2, allowMissing) {
46155
46412
  try {
46156
- const info = await lstat3(resolve18(path2));
46413
+ const info = await lstat4(resolve18(path2));
46157
46414
  if (info.isSymbolicLink()) throw new HomePermissionError(`symlink ditolak: ${path2}`);
46158
46415
  } catch (error) {
46159
46416
  if (error.code === "ENOENT" && allowMissing) return;
@@ -46184,6 +46441,7 @@ async function secureHanomanHome(opts) {
46184
46441
 
46185
46442
  // src/services/retention.ts
46186
46443
  init_db();
46444
+ init_uploads();
46187
46445
  var RETENTION_DAYS = {
46188
46446
  tickets: 90,
46189
46447
  newTickets: 180,
@@ -46298,6 +46556,7 @@ function startRetentionSweep() {
46298
46556
  }
46299
46557
 
46300
46558
  // src/server.ts
46559
+ init_uploads();
46301
46560
  var app = buildApp();
46302
46561
  var port = Number(process.env.PORT ?? 8787);
46303
46562
  var host = process.env.HOST ?? "127.0.0.1";