offsite 0.0.62 → 0.0.64

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.
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import { appendFileSync, chmodSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
3
3
  import http, { createServer } from "node:http";
4
4
  import path from "node:path";
5
5
  import zlib, { createGzip } from "node:zlib";
@@ -31,7 +31,6 @@ import { CookieJar, getPublicSuffix } from "tough-cookie";
31
31
  import { spawn as spawn$1 } from "@lydell/node-pty";
32
32
  import { once } from "node:events";
33
33
  import https from "node:https";
34
- import { buffer } from "node:stream/consumers";
35
34
  import { pipeline } from "node:stream/promises";
36
35
  import { defaultTreeAdapter, html, parse, serialize } from "parse5";
37
36
  import { MagicString } from "magic-string";
@@ -302,6 +301,7 @@ function findCodex() {
302
301
  var AppServer = class {
303
302
  home;
304
303
  threads = /* @__PURE__ */ new Map();
304
+ agents = /* @__PURE__ */ new Map();
305
305
  child;
306
306
  waiting = /* @__PURE__ */ new Map();
307
307
  ready;
@@ -418,9 +418,16 @@ var AppServer = class {
418
418
  }
419
419
  const params = message.params ?? {};
420
420
  const listener = typeof params.threadId === "string" ? this.threads.get(params.threadId) : void 0;
421
+ const root = this.rootOf(params.threadId);
422
+ const owner = root === void 0 ? void 0 : this.threads.get(root);
423
+ if (root !== void 0) this.linkAgents(root, params.item);
421
424
  if (message.id !== void 0) {
422
- if (listener === void 0) this.replyError(message.id, `offsite does not handle ${message.method}`);
423
- else listener.request(message.id, message.method, params);
425
+ if (owner === void 0) this.replyError(message.id, `offsite does not handle ${message.method}`);
426
+ else owner.request(message.id, message.method, params);
427
+ return;
428
+ }
429
+ if (message.method === "serverRequest/resolved") {
430
+ owner?.notify(message.method, params);
424
431
  return;
425
432
  }
426
433
  if (message.method === "account/rateLimits/updated") {
@@ -429,6 +436,14 @@ var AppServer = class {
429
436
  }
430
437
  listener?.notify(message.method, params);
431
438
  }
439
+ rootOf(threadId) {
440
+ if (typeof threadId !== "string") return;
441
+ return this.threads.has(threadId) ? threadId : this.agents.get(threadId);
442
+ }
443
+ linkAgents(root, item) {
444
+ if (item?.type === "subAgentActivity") this.agents.set(item.agentThreadId, root);
445
+ if (item?.type === "collabAgentToolCall") for (const agent of item.receiverThreadIds) this.agents.set(agent, root);
446
+ }
432
447
  armIdleTimer() {
433
448
  clearTimeout(this.idleTimer);
434
449
  this.idleTimer = setTimeout(() => {
@@ -1749,7 +1764,13 @@ function fold(text) {
1749
1764
  return folded.length === 1 ? folded : character;
1750
1765
  }).toLowerCase();
1751
1766
  }
1767
+ function exactPhrase(query) {
1768
+ const phrase = /^"([^"]+)"$/.exec(query.trim())?.[1].trim();
1769
+ return phrase === void 0 || phrase === "" ? null : phrase;
1770
+ }
1752
1771
  function searchWords(query) {
1772
+ const phrase = exactPhrase(query);
1773
+ if (phrase !== null) return [fold(phrase)];
1753
1774
  return fold(query).replace(/^["'`]+|["'`]+$/g, "").split(/\s+/).filter(Boolean);
1754
1775
  }
1755
1776
  function hasSearchableWord(query) {
@@ -1764,6 +1785,28 @@ function isWordStart(text, index) {
1764
1785
  function isWordEnd(text, end) {
1765
1786
  return end >= text.length || !alphanumeric.test(text[end]) || isWordStart(text, end);
1766
1787
  }
1788
+ function mergeRanges(ranges) {
1789
+ const merged = [];
1790
+ for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
1791
+ const last = merged.at(-1);
1792
+ if (last !== void 0 && range.start <= last.end) last.end = Math.max(last.end, range.end);
1793
+ else merged.push({ ...range });
1794
+ }
1795
+ return merged;
1796
+ }
1797
+ function exactRanges(folded, phrase) {
1798
+ const ranges = [];
1799
+ for (let index = folded.indexOf(phrase); index !== -1; index = folded.indexOf(phrase, index + 1)) {
1800
+ const end = index + phrase.length;
1801
+ const joinedBefore = alphanumeric.test(phrase[0]) && alphanumeric.test(folded[index - 1] ?? "");
1802
+ const joinedAfter = alphanumeric.test(phrase[phrase.length - 1]) && alphanumeric.test(folded[end] ?? "");
1803
+ if (!joinedBefore && !joinedAfter) ranges.push({
1804
+ start: index,
1805
+ end
1806
+ });
1807
+ }
1808
+ return mergeRanges(ranges);
1809
+ }
1767
1810
  function findWord(text, folded, word) {
1768
1811
  let quality = 0;
1769
1812
  let at = -1;
@@ -1783,6 +1826,13 @@ function matchMessage(query, text) {
1783
1826
  const words = searchWords(query);
1784
1827
  if (words.length === 0) return null;
1785
1828
  const folded = fold(text);
1829
+ if (exactPhrase(query) !== null) {
1830
+ const range = exactRanges(folded, words[0]).at(0);
1831
+ return range === void 0 ? null : {
1832
+ quality: 1,
1833
+ offset: range.start
1834
+ };
1835
+ }
1786
1836
  let quality = 1;
1787
1837
  let offset = text.length;
1788
1838
  for (const word of words) {
@@ -2224,7 +2274,10 @@ async function openCodeSessions(paths) {
2224
2274
  directory,
2225
2275
  parentID: null,
2226
2276
  limit: PAGE
2227
- } : { cursor });
2277
+ } : {
2278
+ limit: PAGE,
2279
+ cursor
2280
+ });
2228
2281
  found.push(...page.data);
2229
2282
  cursor = page.data.length === PAGE ? page.cursor.next ?? null : null;
2230
2283
  } while (cursor !== null);
@@ -2361,6 +2414,7 @@ async function allMessages(client, sessionId) {
2361
2414
  order: "asc"
2362
2415
  } : {
2363
2416
  sessionID: sessionId,
2417
+ limit: PAGE,
2364
2418
  cursor
2365
2419
  });
2366
2420
  messages.push(...page.data);
@@ -2777,14 +2831,63 @@ const pushSubscriptions = snakeCase.table("push_subscriptions", {
2777
2831
  machine: text().notNull(),
2778
2832
  server: text().notNull()
2779
2833
  });
2834
+ // src/storage/auth.ts
2835
+ const CODE_LIFETIME = 3e5;
2836
+ const DEVICE_LIFETIME = 2592e6;
2837
+ function addCode(name) {
2838
+ const code = randomBytes(16).toString("base64url");
2839
+ db.delete(pairingCodes).where(lte(pairingCodes.expiresAt, Date.now())).run();
2840
+ db.insert(pairingCodes).values({
2841
+ hash: hash(code),
2842
+ name,
2843
+ expiresAt: Date.now() + CODE_LIFETIME
2844
+ }).run();
2845
+ return code;
2846
+ }
2847
+ function redeemCode(code) {
2848
+ const found = db.delete(pairingCodes).where(eq(pairingCodes.hash, hash(code))).returning().get();
2849
+ if (found === void 0 || found.expiresAt <= Date.now()) return null;
2850
+ const token = randomBytes(32).toString("base64url");
2851
+ const now = Date.now();
2852
+ db.insert(devices).values({
2853
+ hash: hash(token),
2854
+ name: found.name,
2855
+ createdAt: now,
2856
+ expiresAt: now + DEVICE_LIFETIME
2857
+ }).run();
2858
+ return token;
2859
+ }
2860
+ function findDevice(token) {
2861
+ const found = db.select().from(devices).where(and(eq(devices.hash, hash(token)), gt(devices.expiresAt, Date.now()))).get();
2862
+ return found === void 0 ? null : found.hash;
2863
+ }
2864
+ function isPaired(device) {
2865
+ return db.select().from(devices).where(and(eq(devices.hash, device), gt(devices.expiresAt, Date.now()))).get() !== void 0;
2866
+ }
2867
+ function hasDevices() {
2868
+ return db.select().from(devices).where(gt(devices.expiresAt, Date.now())).limit(1).get() !== void 0;
2869
+ }
2870
+ function readDevices() {
2871
+ return db.select().from(devices).orderBy(devices.createdAt).all();
2872
+ }
2873
+ function removeDevices(hashes) {
2874
+ db.delete(devices).where(inArray(devices.hash, hashes)).run();
2875
+ }
2876
+ function recordUse(device) {
2877
+ db.update(devices).set({ lastUsedAt: Date.now() }).where(eq(devices.hash, device)).run();
2878
+ }
2879
+ function hash(secret) {
2880
+ return createHash("sha256").update(secret).digest("hex");
2881
+ }
2780
2882
  // src/services/files.ts
2781
2883
  const FILE_LIMIT = 2e4;
2782
2884
  const SNIFF_LENGTH = 8e3;
2783
2885
  const OUTPUT_LIMIT$1 = 262144;
2784
2886
  const READ_LIMIT$1 = 1048576;
2785
2887
  const MEDIA_LINK_LIFETIME = 36e5;
2786
- const browseToken = randomBytes(32).toString("base64url");
2888
+ const BROWSE_LINK_LIFETIME = 36e5;
2787
2889
  const mediaLinks = /* @__PURE__ */ new Map();
2890
+ const browseLinks = /* @__PURE__ */ new Map();
2788
2891
  const skipped = /* @__PURE__ */ new Set([
2789
2892
  "node_modules",
2790
2893
  ".git",
@@ -2855,10 +2958,12 @@ function readProjectFile(cwd, file) {
2855
2958
  content: data.toString("utf8")
2856
2959
  };
2857
2960
  }
2858
- function writeProjectFile(cwd, file, content) {
2961
+ function writeProjectFile(cwd, file, content, base) {
2859
2962
  const full = resolveProjectFile(cwd, file);
2860
2963
  if (!statSync(full).isFile()) throw new Error(`${file} is not a file`);
2964
+ if (base !== null && readFileSync(full, "utf8") !== base) return false;
2861
2965
  writeFileSync(full, content);
2966
+ return true;
2862
2967
  }
2863
2968
  function createProjectEntry(cwd, file, folder) {
2864
2969
  const full = resolveNewProjectPath(cwd, file);
@@ -2878,18 +2983,33 @@ function renameProjectEntry(cwd, file, to) {
2878
2983
  function deleteProjectEntry(cwd, file) {
2879
2984
  rmSync(resolveProjectEntry(cwd, file), { recursive: true });
2880
2985
  }
2881
- function linkProjectMedia(cwd, file) {
2986
+ function linkProjectMedia(cwd, file, device) {
2882
2987
  const resolved = path.resolve(realpathSync(cwd), file);
2883
2988
  if (!existsSync(resolved)) throw new Error(`${file} does not exist`);
2884
2989
  const full = realpathSync(resolved);
2885
2990
  const mediaType = mediaTypes.get(path.extname(full).toLowerCase());
2886
2991
  if (mediaType === void 0) throw new Error(`${file} is not an image, video or audio file`);
2887
- return addLink(full, mediaType);
2992
+ return addLink(full, mediaType, device);
2888
2993
  }
2889
- function linkProjectDownload(cwd, file) {
2994
+ function linkProjectDownload(cwd, file, device) {
2890
2995
  const full = resolveProjectFile(cwd, file);
2891
2996
  if (!statSync(full).isFile()) throw new Error(`${file} is not a file`);
2892
- return addLink(full, "application/octet-stream");
2997
+ return addLink(full, "application/octet-stream", device);
2998
+ }
2999
+ function linkBrowse(device, address) {
3000
+ for (const [token, link] of browseLinks) if (link.expiresAt <= Date.now()) browseLinks.delete(token);
3001
+ const token = randomBytes(32).toString("base64url");
3002
+ browseLinks.set(token, {
3003
+ device,
3004
+ address,
3005
+ expiresAt: Date.now() + BROWSE_LINK_LIFETIME
3006
+ });
3007
+ return token;
3008
+ }
3009
+ function canBrowse(token, address) {
3010
+ const link = browseLinks.get(token);
3011
+ if (link === void 0 || link.expiresAt <= Date.now() || link.address !== address) return false;
3012
+ return link.device === null || isPaired(link.device);
2893
3013
  }
2894
3014
  function setBrowseHeaders(response, contentType) {
2895
3015
  response.setHeader("content-type", contentType);
@@ -2903,12 +3023,50 @@ function sniffType(file) {
2903
3023
  closeSync(handle);
2904
3024
  return head.subarray(0, read).includes(0) ? "application/octet-stream" : "text/plain; charset=utf-8";
2905
3025
  }
2906
- function addLink(file, mediaType) {
3026
+ function streamFile(file, size, request, response) {
3027
+ response.setHeader("accept-ranges", "bytes");
3028
+ if (request.headers.range === void 0) {
3029
+ response.setHeader("content-length", size);
3030
+ createReadStream(file).on("error", () => response.destroy()).pipe(response);
3031
+ return;
3032
+ }
3033
+ const range = byteRange(request.headers.range, size);
3034
+ if (range === null) {
3035
+ response.statusCode = 416;
3036
+ response.setHeader("content-range", `bytes */${size}`);
3037
+ response.end();
3038
+ return;
3039
+ }
3040
+ response.statusCode = 206;
3041
+ response.setHeader("content-range", `bytes ${range.start}-${range.end}/${size}`);
3042
+ response.setHeader("content-length", range.end - range.start + 1);
3043
+ createReadStream(file, range).on("error", () => response.destroy()).pipe(response);
3044
+ }
3045
+ function byteRange(header, size) {
3046
+ const match = /^bytes=(?:(\d+)-(\d*)|-(\d+))$/.exec(header);
3047
+ if (match === null) return null;
3048
+ if (match[3] !== void 0) {
3049
+ if (size === 0 || Number(match[3]) === 0) return null;
3050
+ return {
3051
+ start: Math.max(size - Number(match[3]), 0),
3052
+ end: size - 1
3053
+ };
3054
+ }
3055
+ const start = Number(match[1]);
3056
+ const end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
3057
+ if (start > end) return null;
3058
+ return {
3059
+ start,
3060
+ end
3061
+ };
3062
+ }
3063
+ function addLink(file, mediaType, device) {
2907
3064
  for (const [token, link] of mediaLinks) if (link.expiresAt <= Date.now()) mediaLinks.delete(token);
2908
3065
  const token = randomBytes(32).toString("base64url");
2909
3066
  mediaLinks.set(token, {
2910
3067
  file,
2911
3068
  mediaType,
3069
+ device,
2912
3070
  expiresAt: Date.now() + MEDIA_LINK_LIFETIME
2913
3071
  });
2914
3072
  return token;
@@ -2917,6 +3075,7 @@ function findMediaLink(token) {
2917
3075
  if (token === null) return null;
2918
3076
  const link = mediaLinks.get(token);
2919
3077
  if (link === void 0 || link.expiresAt <= Date.now()) return null;
3078
+ if (link.device !== null && !isPaired(link.device)) return null;
2920
3079
  return link;
2921
3080
  }
2922
3081
  function saveUpload(name, data) {
@@ -3070,7 +3229,12 @@ async function gitRepos(cwd) {
3070
3229
  }
3071
3230
  async function commit(cwd, message, andPush) {
3072
3231
  const text = message.trim() === "" ? await commitMessage(cwd, false) : message;
3073
- await git(cwd, ["add", "--all"]);
3232
+ await git(cwd, [
3233
+ "add",
3234
+ "--all",
3235
+ "--",
3236
+ "."
3237
+ ]);
3074
3238
  await git(cwd, [
3075
3239
  "commit",
3076
3240
  "--message",
@@ -3269,7 +3433,9 @@ async function gitStatus(cwd) {
3269
3433
  "--no-optional-locks",
3270
3434
  "status",
3271
3435
  "--porcelain=v2",
3272
- "--branch"
3436
+ "--branch",
3437
+ "--",
3438
+ "."
3273
3439
  ]);
3274
3440
  const status = {
3275
3441
  branch: null,
@@ -3325,14 +3491,16 @@ async function repoDiff(repo, prefix) {
3325
3491
  await diffBase(repo),
3326
3492
  "--no-color",
3327
3493
  "--no-ext-diff",
3494
+ "--relative",
3328
3495
  `--src-prefix=a/${prefix}`,
3329
3496
  `--dst-prefix=b/${prefix}`
3330
3497
  ]);
3331
3498
  const added = (await git(repo, [
3332
3499
  "ls-files",
3333
3500
  "--others",
3334
- "--exclude-standard"
3335
- ])).stdout.split("\n").filter(Boolean).slice(0, UNTRACKED_LIMIT).map((file) => newFilePatch(repo, file, prefix));
3501
+ "--exclude-standard",
3502
+ "-z"
3503
+ ])).stdout.split("\0").filter(Boolean).slice(0, UNTRACKED_LIMIT).map((file) => newFilePatch(repo, file, prefix));
3336
3504
  return [tracked.stdout, ...added].join("");
3337
3505
  }
3338
3506
  async function diffBase(repo) {
@@ -5750,7 +5918,8 @@ async function claudeMessages(sessionId, dir) {
5750
5918
  });
5751
5919
  }
5752
5920
  async function claudeCandidates(query) {
5753
- const words = searchWords(query).filter((word) => word.length >= 3 && !/["\\]/.test(word));
5921
+ const phrase = exactPhrase(query);
5922
+ const words = phrase !== null ? [JSON.stringify(phrase).slice(1, -1)] : searchWords(query).filter((word) => word.length >= 3 && !/["\\]/.test(word));
5754
5923
  if (words.length === 0) return null;
5755
5924
  try {
5756
5925
  const lists = await Promise.all(words.map((word) => sessionsWith(word)));
@@ -5783,7 +5952,7 @@ async function sessionsWith(word) {
5783
5952
  })).stdout.split("\n").filter(Boolean).map((file) => path.basename(file, ".jsonl"));
5784
5953
  }
5785
5954
  async function searchCodex(query, sessions) {
5786
- const longest = [...searchWords(query)].sort((a, b) => b.length - a.length)[0];
5955
+ const longest = exactPhrase(query) ?? [...searchWords(query)].sort((a, b) => b.length - a.length)[0];
5787
5956
  const known = new Map(sessions.map((session) => [session.id, session]));
5788
5957
  const found = (await codexSearch(longest)).filter((result) => known.has(result.thread.id)).sort((a, b) => b.thread.updatedAt - a.thread.updatedAt);
5789
5958
  const verified = await readAndMatch(query, found.slice(0, CODEX_READ_LIMIT).map((result) => ({ id: result.thread.id })), codexMessages);
@@ -6053,7 +6222,7 @@ async function showFile(cwd, ref, file) {
6053
6222
  const output = await git(cwd, [
6054
6223
  "cat-file",
6055
6224
  "blob",
6056
- `${ref}:${file}`
6225
+ `${ref}:./${file}`
6057
6226
  ]);
6058
6227
  if (output.stdout.length > SHOW_LIMIT || output.stdout.includes("\0")) return null;
6059
6228
  return output.stdout;
@@ -6086,6 +6255,7 @@ async function commitFiles(cwd, hash, parent) {
6086
6255
  "--name-status",
6087
6256
  "-M",
6088
6257
  "-z",
6258
+ "--relative",
6089
6259
  parent ?? (await git(cwd, [
6090
6260
  "hash-object",
6091
6261
  "-t",
@@ -6169,7 +6339,7 @@ async function runAction(cwd, action) {
6169
6339
  await git(cwd, [
6170
6340
  "update-index",
6171
6341
  "--cacheinfo",
6172
- `${entry.stdout.split(" ")[0]},${hash},${action.path}`
6342
+ `${entry.stdout.split(" ")[0]},${hash},${await repoPrefix(cwd)}${action.path}`
6173
6343
  ]);
6174
6344
  return;
6175
6345
  }
@@ -6209,7 +6379,12 @@ async function runAction(cwd, action) {
6209
6379
  return;
6210
6380
  }
6211
6381
  if (action.kind === "commit") {
6212
- if (action.all) await git(cwd, ["add", "--all"]);
6382
+ if (action.all) await git(cwd, [
6383
+ "add",
6384
+ "--all",
6385
+ "--",
6386
+ "."
6387
+ ]);
6213
6388
  await git(cwd, [
6214
6389
  "commit",
6215
6390
  ...action.amend ? ["--amend"] : [],
@@ -6263,6 +6438,9 @@ async function network(cwd, args) {
6263
6438
  throw gitError(error);
6264
6439
  }
6265
6440
  }
6441
+ async function repoPrefix(cwd) {
6442
+ return (await git(cwd, ["rev-parse", "--show-prefix"])).stdout.trim();
6443
+ }
6266
6444
  async function repoChanges(cwd) {
6267
6445
  const output = await git(cwd, [
6268
6446
  "--no-optional-locks",
@@ -6270,8 +6448,11 @@ async function repoChanges(cwd) {
6270
6448
  "--porcelain=v2",
6271
6449
  "--branch",
6272
6450
  "--untracked-files=all",
6273
- "-z"
6451
+ "-z",
6452
+ "--",
6453
+ "."
6274
6454
  ]);
6455
+ const prefix = await repoPrefix(cwd);
6275
6456
  const repo = {
6276
6457
  head: null,
6277
6458
  branch: null,
@@ -6294,19 +6475,19 @@ async function repoChanges(cwd) {
6294
6475
  repo.behind = -Number(fields[3]);
6295
6476
  }
6296
6477
  if (fields[0] === "?") repo.changes.push({
6297
- path: tokens[index].slice(2),
6478
+ path: tokens[index].slice(2 + prefix.length),
6298
6479
  from: null,
6299
6480
  status: "U"
6300
6481
  });
6301
6482
  if (fields[0] === "u") repo.merge.push({
6302
- path: fields.slice(10).join(" "),
6483
+ path: fields.slice(10).join(" ").slice(prefix.length),
6303
6484
  from: null,
6304
6485
  status: "!"
6305
6486
  });
6306
6487
  if (fields[0] === "1" || fields[0] === "2") {
6307
6488
  const renamed = fields[0] === "2";
6308
- const file = fields.slice(renamed ? 9 : 8).join(" ");
6309
- const from = renamed ? tokens[index + 1] : null;
6489
+ const file = fields.slice(renamed ? 9 : 8).join(" ").slice(prefix.length);
6490
+ const from = renamed ? tokens[index + 1].slice(prefix.length) : null;
6310
6491
  if (fields[1][0] !== ".") repo.staged.push({
6311
6492
  path: file,
6312
6493
  from,
@@ -6898,6 +7079,7 @@ const active = /* @__PURE__ */ new Map();
6898
7079
  const terminals = /* @__PURE__ */ new Map();
6899
7080
  const connections = /* @__PURE__ */ new Map();
6900
7081
  const paused = /* @__PURE__ */ new Set();
7082
+ const held = /* @__PURE__ */ new Set();
6901
7083
  let nextTab = 0;
6902
7084
  let draining = null;
6903
7085
  async function terminalRequest(client, message) {
@@ -6957,6 +7139,8 @@ function attach(client) {
6957
7139
  instance
6958
7140
  });
6959
7141
  for (const tab of tabs) forward$1(connection, tab.id, true, Buffer.concat(terminals.get(tab.id)?.history ?? []));
7142
+ for (const pty of held) pty.resume();
7143
+ held.clear();
6960
7144
  }
6961
7145
  function resize(connection, message) {
6962
7146
  const terminal = terminals.get(message.id);
@@ -7066,9 +7250,14 @@ async function createTab(projectId) {
7066
7250
  });
7067
7251
  active.set(projectId ?? "global", id);
7068
7252
  pty.onData((data) => output(id, terminal, Buffer.from(data)));
7253
+ if (connections.size === 0) {
7254
+ pty.pause();
7255
+ held.add(pty);
7256
+ }
7069
7257
  pty.onExit(() => {
7070
7258
  terminals.delete(id);
7071
7259
  paused.delete(pty);
7260
+ held.delete(pty);
7072
7261
  if (closeTab(id)) publish();
7073
7262
  });
7074
7263
  }
@@ -7408,10 +7597,7 @@ const handlers = {
7408
7597
  "files.list": (params) => listFiles(projectPath(params.projectId)),
7409
7598
  "files.entries": (params) => listEntries(projectPath(params.projectId)),
7410
7599
  "files.read": (params) => readProjectFile(projectPath(params.projectId), params.path),
7411
- "files.write": (params) => {
7412
- writeProjectFile(projectPath(params.projectId), params.path, params.content);
7413
- return null;
7414
- },
7600
+ "files.write": (params) => writeProjectFile(projectPath(params.projectId), params.path, params.content, params.base),
7415
7601
  "files.create": (params) => {
7416
7602
  createProjectEntry(projectPath(params.projectId), params.path, params.folder);
7417
7603
  return null;
@@ -7424,10 +7610,10 @@ const handlers = {
7424
7610
  deleteProjectEntry(projectPath(params.projectId), params.path);
7425
7611
  return null;
7426
7612
  },
7427
- "files.media": (params) => linkProjectMedia(projectPath(params.projectId), params.path),
7428
- "files.download": (params) => linkProjectDownload(projectPath(params.projectId), params.path),
7613
+ "files.media": (params, client) => linkProjectMedia(projectPath(params.projectId), params.path, client.device),
7614
+ "files.download": (params, client) => linkProjectDownload(projectPath(params.projectId), params.path, client.device),
7429
7615
  "files.upload": (params) => saveUpload(params.name, Buffer.from(params.data, "base64")),
7430
- "files.browse": () => browseToken,
7616
+ "files.browse": (_params, client) => linkBrowse(client.device, client.address),
7431
7617
  "browser.open": (params) => openSite(params.url, params.fromPage),
7432
7618
  "tasks.output": (params) => taskOutput(params.sessionId, params.path),
7433
7619
  "git.status": (params) => gitRepos(projectPath(params.projectId)),
@@ -7484,9 +7670,11 @@ const handlers = {
7484
7670
  "terminal.busy": (params) => busyTerminal(params.id),
7485
7671
  "terminal.paste": (params) => savePastedImage(params.type, Buffer.from(params.data, "base64"))
7486
7672
  };
7487
- function connectClient(socket) {
7673
+ function connectClient(socket, device, address) {
7488
7674
  const client = {
7489
7675
  socket,
7676
+ device,
7677
+ address,
7490
7678
  heardAt: Date.now(),
7491
7679
  opening: null,
7492
7680
  sessionId: null,
@@ -8362,6 +8550,7 @@ const TARGET = /^(https?)\/([^/?#]+)(.*)$/;
8362
8550
  const SANDBOX = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads allow-presentation allow-pointer-lock";
8363
8551
  const UPSTREAM_TIMEOUT = 6e4;
8364
8552
  const MAX_BODY = 8388608;
8553
+ const MAX_PAGE = 33554432;
8365
8554
  const MAX_HEADERS = 262144;
8366
8555
  const REQUEST_HEADERS_DROPPED = /* @__PURE__ */ new Set([
8367
8556
  "accept-encoding",
@@ -8411,6 +8600,7 @@ const RESPONSE_HEADERS_DROPPED = /* @__PURE__ */ new Set([
8411
8600
  "service-worker-allowed",
8412
8601
  "set-cookie",
8413
8602
  "strict-transport-security",
8603
+ "trailer",
8414
8604
  "transfer-encoding",
8415
8605
  "x-frame-options"
8416
8606
  ]);
@@ -8506,10 +8696,17 @@ async function forward(site, target, request, response) {
8506
8696
  request.pipe(upstream);
8507
8697
  const incoming = (await answer)[0];
8508
8698
  upstream.setTimeout(0);
8699
+ const status = incoming.statusCode;
8700
+ if (status === void 0 || status < 200 || status > 999) throw new Error(`${target.origin} sent an invalid status`);
8509
8701
  for (const cookie of incoming.headers["set-cookie"] ?? []) await site.jar.setCookie(cookie, target.href, { ignoreError: true });
8510
8702
  const kind = rewriteKind(incoming, request);
8511
- response.statusCode = incoming.statusCode ?? 502;
8703
+ response.statusCode = status;
8512
8704
  copyHeaders(incoming, response, kind !== null);
8705
+ if (kind === "html") {
8706
+ response.removeHeader("last-modified");
8707
+ response.removeHeader("expires");
8708
+ response.setHeader("cache-control", "no-store, no-transform");
8709
+ }
8513
8710
  const location = incoming.headers.location;
8514
8711
  if (location !== void 0) {
8515
8712
  const next = URL.parse(location, target);
@@ -8517,6 +8714,7 @@ async function forward(site, target, request, response) {
8517
8714
  response.setHeader("location", proxiedPath(site.key, next));
8518
8715
  }
8519
8716
  if (kind === null) {
8717
+ response.flushHeaders();
8520
8718
  await pipeline(incoming, response);
8521
8719
  return;
8522
8720
  }
@@ -8616,12 +8814,26 @@ function copyHeaders(incoming, response, rewritten) {
8616
8814
  }
8617
8815
  async function readBody(incoming) {
8618
8816
  const encoding = incoming.headers["content-encoding"];
8619
- if (encoding === void 0 || encoding === "identity") return buffer(incoming);
8620
- if (encoding === "gzip") return buffer(incoming.pipe(zlib.createGunzip()));
8621
- if (encoding === "deflate") return buffer(incoming.pipe(zlib.createInflate()));
8622
- if (encoding === "br") return buffer(incoming.pipe(zlib.createBrotliDecompress()));
8817
+ if (encoding === void 0 || encoding === "identity") return readLimited(incoming, MAX_PAGE);
8818
+ const decoder = createDecoder(encoding);
8819
+ return (await Promise.all([readLimited(decoder, MAX_PAGE), pipeline(incoming, decoder)]))[0];
8820
+ }
8821
+ function createDecoder(encoding) {
8822
+ if (encoding === "gzip") return zlib.createGunzip();
8823
+ if (encoding === "deflate") return zlib.createInflate();
8824
+ if (encoding === "br") return zlib.createBrotliDecompress();
8623
8825
  throw new Error(`the site sent unknown compression: ${encoding}`);
8624
8826
  }
8827
+ async function readLimited(source, limit) {
8828
+ const chunks = [];
8829
+ let size = 0;
8830
+ for await (const chunk of source) {
8831
+ size += chunk.length;
8832
+ if (size > limit) throw new Error("This page is too large");
8833
+ chunks.push(chunk);
8834
+ }
8835
+ return Buffer.concat(chunks);
8836
+ }
8625
8837
  function decodeBody(body, incoming) {
8626
8838
  const declared = /charset=["']?([\w-]+)/i.exec(incoming.headers["content-type"] ?? "")?.[1] ?? /<meta[^>]+charset=["']?([\w-]+)/i.exec(body.subarray(0, 1024).toString("latin1"))?.[1] ?? "utf-8";
8627
8839
  try {
@@ -8652,11 +8864,7 @@ async function savePageData(site, kind, request, response) {
8652
8864
  fail(response, 405, "use POST");
8653
8865
  return;
8654
8866
  }
8655
- const body = await buffer(request);
8656
- if (body.length > MAX_BODY) {
8657
- fail(response, 413, "too much page data");
8658
- return;
8659
- }
8867
+ const body = await readLimited(request, MAX_BODY);
8660
8868
  const data = JSON.parse(body.toString());
8661
8869
  if (kind === "cookie") {
8662
8870
  const write = pageWrite.parse(data);
@@ -8681,54 +8889,6 @@ function fail(response, status, message) {
8681
8889
  response.setHeader("content-type", "text/plain; charset=utf-8");
8682
8890
  response.end(message);
8683
8891
  }
8684
- // src/storage/auth.ts
8685
- const CODE_LIFETIME = 3e5;
8686
- const DEVICE_LIFETIME = 2592e6;
8687
- function addCode(name) {
8688
- const code = randomBytes(16).toString("base64url");
8689
- db.delete(pairingCodes).where(lte(pairingCodes.expiresAt, Date.now())).run();
8690
- db.insert(pairingCodes).values({
8691
- hash: hash(code),
8692
- name,
8693
- expiresAt: Date.now() + CODE_LIFETIME
8694
- }).run();
8695
- return code;
8696
- }
8697
- function redeemCode(code) {
8698
- const found = db.delete(pairingCodes).where(eq(pairingCodes.hash, hash(code))).returning().get();
8699
- if (found === void 0 || found.expiresAt <= Date.now()) return null;
8700
- const token = randomBytes(32).toString("base64url");
8701
- const now = Date.now();
8702
- db.insert(devices).values({
8703
- hash: hash(token),
8704
- name: found.name,
8705
- createdAt: now,
8706
- expiresAt: now + DEVICE_LIFETIME
8707
- }).run();
8708
- return token;
8709
- }
8710
- function findDevice(token) {
8711
- const found = db.select().from(devices).where(and(eq(devices.hash, hash(token)), gt(devices.expiresAt, Date.now()))).get();
8712
- return found === void 0 ? null : found.hash;
8713
- }
8714
- function isPaired(device) {
8715
- return db.select().from(devices).where(and(eq(devices.hash, device), gt(devices.expiresAt, Date.now()))).get() !== void 0;
8716
- }
8717
- function hasDevices() {
8718
- return db.select().from(devices).where(gt(devices.expiresAt, Date.now())).limit(1).get() !== void 0;
8719
- }
8720
- function readDevices() {
8721
- return db.select().from(devices).orderBy(devices.createdAt).all();
8722
- }
8723
- function removeDevices(hashes) {
8724
- db.delete(devices).where(inArray(devices.hash, hashes)).run();
8725
- }
8726
- function recordUse(device) {
8727
- db.update(devices).set({ lastUsedAt: Date.now() }).where(eq(devices.hash, device)).run();
8728
- }
8729
- function hash(secret) {
8730
- return createHash("sha256").update(secret).digest("hex");
8731
- }
8732
8892
  // src/main.ts
8733
8893
  const UNPAIR_CHECK = 5e3;
8734
8894
  const TICKET_LIFETIME = 6e4;
@@ -8794,7 +8954,25 @@ const publicDir = path.join(import.meta.dirname, "..", "..", "client", "dist");
8794
8954
  const code = args.auth && args.pair && !hasDevices() ? addCode(null) : null;
8795
8955
  const tickets = /* @__PURE__ */ new Map();
8796
8956
  const server = createServer((request, response) => {
8797
- const url = new URL(request.url ?? "/", "http://localhost");
8957
+ try {
8958
+ answerRequest(request, response);
8959
+ } catch (error) {
8960
+ console.warn(`a request failed: ${error}`);
8961
+ if (response.headersSent) {
8962
+ response.destroy();
8963
+ return;
8964
+ }
8965
+ response.statusCode = 500;
8966
+ response.end("request failed");
8967
+ }
8968
+ });
8969
+ function answerRequest(request, response) {
8970
+ const url = URL.parse(request.url ?? "/", "http://localhost");
8971
+ if (url === null) {
8972
+ response.statusCode = 400;
8973
+ response.end("bad url");
8974
+ return;
8975
+ }
8798
8976
  if (url.pathname === "/api/media") {
8799
8977
  serveMedia(url.searchParams.get("token"), request, response);
8800
8978
  return;
@@ -8821,25 +8999,30 @@ const server = createServer((request, response) => {
8821
8999
  return;
8822
9000
  }
8823
9001
  serveFile(url.pathname, request, response);
8824
- });
9002
+ }
8825
9003
  const sockets = new WebSocketServer({
8826
9004
  noServer: true,
8827
9005
  perMessageDeflate: true
8828
9006
  });
8829
9007
  const paired = /* @__PURE__ */ new Map();
8830
9008
  server.on("upgrade", (request, socket, head) => {
8831
- const url = new URL(request.url ?? "/", "http://localhost");
9009
+ const url = URL.parse(request.url ?? "/", "http://localhost");
9010
+ if (url === null) {
9011
+ socket.destroy();
9012
+ return;
9013
+ }
8832
9014
  if (url.pathname.startsWith("/api/proxy/")) {
8833
9015
  proxySocket(request, socket, head);
8834
9016
  return;
8835
9017
  }
8836
9018
  const device = args.auth ? redeemTicket(url.searchParams.get("ticket")) : null;
8837
- if (url.pathname !== "/ws" || args.auth && device === null) {
9019
+ const remoteAddress = request.socket.remoteAddress;
9020
+ if (url.pathname !== "/ws" || remoteAddress === void 0 || args.auth && device === null) {
8838
9021
  socket.destroy();
8839
9022
  return;
8840
9023
  }
8841
9024
  sockets.handleUpgrade(request, socket, head, (client) => {
8842
- connectClient(client);
9025
+ connectClient(client, device, remoteAddress);
8843
9026
  if (device === null) return;
8844
9027
  paired.set(client, device);
8845
9028
  client.on("close", () => paired.delete(client));
@@ -8948,7 +9131,7 @@ function serveBrowse(pathname, request, response) {
8948
9131
  }
8949
9132
  const rest = pathname.slice(12);
8950
9133
  const slash = rest.indexOf("/");
8951
- if (slash === -1 || rest.slice(0, slash) !== browseToken) {
9134
+ if (slash === -1 || !canBrowse(rest.slice(0, slash), request.socket.remoteAddress)) {
8952
9135
  response.statusCode = 404;
8953
9136
  response.end("This browser link expired");
8954
9137
  return;
@@ -8961,13 +9144,23 @@ function serveBrowse(pathname, request, response) {
8961
9144
  response.end("bad path");
8962
9145
  return;
8963
9146
  }
8964
- const stats = statSync(file, { throwIfNoEntry: false });
8965
- if (stats === void 0 || !stats.isFile()) {
8966
- response.statusCode = 404;
8967
- response.end(`${file} is not a file`);
9147
+ let stats;
9148
+ let contentType;
9149
+ try {
9150
+ stats = statSync(file, { throwIfNoEntry: false });
9151
+ if (stats === void 0 || !stats.isFile()) {
9152
+ response.statusCode = 404;
9153
+ response.end(`${file} is not a file`);
9154
+ return;
9155
+ }
9156
+ accessSync(file, constants.R_OK);
9157
+ contentType = browseTypes.get(path.extname(file).toLowerCase()) ?? sniffType(file);
9158
+ } catch (error) {
9159
+ response.statusCode = 500;
9160
+ response.end(`Could not read ${file}: ${error}`);
8968
9161
  return;
8969
9162
  }
8970
- setBrowseHeaders(response, browseTypes.get(path.extname(file).toLowerCase()) ?? sniffType(file));
9163
+ setBrowseHeaders(response, contentType);
8971
9164
  streamFile(file, stats.size, request, response);
8972
9165
  }
8973
9166
  async function answerOpen(request, response) {
@@ -8998,43 +9191,6 @@ async function answerOpen(request, response) {
8998
9191
  response.statusCode = 204;
8999
9192
  response.end();
9000
9193
  }
9001
- function streamFile(file, size, request, response) {
9002
- response.setHeader("accept-ranges", "bytes");
9003
- if (request.headers.range === void 0) {
9004
- response.setHeader("content-length", size);
9005
- createReadStream(file).on("error", () => response.destroy()).pipe(response);
9006
- return;
9007
- }
9008
- const range = byteRange(request.headers.range, size);
9009
- if (range === null) {
9010
- response.statusCode = 416;
9011
- response.setHeader("content-range", `bytes */${size}`);
9012
- response.end();
9013
- return;
9014
- }
9015
- response.statusCode = 206;
9016
- response.setHeader("content-range", `bytes ${range.start}-${range.end}/${size}`);
9017
- response.setHeader("content-length", range.end - range.start + 1);
9018
- createReadStream(file, range).on("error", () => response.destroy()).pipe(response);
9019
- }
9020
- function byteRange(header, size) {
9021
- const match = /^bytes=(?:(\d+)-(\d*)|-(\d+))$/.exec(header);
9022
- if (match === null) return null;
9023
- if (match[3] !== void 0) {
9024
- if (size === 0 || Number(match[3]) === 0) return null;
9025
- return {
9026
- start: Math.max(size - Number(match[3]), 0),
9027
- end: size - 1
9028
- };
9029
- }
9030
- const start = Number(match[1]);
9031
- const end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
9032
- if (start > end) return null;
9033
- return {
9034
- start,
9035
- end
9036
- };
9037
- }
9038
9194
  function answerJson(response, body) {
9039
9195
  response.setHeader("content-type", "application/json");
9040
9196
  response.end(JSON.stringify(body));