@treeport/treeport 0.5.0 → 0.6.1

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,11 +1,13 @@
1
- import { $ as repositoryTerminalPresetSchema, At as terminalSizeSchema, B as createWorktreeSchema, Ct as parseTerminalProgress, Dt as terminalLegacyTakeControlSchema, Et as terminalInputSchema, G as openWebPanelSchema, H as deleteWebPanelStorageSchema, I as browseDirectoryQuerySchema, J as packageReloadSchema, K as packageInstallSchema, L as createTerminalPresetSchema, M as parseDurationMs, Ot as terminalOutputAckSchema, P as TERMINAL_MAX_UPLOAD_BYTES, Q as removeWorktreeSchema, R as createTerminalSchema, St as parseTerminalAuth, Tt as terminalBinarySchema, U as formatCommandLine, V as deleteTerminalPresetSchema, W as getWebPanelStorageSchema, X as packageUpdateSchema, Y as packageRemoveSchema, Z as registerProjectSchema, _ as serviceStatus, _t as TERMINAL_SCROLL_EXIT_SEQUENCE, a as readLocalUpdateProgress, at as updateTerminalPresetSchema, bt as TERMINAL_SELECTION_START_SEQUENCE, c as createUpdateStartupReporter, dt as TERMINAL_CONTROLLER_GRACE_MS, et as repositoryTerminalPresetsFileSchema, ft as TERMINAL_MAX_CLIENT_MESSAGE_BYTES, gt as TERMINAL_OUTPUT_STALL_TIMEOUT_MS, i as isCanonicalTreeportVersion, it as updateProjectSchema, j as assertLoopbackHost, jt as terminalTakeControlSchema, kt as terminalResizeSchema, n as compareTreeportVersions, nt as setWebPanelStorageSchema, o as resolveLatestTreeportRelease, ot as updateTerminalSchema, q as packageProjectQuerySchema, r as inspectLocalUpdateInstallation, rt as terminalCaptureQuerySchema, st as webPanelInputSchema, tt as requestWorkspaceOpenSchema, ut as SOCKET_IO_PATH, vt as TERMINAL_SELECTION_CLEAR_SEQUENCE, wt as terminalBellAcknowledgementSchema, xt as TERMINAL_SELECTION_STOP_SEQUENCE, yt as TERMINAL_SELECTION_RESTORE_SEQUENCE, z as createWebPanelSchema } from "../../update-BW-a6Bd-.js";
1
+ import { $ as TERMINAL_SELECTION_STOP_SEQUENCE, A as terminalCaptureQuerySchema, C as readTreeFileSchema, D as repositoryTerminalPresetsFileSchema, E as repositoryTerminalPresetSchema, F as updateTerminalPresetSchema, H as SOCKET_IO_PATH, I as updateTerminalSchema, J as TERMINAL_OUTPUT_STALL_TIMEOUT_MS, L as updateWebPanelPermissionGrantSchema, M as treeContextValuesSchema, N as treeFilePathSchema, O as requestWorkspaceOpenSchema, P as updateProjectSchema, Q as TERMINAL_SELECTION_START_SEQUENCE, R as webPanelInputSchema, S as packageUpdateSchema, T as removeWorktreeSchema, U as TERMINAL_CONTROLLER_GRACE_MS, W as TERMINAL_MAX_CLIENT_MESSAGE_BYTES, X as TERMINAL_SELECTION_CLEAR_SEQUENCE, Y as TERMINAL_SCROLL_EXIT_SEQUENCE, Z as TERMINAL_SELECTION_RESTORE_SEQUENCE, _ as openWebPanelSchema, _t as parseBrowserAuth, at as terminalLegacyTakeControlSchema, b as packageReloadSchema, bt as parseBrowserOwnerClientMessage, c as createTerminalPresetSchema, ct as terminalSizeSchema, d as createWorktreeSchema, et as parseTerminalAuth, f as deleteTerminalPresetSchema, ft as browserAgentCommandSchema, g as openBrowserPanelFromTerminalSchema, gt as browserUrlSchema, h as getWebPanelStorageSchema, ht as browserTicketRequestSchema, i as TREE_FILE_MAX_BYTES, it as terminalInputSchema, j as treeContextFieldDefinitionSchema, k as setWebPanelStorageSchema, l as createTerminalSchema, lt as terminalTakeControlSchema, m as formatCommandLine, mt as browserOwnerTicketRequestSchema, n as TERMINAL_MAX_UPLOAD_BYTES, nt as terminalBellAcknowledgementSchema, o as browseDirectoryQuerySchema, ot as terminalOutputAckSchema, p as deleteWebPanelStorageSchema, pt as browserOwnerEndpointSchema, r as TREE_FILE_LIST_MAX_ENTRIES, rt as terminalBinarySchema, s as createBrowserPanelSchema, st as terminalResizeSchema, tt as parseTerminalProgress, u as createWebPanelSchema, ut as webPanelPermissionSchema, v as packageInstallSchema, vt as parseBrowserClientMessage, w as registerProjectSchema, x as packageRemoveSchema, y as packageProjectQuerySchema, yt as parseBrowserOwnerAuth, z as writeTreeFileSchema } from "../../dist-Crk_Xr82.js";
2
+ import { M as parseDurationMs, _ as serviceStatus, a as readLocalUpdateProgress, c as createUpdateStartupReporter, i as isCanonicalTreeportVersion, j as assertLoopbackHost, n as compareTreeportVersions, o as resolveLatestTreeportRelease, r as inspectLocalUpdateInstallation } from "../../update-qVp7yL5D.js";
2
3
  import { n as prepareShellIntegration } from "../../shell-integration-Be_c91lw.js";
4
+ import { createRequire } from "node:module";
3
5
  import fs from "node:fs/promises";
4
6
  import path from "node:path";
5
7
  import { z } from "zod";
6
8
  import { execFile, spawn } from "node:child_process";
7
9
  import crypto from "node:crypto";
8
- import fsSync, { existsSync } from "node:fs";
10
+ import fsSync, { constants, existsSync } from "node:fs";
9
11
  import os from "node:os";
10
12
  import { fileURLToPath, pathToFileURL } from "node:url";
11
13
  import { createServer } from "node:http";
@@ -34,6 +36,7 @@ import { Hono } from "hono";
34
36
  import { HTTPException } from "hono/http-exception";
35
37
  import { requestId } from "hono/request-id";
36
38
  import { validator } from "hono/validator";
39
+ import { getConnInfo } from "@hono/node-server/conninfo";
37
40
  import { serveStatic } from "@hono/node-server/serve-static";
38
41
  import { zValidator } from "@hono/zod-validator";
39
42
  import * as Fiber from "effect/Fiber";
@@ -353,10 +356,12 @@ function loadConfig(env = process.env) {
353
356
  //#endregion
354
357
  //#region src/server/core/database-schema.ts
355
358
  var database_schema_exports = /* @__PURE__ */ __exportAll({
359
+ browserPanels: () => browserPanels,
356
360
  operations: () => operations,
357
361
  projects: () => projects,
358
362
  terminalBellStates: () => terminalBellStates,
359
363
  terminalPresets: () => terminalPresets,
364
+ webPanelPermissionGrants: () => webPanelPermissionGrants,
360
365
  webPanelStorage: () => webPanelStorage,
361
366
  webPanels: () => webPanels,
362
367
  worktrees: () => worktrees
@@ -401,6 +406,7 @@ const worktrees = sqliteTable("worktrees", {
401
406
  kind: text().notNull(),
402
407
  tmuxSocketName: text("tmux_socket_name").notNull().unique(),
403
408
  managedWrapperPath: text("managed_wrapper_path"),
409
+ treeContextJson: text("tree_context_json").notNull().default("{}"),
404
410
  prState: text("pr_state").notNull().default("unknown"),
405
411
  prNumber: integer("pr_number"),
406
412
  prUrl: text("pr_url"),
@@ -425,6 +431,14 @@ const terminalBellStates = sqliteTable("terminal_bell_states", {
425
431
  occurredAt: text("occurred_at").notNull(),
426
432
  unread: integer().notNull()
427
433
  }, (table) => [check("terminal_bell_states_sequence_check", sql`${table.sequence} > 0`), check("terminal_bell_states_unread_check", sql`${table.unread} IN (0,1)`)]);
434
+ const browserPanels = sqliteTable("browser_panels", {
435
+ id: text().primaryKey(),
436
+ worktreeId: text("worktree_id").notNull().references(() => worktrees.id, { onDelete: "cascade" }),
437
+ title: text().notNull(),
438
+ url: text().notNull(),
439
+ createdAt: text("created_at").notNull(),
440
+ updatedAt: text("updated_at").notNull()
441
+ }, (table) => [index("browser_panels_worktree_order_idx").on(table.worktreeId, table.createdAt, table.id)]);
428
442
  const webPanels = sqliteTable("web_panels", {
429
443
  id: text().primaryKey(),
430
444
  worktreeId: text("worktree_id").notNull().references(() => worktrees.id, { onDelete: "cascade" }),
@@ -435,6 +449,13 @@ const webPanels = sqliteTable("web_panels", {
435
449
  createdAt: text("created_at").notNull(),
436
450
  updatedAt: text("updated_at").notNull()
437
451
  }, (table) => [index("web_panels_worktree_order_idx").on(table.worktreeId, table.createdAt, table.id)]);
452
+ const webPanelPermissionGrants = sqliteTable("web_panel_permission_grants", {
453
+ sourceKey: text("source_key").primaryKey(),
454
+ definitionId: text("definition_id").notNull(),
455
+ permissionsJson: text("permissions_json").notNull(),
456
+ grantedAt: text("granted_at").notNull(),
457
+ updatedAt: text("updated_at").notNull()
458
+ }, (table) => [index("web_panel_permission_definition_idx").on(table.definitionId)]);
438
459
  const webPanelStorage = sqliteTable("web_panel_storage", {
439
460
  panelId: text("panel_id").notNull().references(() => webPanels.id, { onDelete: "cascade" }),
440
461
  key: text().notNull(),
@@ -764,8 +785,10 @@ const BACKUP_RETENTION = 2;
764
785
  const createOperationRequestSchema = z.strictObject({
765
786
  name: z.string(),
766
787
  base: z.enum(["default", "current"]),
788
+ context: treeContextValuesSchema.optional(),
767
789
  initialTerminal: z.strictObject({
768
790
  name: z.string(),
791
+ initialTitle: z.string().optional(),
769
792
  argv: z.array(z.string()).optional(),
770
793
  returnToShell: z.boolean().optional(),
771
794
  initialSize: z.strictObject({
@@ -1642,6 +1665,16 @@ var GitAdapter = class {
1642
1665
  async dirtyState(cwd) {
1643
1666
  return (await this.dirtyStatus(cwd)).dirty;
1644
1667
  }
1668
+ async worktreeFiles(cwd) {
1669
+ const result = await this.checked(cwd, [
1670
+ "ls-files",
1671
+ "--cached",
1672
+ "--others",
1673
+ "--exclude-standard",
1674
+ "-z"
1675
+ ]);
1676
+ return [...new Set(result.stdout.split("\0").filter(Boolean))].sort();
1677
+ }
1645
1678
  async worktreeDiff(cwd, defaultBranch) {
1646
1679
  const candidates = [`origin/${defaultBranch}`, defaultBranch];
1647
1680
  let baseRef = "";
@@ -2066,7 +2099,7 @@ z.unknown();
2066
2099
  const manifestPatternsSchema = z.array(z.string());
2067
2100
  const webPanelManifestEntrySchema = z.union([z.string(), z.strictObject({
2068
2101
  source: z.string(),
2069
- permissions: z.array(z.enum(["same-origin"])).optional()
2102
+ permissions: z.array(webPanelPermissionSchema).optional()
2070
2103
  })]);
2071
2104
  const EMPTY_SETTINGS = {
2072
2105
  raw: {},
@@ -2478,19 +2511,20 @@ var PackageSystem = class {
2478
2511
  this.validateManifestPatterns([parsedSource.data], "webPanels", packageJsonPath);
2479
2512
  return {
2480
2513
  source: parsedSource.data,
2481
- allowSameOrigin: false
2514
+ permissions: []
2482
2515
  };
2483
2516
  }
2484
2517
  const { source, permissions = [] } = z.strictObject({
2485
2518
  source: z.string(),
2486
- permissions: z.array(z.enum(["same-origin"])).optional()
2519
+ permissions: z.array(webPanelPermissionSchema).optional()
2487
2520
  }).parse(parsed.data);
2488
2521
  if (source.startsWith("!")) throw new Error(`${packageJsonPath} contains an invalid web panel definition`);
2489
2522
  this.validateManifestPatterns([source], "webPanels", packageJsonPath);
2490
- if (new Set(permissions).size !== permissions.length) throw new Error(`${packageJsonPath} contains an invalid web panel permission`);
2523
+ const uniquePermissions = new Set(permissions);
2524
+ if (uniquePermissions.size !== permissions.length) throw new Error(`${packageJsonPath} contains an invalid web panel permission`);
2491
2525
  return {
2492
2526
  source,
2493
- allowSameOrigin: permissions.includes("same-origin")
2527
+ permissions: [...uniquePermissions]
2494
2528
  };
2495
2529
  });
2496
2530
  }
@@ -2628,13 +2662,15 @@ var PackageSystem = class {
2628
2662
  })).then((values) => values.find(Boolean)) : void 0;
2629
2663
  const webPanels = applyNormalFilter(panelCandidates.map((candidate) => {
2630
2664
  const resourceId = encodeURIComponent(path.posix.basename(candidate.relativePath));
2631
- const allowSameOrigin = manifest?.webPanels.some((entry) => entry.allowSameOrigin && this.manifestAllows(candidate.relativePath, [entry.source], "web-panel")) ?? false;
2665
+ const permissions = [...new Set((manifest?.webPanels ?? []).filter((entry) => this.manifestAllows(candidate.relativePath, [entry.source], "web-panel")).flatMap((entry) => entry.permissions))];
2632
2666
  const resolved = {
2633
2667
  definition: {
2634
2668
  id: `package:${parsed.packageId}:web-panel:${resourceId}`,
2635
2669
  title: titleFromPath(candidate.relativePath),
2636
2670
  source: metadata,
2637
- sandbox: { allowSameOrigin }
2671
+ permissions,
2672
+ permissionsGranted: permissions.length === 0,
2673
+ sandbox: { allowSameOrigin: permissions.includes("same-origin") }
2638
2674
  },
2639
2675
  root: candidate.root,
2640
2676
  entry: "index.html",
@@ -3276,6 +3312,120 @@ async function loadRepositoryTerminalPresets(projectId, worktreePath) {
3276
3312
  };
3277
3313
  }
3278
3314
  //#endregion
3315
+ //#region src/server/core/tree-context.ts
3316
+ async function loadTreeContextFields({ dataDir, projectRoot }) {
3317
+ const diagnostics = [];
3318
+ const definitionsByScope = [];
3319
+ const settingsFiles = [{
3320
+ scope: "global",
3321
+ path: path.join(dataDir, "settings.json")
3322
+ }, {
3323
+ scope: "project",
3324
+ path: path.join(projectRoot, ".treeport", "settings.json")
3325
+ }];
3326
+ for (const settings of settingsFiles) {
3327
+ const content = await fs.readFile(settings.path, "utf8").catch((error) => {
3328
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
3329
+ diagnostics.push({
3330
+ scope: settings.scope,
3331
+ path: settings.path,
3332
+ message: `Could not read ${settings.path}: ${error instanceof Error ? error.message : String(error)}`
3333
+ });
3334
+ return null;
3335
+ });
3336
+ if (content === null || content.trim() === "") {
3337
+ definitionsByScope.push([]);
3338
+ continue;
3339
+ }
3340
+ let parsedJson;
3341
+ try {
3342
+ parsedJson = JSON.parse(content);
3343
+ } catch (error) {
3344
+ diagnostics.push({
3345
+ scope: settings.scope,
3346
+ path: settings.path,
3347
+ message: `Could not parse ${settings.path}: ${error instanceof Error ? error.message : String(error)}`
3348
+ });
3349
+ definitionsByScope.push([]);
3350
+ continue;
3351
+ }
3352
+ const parsedSettings = z.looseObject({ treeContext: z.unknown().optional() }).safeParse(parsedJson);
3353
+ if (!parsedSettings.success) {
3354
+ diagnostics.push({
3355
+ scope: settings.scope,
3356
+ path: settings.path,
3357
+ message: `${settings.path} must contain a JSON object`
3358
+ });
3359
+ definitionsByScope.push([]);
3360
+ continue;
3361
+ }
3362
+ if (parsedSettings.data.treeContext === void 0) {
3363
+ definitionsByScope.push([]);
3364
+ continue;
3365
+ }
3366
+ const parsedTreeContext = z.looseObject({ fields: z.unknown().optional() }).safeParse(parsedSettings.data.treeContext);
3367
+ const parsedFields = z.array(z.unknown()).safeParse(parsedTreeContext.success ? parsedTreeContext.data.fields ?? [] : void 0);
3368
+ if (!parsedTreeContext.success || !parsedFields.success) {
3369
+ diagnostics.push({
3370
+ scope: settings.scope,
3371
+ path: settings.path,
3372
+ message: `${settings.path} treeContext.fields must be an array`
3373
+ });
3374
+ definitionsByScope.push([]);
3375
+ continue;
3376
+ }
3377
+ const fields = [];
3378
+ const ids = /* @__PURE__ */ new Set();
3379
+ for (const [index, input] of parsedFields.data.entries()) {
3380
+ if (fields.length === 64) {
3381
+ diagnostics.push({
3382
+ scope: settings.scope,
3383
+ path: settings.path,
3384
+ message: `${settings.path} treeContext.fields cannot contain more than 64 fields`
3385
+ });
3386
+ break;
3387
+ }
3388
+ const parsedField = treeContextFieldDefinitionSchema.safeParse(input);
3389
+ if (!parsedField.success) {
3390
+ diagnostics.push({
3391
+ scope: settings.scope,
3392
+ path: settings.path,
3393
+ message: `${settings.path} treeContext.fields[${index}] is invalid: ${parsedField.error.issues[0]?.message ?? "invalid field"}`
3394
+ });
3395
+ continue;
3396
+ }
3397
+ if (ids.has(parsedField.data.id)) {
3398
+ diagnostics.push({
3399
+ scope: settings.scope,
3400
+ path: settings.path,
3401
+ message: `${settings.path} contains duplicate tree context field ${parsedField.data.id}`
3402
+ });
3403
+ continue;
3404
+ }
3405
+ ids.add(parsedField.data.id);
3406
+ fields.push(parsedField.data);
3407
+ }
3408
+ definitionsByScope.push(fields);
3409
+ }
3410
+ const fields = /* @__PURE__ */ new Map();
3411
+ for (const [index, definitions] of definitionsByScope.entries()) for (const definition of definitions) {
3412
+ if (!fields.has(definition.id) && fields.size === 64) {
3413
+ const settings = settingsFiles[index];
3414
+ diagnostics.push({
3415
+ scope: settings.scope,
3416
+ path: settings.path,
3417
+ message: `Tree context cannot contain more than 64 fields`
3418
+ });
3419
+ break;
3420
+ }
3421
+ fields.set(definition.id, definition);
3422
+ }
3423
+ return {
3424
+ fields: [...fields.values()],
3425
+ diagnostics
3426
+ };
3427
+ }
3428
+ //#endregion
3279
3429
  //#region src/server/core/setup.ts
3280
3430
  const DEFAULT_SETUP_TIMEOUT_MS = 30 * 6e4;
3281
3431
  const MAX_SETUP_OUTPUT = 4e3;
@@ -3415,16 +3565,19 @@ var dependencies = {
3415
3565
  "@hono/node-server": "^2.0.10",
3416
3566
  "@hono/zod-validator": "^0.9.0",
3417
3567
  "@libsql/client": "^0.17.4",
3568
+ "@playwright/cli": "0.1.18",
3418
3569
  "@radix-ui/react-context-menu": "^2.3.7",
3419
3570
  "@treeport/panel-sdk": "workspace:*",
3420
3571
  "@vitejs/plugin-react": "5.2.0",
3421
3572
  "@xterm/headless": "^6.0.0",
3573
+ "cmdk": "^1.1.1",
3422
3574
  "commander": "14.0.3",
3423
3575
  "drizzle-orm": "^0.45.2",
3424
3576
  "effect": "^3.22.0",
3425
3577
  "hono": "^4.11.7",
3426
3578
  "jsonc-parser": "^3.3.1",
3427
3579
  "node-pty": "1.2.0-beta.14",
3580
+ "playwright": "1.61.1",
3428
3581
  "socket.io": "4.8.3",
3429
3582
  "socket.io-client": "4.8.3",
3430
3583
  "vite": "8.1.5",
@@ -3822,7 +3975,7 @@ const TMUX_SELECTION_RESTORE_KEY = TMUX_SELECTION_RESTORE_SEQUENCE.replace("\x1B
3822
3975
  const TMUX_CONFIG = `# Generated by Treeport. This does not read or modify ~/.tmux.conf.
3823
3976
  set -g status off
3824
3977
  set -g default-terminal "tmux-256color"
3825
- set -s terminal-features[999] "xterm-256color:hyperlinks"
3978
+ set -s terminal-features[999] "xterm*:hyperlinks"
3826
3979
  set -g extended-keys on
3827
3980
  if-shell -F '#{>=:#{version},3.5}' 'set-option -s extended-keys-format csi-u'
3828
3981
  set -g history-limit 50000
@@ -3834,7 +3987,7 @@ set -g bell-action any
3834
3987
  set -g visual-bell off
3835
3988
  set -g mouse on
3836
3989
  set -g destroy-unattached off
3837
- set -g mode-style "bg=#1f1f22"
3990
+ set -g mode-style "fg=#09090b,bg=#f4f4f5"
3838
3991
  set -s set-clipboard external
3839
3992
  set -s user-keys[0] "${TMUX_SCROLL_EXIT_KEY}"
3840
3993
  set -s user-keys[1] "${TMUX_SELECTION_START_SEQUENCE.replace("\x1B", "\\033")}"
@@ -3974,6 +4127,7 @@ var TmuxAdapter = class {
3974
4127
  cwd: input.cwd,
3975
4128
  env: environment
3976
4129
  };
4130
+ if (input.initialTitle) spec.initialTitle = input.initialTitle;
3977
4131
  if (input.fallbackArgv) spec.fallbackArgv = [...input.fallbackArgv];
3978
4132
  spec.tmuxExecutable = resolveExecutablePath(this.executable, this.hostEnvironment);
3979
4133
  if (shellIntegrationReady) spec.shellIntegrationDir = this.shellIntegrationDir;
@@ -4413,7 +4567,18 @@ const id = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
4413
4567
  const WEB_PANEL_STORAGE_MAX_ENTRIES = 256;
4414
4568
  const WEB_PANEL_STORAGE_MAX_TOTAL_BYTES = 1024 * 1024;
4415
4569
  const WEB_PANEL_STORAGE_MAX_VALUE_BYTES = 64 * 1024;
4416
- function mapWebPanel(row, allowSameOrigin = false) {
4570
+ function mapBrowserPanel(row) {
4571
+ return {
4572
+ id: row.id,
4573
+ kind: "browser",
4574
+ worktreeId: row.worktreeId,
4575
+ title: row.title,
4576
+ url: row.url,
4577
+ createdAt: row.createdAt,
4578
+ updatedAt: row.updatedAt
4579
+ };
4580
+ }
4581
+ function mapWebPanel(row, permissions = [], permissionsGranted = permissions.length === 0) {
4417
4582
  const parsedInput = webPanelInputSchema.nullable().safeParse(JSON.parse(row.inputJson));
4418
4583
  if (!parsedInput.success) throw new Error(`Web panel ${row.id} has invalid stored launch input`);
4419
4584
  return {
@@ -4426,7 +4591,8 @@ function mapWebPanel(row, allowSameOrigin = false) {
4426
4591
  input: parsedInput.data,
4427
4592
  cwd: row.launchCwd
4428
4593
  },
4429
- sandbox: { allowSameOrigin },
4594
+ permissions,
4595
+ sandbox: { allowSameOrigin: permissionsGranted && permissions.includes("same-origin") },
4430
4596
  createdAt: row.createdAt,
4431
4597
  updatedAt: row.updatedAt
4432
4598
  };
@@ -4468,6 +4634,7 @@ var TreeportService = class {
4468
4634
  projectLocks = /* @__PURE__ */ new Set();
4469
4635
  worktreeMutations = new KeyedTaskQueue();
4470
4636
  terminalMutations = new KeyedTaskQueue();
4637
+ treeFileMutations = new KeyedTaskQueue();
4471
4638
  removeConfirmationKey = crypto.randomBytes(32);
4472
4639
  terminalStates = /* @__PURE__ */ new Map();
4473
4640
  closeOnSuccessTerminalIds = /* @__PURE__ */ new Set();
@@ -4709,18 +4876,25 @@ var TreeportService = class {
4709
4876
  })]);
4710
4877
  worktree.dirty = dirty;
4711
4878
  worktree.terminals = terminals;
4712
- const storedWebPanels = await this.deps.database.db.select().from(webPanels).where(eq(webPanels.worktreeId, worktree.id)).orderBy(asc(webPanels.createdAt), asc(webPanels.id));
4713
- const definitions = project.availability.state === "available" && storedWebPanels.length > 0 ? await this.effectiveWebPanelDefinitions(worktree.id).catch(() => []) : [];
4879
+ const [storedBrowserPanels, storedWebPanels] = await Promise.all([this.deps.database.db.select().from(browserPanels).where(eq(browserPanels.worktreeId, worktree.id)).orderBy(asc(browserPanels.createdAt), asc(browserPanels.id)), this.deps.database.db.select().from(webPanels).where(eq(webPanels.worktreeId, worktree.id)).orderBy(asc(webPanels.createdAt), asc(webPanels.id))]);
4880
+ const definitions = project.availability.state === "available" && storedWebPanels.length > 0 ? await this.listWebPanelDefinitions(worktree.id).catch(() => []) : [];
4714
4881
  const definitionsById = new Map(definitions.map((definition) => [definition.id, definition]));
4715
- worktree.panels = [...terminals.map((terminal) => ({
4716
- id: `panel_${terminal.id}`,
4717
- kind: "terminal",
4718
- worktreeId: worktree.id,
4719
- terminalId: terminal.id,
4720
- title: terminal.name,
4721
- createdAt: terminal.createdAt,
4722
- updatedAt: terminal.updatedAt
4723
- })), ...storedWebPanels.map((panel) => mapWebPanel(panel, definitionsById.get(panel.definitionId)?.sandbox.allowSameOrigin ?? false))].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
4882
+ worktree.panels = [
4883
+ ...terminals.map((terminal) => ({
4884
+ id: `panel_${terminal.id}`,
4885
+ kind: "terminal",
4886
+ worktreeId: worktree.id,
4887
+ terminalId: terminal.id,
4888
+ title: terminal.name,
4889
+ createdAt: terminal.createdAt,
4890
+ updatedAt: terminal.updatedAt
4891
+ })),
4892
+ ...storedBrowserPanels.map(mapBrowserPanel),
4893
+ ...storedWebPanels.map((panel) => {
4894
+ const definition = definitionsById.get(panel.definitionId);
4895
+ return mapWebPanel(panel, definition?.permissions ?? [], definition?.permissionsGranted ?? false);
4896
+ })
4897
+ ].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
4724
4898
  }));
4725
4899
  return project;
4726
4900
  }))).filter((project) => project !== null);
@@ -4828,7 +5002,21 @@ var TreeportService = class {
4828
5002
  }
4829
5003
  async removePackage(source, projectId) {
4830
5004
  if (projectId) this.packages.syncProjects([await this.getProject(projectId)]);
5005
+ const registeredProjects = projectId ? [await this.getProject(projectId)] : await this.storedProjects();
5006
+ const collectPermissionSourceKeys = async () => {
5007
+ const keys = /* @__PURE__ */ new Set();
5008
+ for (const project of registeredProjects) {
5009
+ const worktree = project.worktrees[0];
5010
+ if (!worktree) continue;
5011
+ const definitions = await this.effectiveWebPanelDefinitions(worktree.id).catch(() => []);
5012
+ for (const definition of definitions) if (definition.permissions.length > 0) keys.add(await this.webPanelPermissionSourceKey(worktree.id, definition));
5013
+ }
5014
+ return keys;
5015
+ };
5016
+ const before = await collectPermissionSourceKeys();
4831
5017
  const result = await this.packages.remove(source, projectId);
5018
+ const after = await collectPermissionSourceKeys();
5019
+ for (const sourceKey of before) if (!after.has(sourceKey)) await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
4832
5020
  await this.webPanelRuntime.disposeDevelopmentServers();
4833
5021
  await this.packageResourcesChanged(projectId);
4834
5022
  return result;
@@ -4859,6 +5047,13 @@ var TreeportService = class {
4859
5047
  this.events.publish("project.updated", { projectId });
4860
5048
  return await this.getProject(projectId);
4861
5049
  }
5050
+ async listTreeContextFields(projectId) {
5051
+ const project = await this.getProject(projectId);
5052
+ return loadTreeContextFields({
5053
+ dataDir: this.deps.config.dataDir,
5054
+ projectRoot: project.rootPath
5055
+ });
5056
+ }
4862
5057
  async listTerminalPresets() {
4863
5058
  return (await this.deps.database.db.select().from(terminalPresets).orderBy(asc(terminalPresets.createdAt), asc(terminalPresets.id))).map(mapTerminalPreset);
4864
5059
  }
@@ -4976,6 +5171,8 @@ var TreeportService = class {
4976
5171
  id: `project:${encodeURIComponent(directory.name)}`,
4977
5172
  title: words ? `${words[0].toLocaleUpperCase()}${words.slice(1)}` : directory.name,
4978
5173
  source: { type: "project" },
5174
+ permissions: [],
5175
+ permissionsGranted: true,
4979
5176
  sandbox: { allowSameOrigin: false },
4980
5177
  root,
4981
5178
  entry,
@@ -5005,8 +5202,83 @@ var TreeportService = class {
5005
5202
  return resolved;
5006
5203
  })];
5007
5204
  }
5205
+ async webPanelPermissionSourceKey(worktreeId, definition) {
5206
+ const worktree = await this.getWorktree(worktreeId);
5207
+ const source = definition.source.type === "project" ? {
5208
+ type: "project",
5209
+ projectId: worktree.projectId,
5210
+ definitionId: definition.id
5211
+ } : {
5212
+ type: "package",
5213
+ scope: definition.source.scope,
5214
+ projectId: definition.source.scope === "project" ? worktree.projectId : null,
5215
+ packageId: definition.source.packageId,
5216
+ source: definition.source.source,
5217
+ definitionId: definition.id
5218
+ };
5219
+ return crypto.createHash("sha256").update(JSON.stringify(source)).digest("hex");
5220
+ }
5221
+ async webPanelPermissionsGranted(worktreeId, definition) {
5222
+ const sourceKey = await this.webPanelPermissionSourceKey(worktreeId, definition);
5223
+ const [grant] = await this.deps.database.db.select({ permissionsJson: webPanelPermissionGrants.permissionsJson }).from(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey)).limit(1);
5224
+ if (definition.permissions.length === 0) {
5225
+ if (grant) await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
5226
+ return true;
5227
+ }
5228
+ const matches = grant?.permissionsJson === JSON.stringify([...definition.permissions].sort());
5229
+ if (grant && !matches) await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
5230
+ return matches;
5231
+ }
5008
5232
  async listWebPanelDefinitions(worktreeId) {
5009
- return (await this.effectiveWebPanelDefinitions(worktreeId)).map(({ root: _root, entry: _entry, packageRoot: _packageRoot, development: _development, packageLockPath: _packageLockPath, definitionId: _definitionId, packageSource: _packageSource, allowNetworkRequests: _allowNetworkRequests, ...definition }) => definition);
5233
+ const definitions = await this.effectiveWebPanelDefinitions(worktreeId);
5234
+ return Promise.all(definitions.map(async ({ root: _root, entry: _entry, packageRoot: _packageRoot, development: _development, packageLockPath: _packageLockPath, definitionId: _definitionId, packageSource: _packageSource, allowNetworkRequests: _allowNetworkRequests, ...definition }) => ({
5235
+ ...definition,
5236
+ permissionsGranted: await this.webPanelPermissionsGranted(worktreeId, definition)
5237
+ })));
5238
+ }
5239
+ async setWebPanelPermissionGrant(worktreeId, definitionId, granted, expectedPermissions) {
5240
+ await this.requireAvailableWorktree(worktreeId);
5241
+ const definition = (await this.effectiveWebPanelDefinitions(worktreeId)).find((candidate) => candidate.id === definitionId);
5242
+ if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "Web panel definition not found", 404);
5243
+ if (JSON.stringify([...definition.permissions].sort()) !== JSON.stringify([...expectedPermissions].sort())) throw new DomainError("WEB_PANEL_PERMISSIONS_CHANGED", "Web panel permissions changed; review them and try again", 409, { permissions: definition.permissions });
5244
+ const sourceKey = await this.webPanelPermissionSourceKey(worktreeId, definition);
5245
+ if (granted && definition.permissions.length > 0) {
5246
+ const timestamp = now();
5247
+ await this.deps.database.db.insert(webPanelPermissionGrants).values({
5248
+ sourceKey,
5249
+ definitionId,
5250
+ permissionsJson: JSON.stringify([...definition.permissions].sort()),
5251
+ grantedAt: timestamp,
5252
+ updatedAt: timestamp
5253
+ }).onConflictDoUpdate({
5254
+ target: webPanelPermissionGrants.sourceKey,
5255
+ set: {
5256
+ permissionsJson: JSON.stringify([...definition.permissions].sort()),
5257
+ updatedAt: timestamp
5258
+ }
5259
+ });
5260
+ } else await this.deps.database.db.delete(webPanelPermissionGrants).where(eq(webPanelPermissionGrants.sourceKey, sourceKey));
5261
+ const affectedPanels = await this.deps.database.db.select({ id: webPanels.id }).from(webPanels).where(and(eq(webPanels.worktreeId, worktreeId), eq(webPanels.definitionId, definitionId)));
5262
+ this.invalidateProjectsSnapshot();
5263
+ for (const panel of affectedPanels) this.events.publish("panel.updated", {
5264
+ worktreeId,
5265
+ panelId: panel.id
5266
+ });
5267
+ return {
5268
+ id: definition.id,
5269
+ title: definition.title,
5270
+ source: definition.source,
5271
+ permissions: definition.permissions,
5272
+ permissionsGranted: definition.permissions.length === 0 ? true : granted,
5273
+ sandbox: definition.sandbox
5274
+ };
5275
+ }
5276
+ async requireWebPanelPermissions(worktreeId, definition) {
5277
+ if (await this.webPanelPermissionsGranted(worktreeId, definition)) return;
5278
+ throw new DomainError("WEB_PANEL_PERMISSION_REQUIRED", `Permission is required before ${definition.title} can open`, 403, {
5279
+ definitionId: definition.id,
5280
+ permissions: definition.permissions
5281
+ });
5010
5282
  }
5011
5283
  async normalizeWebPanelLaunch(worktree, launch) {
5012
5284
  const inputJson = JSON.stringify(launch.input);
@@ -5030,6 +5302,122 @@ var TreeportService = class {
5030
5302
  inputJson
5031
5303
  };
5032
5304
  }
5305
+ async createBrowserPanel(worktreeId, requestedUrl) {
5306
+ await this.requireAvailableWorktree(worktreeId);
5307
+ const parsedUrl = requestedUrl ? browserUrlSchema.safeParse(requestedUrl) : null;
5308
+ if (parsedUrl && !parsedUrl.success) throw new DomainError("INVALID_BROWSER_URL", "Enter an absolute HTTP or HTTPS URL without credentials", 400);
5309
+ const url = parsedUrl ? new URL(parsedUrl.data).href : "about:blank";
5310
+ const timestamp = now();
5311
+ const panel = {
5312
+ id: id("panel"),
5313
+ kind: "browser",
5314
+ worktreeId,
5315
+ title: url === "about:blank" ? "Browser" : new URL(url).host,
5316
+ url,
5317
+ createdAt: timestamp,
5318
+ updatedAt: timestamp
5319
+ };
5320
+ await this.deps.database.db.insert(browserPanels).values({
5321
+ id: panel.id,
5322
+ worktreeId: panel.worktreeId,
5323
+ title: panel.title,
5324
+ url: panel.url,
5325
+ createdAt: panel.createdAt,
5326
+ updatedAt: panel.updatedAt
5327
+ });
5328
+ this.invalidateProjectsSnapshot();
5329
+ this.events.publish("panel.created", {
5330
+ worktreeId,
5331
+ panelId: panel.id
5332
+ });
5333
+ return panel;
5334
+ }
5335
+ async openBrowserPanel(worktreeId, requestedUrl, sourceTerminalId = null, sourcePanelId = null) {
5336
+ if (sourceTerminalId) {
5337
+ const terminal = await this.getTerminalFromBindings(sourceTerminalId);
5338
+ if (worktreeId && terminal.worktreeId !== worktreeId) throw new DomainError("INVALID_PANEL_OPEN_SOURCE", "The source terminal does not belong to the target tree", 400);
5339
+ worktreeId ??= terminal.worktreeId;
5340
+ }
5341
+ if (sourcePanelId) {
5342
+ const sourcePanel = await this.getBrowserPanel(sourcePanelId);
5343
+ if (worktreeId && sourcePanel.worktreeId !== worktreeId) throw new DomainError("INVALID_PANEL_OPEN_SOURCE", "The source Browser does not belong to the target tree", 400);
5344
+ worktreeId ??= sourcePanel.worktreeId;
5345
+ }
5346
+ if (!worktreeId) throw new DomainError("INVALID_PANEL_OPEN_SOURCE", "A target tree or panel source is required", 400);
5347
+ const panel = await this.createBrowserPanel(worktreeId, requestedUrl);
5348
+ this.events.publish("panel.open_requested", {
5349
+ worktreeId,
5350
+ panelId: panel.id,
5351
+ panel,
5352
+ sourceTerminalId,
5353
+ sourcePanelId
5354
+ });
5355
+ return { panel };
5356
+ }
5357
+ async openBrowserPanelFromTerminal(terminalId, requestedUrl) {
5358
+ return this.openBrowserPanel(null, requestedUrl, terminalId, null);
5359
+ }
5360
+ async openBrowserPanelFromPanel(panelId, requestedUrl) {
5361
+ return this.openBrowserPanel(null, requestedUrl, null, panelId);
5362
+ }
5363
+ async getBrowserPanel(panelId) {
5364
+ const [row] = await this.deps.database.db.select().from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
5365
+ if (!row) throw new DomainError("PANEL_NOT_FOUND", "Browser not found", 404);
5366
+ await this.requireAvailableWorktree(row.worktreeId);
5367
+ return mapBrowserPanel(row);
5368
+ }
5369
+ async authorizeBrowserPanel(panelId) {
5370
+ const panel = await this.getBrowserPanel(panelId);
5371
+ return {
5372
+ panel,
5373
+ worktreePath: (await this.getWorktree(panel.worktreeId)).path
5374
+ };
5375
+ }
5376
+ async updateBrowserPanelState(panelId, state) {
5377
+ const panel = await this.getBrowserPanel(panelId);
5378
+ const parsedUrl = state.url === "about:blank" ? {
5379
+ success: true,
5380
+ data: "about:blank"
5381
+ } : browserUrlSchema.safeParse(state.url);
5382
+ if (!parsedUrl.success) throw new DomainError("INVALID_BROWSER_URL", "The hosted browser reported an unsupported URL", 400);
5383
+ const url = parsedUrl.data === "about:blank" ? parsedUrl.data : new URL(parsedUrl.data).href;
5384
+ const title = state.title.trim().slice(0, 256) || (url === "about:blank" ? "Browser" : new URL(url).host || "Browser");
5385
+ if (panel.url === url && panel.title === title) return panel;
5386
+ const observedAt = now();
5387
+ const updatedAt = observedAt > panel.updatedAt ? observedAt : new Date(Date.parse(panel.updatedAt) + 1).toISOString();
5388
+ await this.deps.database.db.update(browserPanels).set({
5389
+ url,
5390
+ title,
5391
+ updatedAt
5392
+ }).where(eq(browserPanels.id, panelId));
5393
+ const updated = {
5394
+ ...panel,
5395
+ url,
5396
+ title,
5397
+ updatedAt
5398
+ };
5399
+ this.invalidateProjectsSnapshot();
5400
+ this.events.publish("panel.updated", {
5401
+ worktreeId: panel.worktreeId,
5402
+ panelId
5403
+ });
5404
+ return updated;
5405
+ }
5406
+ async deleteBrowserPanel(panelId) {
5407
+ const [row] = await this.deps.database.db.select().from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
5408
+ if (!row) throw new DomainError("PANEL_NOT_FOUND", "Browser not found", 404);
5409
+ await this.deps.database.db.delete(browserPanels).where(eq(browserPanels.id, panelId));
5410
+ this.invalidateProjectsSnapshot();
5411
+ this.events.publish("panel.removed", {
5412
+ worktreeId: row.worktreeId,
5413
+ panelId
5414
+ });
5415
+ }
5416
+ async deletePanel(panelId, discardStoredData = false) {
5417
+ const [browserPanel] = await this.deps.database.db.select({ id: browserPanels.id }).from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
5418
+ if (browserPanel) return this.deleteBrowserPanel(panelId);
5419
+ return this.deleteWebPanel(panelId, discardStoredData);
5420
+ }
5033
5421
  async createWebPanel(worktreeId, definitionId, launch = {
5034
5422
  input: null,
5035
5423
  cwd: null
@@ -5037,6 +5425,7 @@ var TreeportService = class {
5037
5425
  const worktree = await this.requireAvailableWorktree(worktreeId);
5038
5426
  const definition = (await this.effectiveWebPanelDefinitions(worktreeId)).find((candidate) => candidate.id === definitionId);
5039
5427
  if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "Web panel definition not found", 404);
5428
+ await this.requireWebPanelPermissions(worktreeId, definition);
5040
5429
  const normalized = await this.normalizeWebPanelLaunch(worktree, launch);
5041
5430
  const timestamp = now();
5042
5431
  const panel = {
@@ -5046,6 +5435,7 @@ var TreeportService = class {
5046
5435
  definitionId,
5047
5436
  title: definition.title,
5048
5437
  launch: normalized.launch,
5438
+ permissions: definition.permissions,
5049
5439
  sandbox: definition.sandbox,
5050
5440
  createdAt: timestamp,
5051
5441
  updatedAt: timestamp
@@ -5074,11 +5464,14 @@ var TreeportService = class {
5074
5464
  const worktree = await this.requireAvailableWorktree(worktreeId);
5075
5465
  const definition = (await this.effectiveWebPanelDefinitions(worktreeId)).find((candidate) => candidate.id === definitionId);
5076
5466
  if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "Web panel definition not found", 404);
5467
+ await this.requireWebPanelPermissions(worktreeId, definition);
5077
5468
  const finish = (result) => {
5078
5469
  this.events.publish("panel.open_requested", {
5079
5470
  worktreeId,
5080
5471
  panelId: result.panel.id,
5081
- sourceTerminalId
5472
+ panel: result.panel,
5473
+ sourceTerminalId,
5474
+ sourcePanelId: null
5082
5475
  });
5083
5476
  return result;
5084
5477
  };
@@ -5108,7 +5501,7 @@ var TreeportService = class {
5108
5501
  inputJson: normalized.inputJson,
5109
5502
  launchCwd: normalized.launch.cwd,
5110
5503
  updatedAt
5111
- }, definition.sandbox.allowSameOrigin);
5504
+ }, definition.permissions, true);
5112
5505
  this.invalidateProjectsSnapshot();
5113
5506
  this.events.publish("panel.updated", {
5114
5507
  worktreeId,
@@ -5133,10 +5526,149 @@ var TreeportService = class {
5133
5526
  panelId
5134
5527
  });
5135
5528
  }
5529
+ async requireWebPanelTreeFiles(panelId) {
5530
+ const [panel] = await this.deps.database.db.select().from(webPanels).where(eq(webPanels.id, panelId)).limit(1);
5531
+ if (!panel) throw new DomainError("PANEL_NOT_FOUND", "Panel not found", 404);
5532
+ const definition = (await this.effectiveWebPanelDefinitions(panel.worktreeId)).find((candidate) => candidate.id === panel.definitionId);
5533
+ if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "The definition for this panel is unavailable", 404);
5534
+ await this.requireWebPanelPermissions(panel.worktreeId, definition);
5535
+ if (!definition.permissions.includes("tree-files")) throw new DomainError("WEB_PANEL_TREE_FILES_REQUIRED", "This panel does not have permission to access tree files", 403);
5536
+ const worktree = await this.requireAvailableWorktree(panel.worktreeId);
5537
+ return {
5538
+ project: await this.getProject(worktree.projectId),
5539
+ worktree
5540
+ };
5541
+ }
5542
+ async resolveTreeFile(worktreePath, requestedPath) {
5543
+ if (!treeFilePathSchema.safeParse(requestedPath).success) throw new DomainError("INVALID_TREE_FILE_PATH", "File path must be a relative path inside the tree", 400);
5544
+ const root = await fs.realpath(worktreePath);
5545
+ const candidate = path.resolve(root, requestedPath);
5546
+ if (!isPathWithin(candidate, root)) throw new DomainError("INVALID_TREE_FILE_PATH", "File path must stay inside the tree", 400);
5547
+ const canonicalPath = await fs.realpath(candidate).catch((error) => {
5548
+ if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return null;
5549
+ throw error;
5550
+ });
5551
+ if (!canonicalPath) throw new DomainError("TREE_FILE_NOT_FOUND", "The selected file does not exist", 404);
5552
+ if (!isPathWithin(canonicalPath, root)) throw new DomainError("INVALID_TREE_FILE_PATH", "File path must stay inside the tree", 400);
5553
+ const stat = await fs.lstat(candidate);
5554
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only existing regular files can be edited", 415);
5555
+ return {
5556
+ canonicalPath,
5557
+ path: path.relative(root, canonicalPath).split(path.sep).join("/")
5558
+ };
5559
+ }
5560
+ async readTreeFileHandle(handle) {
5561
+ const stat = await handle.stat();
5562
+ if (!stat.isFile()) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only existing regular files can be edited", 415);
5563
+ if (stat.size > 2097152) throw new DomainError("TREE_FILE_TOO_LARGE", "Files larger than 2 MiB cannot be edited", 413);
5564
+ const buffer = Buffer.alloc(TREE_FILE_MAX_BYTES + 1);
5565
+ let bytesRead = 0;
5566
+ while (bytesRead < buffer.length) {
5567
+ const read = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
5568
+ if (read.bytesRead === 0) break;
5569
+ bytesRead += read.bytesRead;
5570
+ }
5571
+ if (bytesRead > 2097152) throw new DomainError("TREE_FILE_TOO_LARGE", "Files larger than 2 MiB cannot be edited", 413);
5572
+ const bytes = buffer.subarray(0, bytesRead);
5573
+ let content;
5574
+ try {
5575
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
5576
+ } catch {
5577
+ throw new DomainError("TREE_FILE_UNSUPPORTED", "Only UTF-8 text files can be edited", 415);
5578
+ }
5579
+ if (content.includes("\0")) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only UTF-8 text files can be edited", 415);
5580
+ return {
5581
+ bytes,
5582
+ content
5583
+ };
5584
+ }
5585
+ async listTreeFiles(panelId) {
5586
+ const { project, worktree } = await this.requireWebPanelTreeFiles(panelId);
5587
+ const root = await fs.realpath(worktree.path);
5588
+ const paths = [];
5589
+ if (project.kind === "repository") {
5590
+ const candidates = await this.deps.git.worktreeFiles(root);
5591
+ for (const relativePath of candidates) {
5592
+ if (paths.length > 5e4) break;
5593
+ const candidate = path.resolve(root, relativePath);
5594
+ if (!isPathWithin(candidate, root)) continue;
5595
+ const [canonicalPath, stat] = await Promise.all([fs.realpath(candidate).catch(() => null), fs.lstat(candidate).catch(() => null)]);
5596
+ if (canonicalPath && isPathWithin(canonicalPath, root) && stat?.isFile() && !stat.isSymbolicLink()) paths.push(relativePath.split(path.sep).join("/"));
5597
+ }
5598
+ } else {
5599
+ const directories = [""];
5600
+ while (directories.length > 0 && paths.length <= 5e4) {
5601
+ const relativeDirectory = directories.pop();
5602
+ const entries = await fs.readdir(path.join(root, relativeDirectory), { withFileTypes: true }).then((values) => values.sort((left, right) => right.name.localeCompare(left.name)));
5603
+ for (const entry of entries) {
5604
+ if (entry.name === ".git" && entry.isDirectory()) continue;
5605
+ const relativePath = path.join(relativeDirectory, entry.name);
5606
+ if (entry.isDirectory()) directories.push(relativePath);
5607
+ else if (entry.isFile()) {
5608
+ paths.push(relativePath.split(path.sep).join("/"));
5609
+ if (paths.length > 5e4) break;
5610
+ }
5611
+ }
5612
+ }
5613
+ }
5614
+ paths.sort();
5615
+ return {
5616
+ paths: paths.slice(0, TREE_FILE_LIST_MAX_ENTRIES),
5617
+ truncated: paths.length > TREE_FILE_LIST_MAX_ENTRIES
5618
+ };
5619
+ }
5620
+ async readTreeFile(panelId, requestedPath) {
5621
+ const { worktree } = await this.requireWebPanelTreeFiles(panelId);
5622
+ const resolved = await this.resolveTreeFile(worktree.path, requestedPath);
5623
+ const handle = await fs.open(resolved.canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => {
5624
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new DomainError("TREE_FILE_NOT_FOUND", "The selected file does not exist", 404);
5625
+ throw error;
5626
+ });
5627
+ try {
5628
+ const { bytes, content } = await this.readTreeFileHandle(handle);
5629
+ return {
5630
+ path: resolved.path,
5631
+ content,
5632
+ revision: crypto.createHash("sha256").update(bytes).digest("hex")
5633
+ };
5634
+ } finally {
5635
+ await handle.close();
5636
+ }
5637
+ }
5638
+ async writeTreeFile(panelId, input) {
5639
+ const { worktree } = await this.requireWebPanelTreeFiles(panelId);
5640
+ const content = Buffer.from(input.content, "utf8");
5641
+ if (content.length > 2097152) throw new DomainError("TREE_FILE_TOO_LARGE", "Files larger than 2 MiB cannot be edited", 413);
5642
+ if (input.content.includes("\0")) throw new DomainError("TREE_FILE_UNSUPPORTED", "Only UTF-8 text files can be edited", 415);
5643
+ const resolved = await this.resolveTreeFile(worktree.path, input.path);
5644
+ return this.treeFileMutations.enqueue(resolved.canonicalPath, async () => {
5645
+ const handle = await fs.open(resolved.canonicalPath, constants.O_RDWR | constants.O_NOFOLLOW);
5646
+ try {
5647
+ const current = await this.readTreeFileHandle(handle);
5648
+ if (crypto.createHash("sha256").update(current.bytes).digest("hex") !== input.expectedRevision) throw new DomainError("TREE_FILE_CHANGED", "The file changed after it was opened. Reload it before saving.", 409);
5649
+ let offset = 0;
5650
+ while (offset < content.length) {
5651
+ const write = await handle.write(content, offset, content.length - offset, offset);
5652
+ if (write.bytesWritten === 0) throw new Error("Could not write tree file");
5653
+ offset += write.bytesWritten;
5654
+ }
5655
+ await handle.truncate(content.length);
5656
+ await handle.sync();
5657
+ return {
5658
+ path: resolved.path,
5659
+ revision: crypto.createHash("sha256").update(content).digest("hex")
5660
+ };
5661
+ } finally {
5662
+ await handle.close();
5663
+ }
5664
+ });
5665
+ }
5136
5666
  async getWebPanelContext(panelId) {
5137
5667
  const [panelRow] = await this.deps.database.db.select().from(webPanels).where(eq(webPanels.id, panelId)).limit(1);
5138
5668
  if (!panelRow) throw new DomainError("PANEL_NOT_FOUND", "Panel not found", 404);
5139
- const panel = mapWebPanel(panelRow, (await this.effectiveWebPanelDefinitions(panelRow.worktreeId)).find((candidate) => candidate.id === panelRow.definitionId)?.sandbox.allowSameOrigin ?? false);
5669
+ const definition = (await this.effectiveWebPanelDefinitions(panelRow.worktreeId)).find((candidate) => candidate.id === panelRow.definitionId);
5670
+ const permissionsGranted = definition ? await this.webPanelPermissionsGranted(panelRow.worktreeId, definition) : false;
5671
+ const panel = mapWebPanel(panelRow, definition?.permissions ?? [], permissionsGranted);
5140
5672
  const worktree = await this.getWorktree(panel.worktreeId);
5141
5673
  const project = await this.getProject(worktree.projectId);
5142
5674
  return {
@@ -5164,6 +5696,19 @@ var TreeportService = class {
5164
5696
  const worktree = await this.getWorktree(context.panel.worktreeId);
5165
5697
  return this.deps.git.worktreeDiff(worktree.path, context.project.defaultBranch);
5166
5698
  }
5699
+ async getBrowserPanelListeners(panelId) {
5700
+ const panel = await this.getBrowserPanel(panelId);
5701
+ const worktree = await this.getWorktree(panel.worktreeId);
5702
+ const panes = await this.deps.tmux.listPaneProcesses(worktree.tmuxSocketName, worktree.id);
5703
+ return this.networkListeners.listeners({
5704
+ worktreePath: worktree.path,
5705
+ panes
5706
+ });
5707
+ }
5708
+ async getPanelListeners(panelId) {
5709
+ const [browserPanel] = await this.deps.database.db.select({ id: browserPanels.id }).from(browserPanels).where(eq(browserPanels.id, panelId)).limit(1);
5710
+ return browserPanel ? this.getBrowserPanelListeners(panelId) : this.getWebPanelListeners(panelId);
5711
+ }
5167
5712
  async getWebPanelListeners(panelId) {
5168
5713
  const context = await this.getWebPanelContext(panelId);
5169
5714
  const worktree = await this.getWorktree(context.panel.worktreeId);
@@ -5214,17 +5759,30 @@ var TreeportService = class {
5214
5759
  if (!panel) throw new DomainError("PANEL_NOT_FOUND", "Panel not found", 404);
5215
5760
  const definition = (await this.effectiveWebPanelDefinitions(panel.worktreeId)).find((candidate) => candidate.id === panel.definitionId);
5216
5761
  if (!definition) throw new DomainError("WEB_PANEL_DEFINITION_NOT_FOUND", "The definition for this panel is unavailable", 404);
5762
+ await this.requireWebPanelPermissions(panel.worktreeId, definition);
5217
5763
  const encodedPanelId = encodeURIComponent(panelId);
5218
5764
  return this.webPanelRuntime.resolve(definition, requestedPath, `/api/web-panels/${encodedPanelId}/assets/`);
5219
5765
  }
5766
+ async listBrowserPanels() {
5767
+ return this.deps.database.db.select().from(browserPanels).orderBy(asc(browserPanels.createdAt), asc(browserPanels.id)).then((rows) => rows.map(mapBrowserPanel));
5768
+ }
5220
5769
  async listWebPanels() {
5221
- return (await this.deps.database.db.select().from(webPanels).orderBy(asc(webPanels.createdAt), asc(webPanels.id))).map((row) => mapWebPanel(row));
5770
+ const rows = await this.deps.database.db.select().from(webPanels).orderBy(asc(webPanels.createdAt), asc(webPanels.id));
5771
+ return Promise.all(rows.map(async (row) => {
5772
+ const definition = (await this.effectiveWebPanelDefinitions(row.worktreeId).catch(() => [])).find((candidate) => candidate.id === row.definitionId);
5773
+ return mapWebPanel(row, definition?.permissions ?? [], definition ? await this.webPanelPermissionsGranted(row.worktreeId, definition) : false);
5774
+ }));
5222
5775
  }
5223
5776
  async getWorktree(worktreeId) {
5224
5777
  const worktree = await this.storedWorktree(worktreeId);
5225
5778
  if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
5226
5779
  return worktree;
5227
5780
  }
5781
+ async getWorktreeContext(worktreeId) {
5782
+ const [row] = await this.deps.database.db.select({ treeContextJson: worktrees.treeContextJson }).from(worktrees).where(eq(worktrees.id, worktreeId)).limit(1);
5783
+ if (!row) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
5784
+ return treeContextValuesSchema.parse(JSON.parse(row.treeContextJson));
5785
+ }
5228
5786
  async requestWorkspaceOpen(worktreeId, sourceTerminalId) {
5229
5787
  const worktree = await this.getWorktree(worktreeId);
5230
5788
  await this.requireOpenProject(worktree.projectId);
@@ -5901,7 +6459,7 @@ var TreeportService = class {
5901
6459
  if (filters.projectId) await this.requireOpenProject(filters.projectId);
5902
6460
  return (await this.deps.database.db.select().from(operations).where(and(or(eq(operations.status, "pending"), eq(operations.status, "running")), ...filters.projectId ? [eq(operations.projectId, filters.projectId)] : [], ...filters.kind ? [eq(operations.kind, filters.kind)] : [])).orderBy(asc(operations.createdAt), asc(operations.id))).map(mapOperation);
5903
6461
  }
5904
- async beginCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
6462
+ async beginCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
5905
6463
  if ((await this.requireOpenProject(projectId)).kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
5906
6464
  let name;
5907
6465
  try {
@@ -5918,6 +6476,7 @@ var TreeportService = class {
5918
6476
  };
5919
6477
  if (initialTerminal) request.initialTerminal = initialTerminal;
5920
6478
  if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
6479
+ if (treeContext && Object.keys(treeContext).length > 0) request.context = treeContext;
5921
6480
  await this.deps.database.db.run(sql`
5922
6481
  INSERT INTO operations(
5923
6482
  id,kind,project_id,worktree_id,status,request_json,result_json,error,
@@ -5932,16 +6491,16 @@ var TreeportService = class {
5932
6491
  projectId,
5933
6492
  operationId
5934
6493
  });
5935
- this.worktreeMutations.enqueue(projectId, () => this.executeCreateOperation(operationId, projectId, name, base, initialTerminal, sourceWorktreeId)).catch(() => void 0);
6494
+ this.worktreeMutations.enqueue(projectId, () => this.executeCreateOperation(operationId, projectId, name, base, initialTerminal, sourceWorktreeId, treeContext)).catch(() => void 0);
5936
6495
  return operation;
5937
6496
  }
5938
- async executeCreateOperation(operationId, projectId, inputName, base, initialTerminal, sourceWorktreeId) {
6497
+ async executeCreateOperation(operationId, projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
5939
6498
  await this.deps.database.db.run(sql`
5940
6499
  UPDATE operations SET status='running',updated_at=${now()}
5941
6500
  WHERE id=${operationId} AND status='pending'
5942
6501
  `);
5943
6502
  try {
5944
- const result = await this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId);
6503
+ const result = await this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext);
5945
6504
  const timestamp = now();
5946
6505
  await this.deps.database.db.run(sql`
5947
6506
  UPDATE operations
@@ -5972,12 +6531,13 @@ var TreeportService = class {
5972
6531
  });
5973
6532
  }
5974
6533
  }
5975
- async createWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
6534
+ async createWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
5976
6535
  if ((await this.requireOpenProject(projectId)).kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
5977
6536
  if (this.projectLocks.has(projectId) && !this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5978
- return this.worktreeMutations.enqueue(projectId, () => this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId));
6537
+ return this.worktreeMutations.enqueue(projectId, () => this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext));
5979
6538
  }
5980
- async executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
6539
+ async executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId, treeContext) {
6540
+ const contextValues = treeContextValuesSchema.parse(treeContext ?? {});
5981
6541
  await this.requireOpenProject(projectId);
5982
6542
  if (this.projectLocks.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5983
6543
  this.projectLocks.add(projectId);
@@ -6027,7 +6587,8 @@ var TreeportService = class {
6027
6587
  await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath, true);
6028
6588
  await this.deps.database.db.run(sql`
6029
6589
  UPDATE worktrees
6030
- SET managed_wrapper_path=${wrapperCreated ? wrapperPath : null}
6590
+ SET managed_wrapper_path=${wrapperCreated ? wrapperPath : null},
6591
+ tree_context_json=${JSON.stringify(contextValues)}
6031
6592
  WHERE path=${worktreePath}
6032
6593
  `);
6033
6594
  const [worktreeRow] = await this.deps.database.db.select({
@@ -6047,6 +6608,7 @@ var TreeportService = class {
6047
6608
  let setupError = null;
6048
6609
  if (initialTerminal) {
6049
6610
  const launchOptions = {};
6611
+ if (initialTerminal.initialTitle) launchOptions.initialTitle = initialTerminal.initialTitle;
6050
6612
  if (initialTerminal.returnToShell) launchOptions.returnToShell = true;
6051
6613
  if (initialTerminal.initialSize) launchOptions.initialSize = initialTerminal.initialSize;
6052
6614
  const initialTerminalCreation = this.executeCreateTerminal(worktree.id, initialTerminal.name, initialTerminal.argv, launchOptions);
@@ -6172,6 +6734,7 @@ var TreeportService = class {
6172
6734
  TREEPORT_TERMINAL_ID: terminalId
6173
6735
  }
6174
6736
  };
6737
+ if (options?.initialTitle) session.initialTitle = options.initialTitle;
6175
6738
  if (options?.returnToShell && !interactiveShell) session.fallbackArgv = [this.deps.config.shell, "-l"];
6176
6739
  if (options?.closeOnSuccess) session.closeOnSuccess = true;
6177
6740
  if (options?.initialSize) session.initialSize = options.initialSize;
@@ -6706,7 +7269,11 @@ var TreeportService = class {
6706
7269
  return terminated;
6707
7270
  }
6708
7271
  async drainMutations() {
6709
- await Promise.all([this.worktreeMutations.drain(), this.terminalMutations.drain()]);
7272
+ await Promise.all([
7273
+ this.worktreeMutations.drain(),
7274
+ this.terminalMutations.drain(),
7275
+ this.treeFileMutations.drain()
7276
+ ]);
6710
7277
  }
6711
7278
  async reconcile() {
6712
7279
  const availableProjects = /* @__PURE__ */ new Set();
@@ -7831,52 +8398,217 @@ var TerminalMetadataManager = class {
7831
8398
  }
7832
8399
  };
7833
8400
  //#endregion
7834
- //#region src/server/app.ts
7835
- const UPLOAD_MIME_EXTENSIONS = /* @__PURE__ */ new Map([
7836
- ["application/pdf", "pdf"],
7837
- ["image/gif", "gif"],
7838
- ["image/jpeg", "jpg"],
7839
- ["image/png", "png"],
7840
- ["image/svg+xml", "svg"],
7841
- ["image/webp", "webp"],
7842
- ["text/plain", "txt"]
8401
+ //#region src/server/request-security.ts
8402
+ const MAX_HOST_BYTES = 512;
8403
+ const MAX_LOGIN_BYTES = 320;
8404
+ const MAX_NAME_BYTES = 512;
8405
+ const MAX_PROFILE_PICTURE_BYTES = 2048;
8406
+ const UNSAFE_METHODS = /* @__PURE__ */ new Set([
8407
+ "POST",
8408
+ "PUT",
8409
+ "PATCH",
8410
+ "DELETE"
7843
8411
  ]);
7844
- const UPLOAD_RETENTION_MS = 1440 * 6e4;
7845
- const UPLOAD_DIRECTORY_MAX_BYTES = 512 * 1024 * 1024;
7846
- const terminalPresetDefinitionsQuerySchema = z.object({
7847
- projectId: z.string().optional(),
7848
- worktreeId: z.string().optional()
7849
- });
7850
- const operationQuerySchema = z.object({
7851
- kind: z.enum([
7852
- "create",
7853
- "finish",
7854
- "discard",
7855
- "project_cleanup",
7856
- "remove",
7857
- "external_remove"
7858
- ]).optional(),
7859
- projectId: z.string().optional()
7860
- });
7861
- const discardStoredDataQuerySchema = z.object({ discardStoredData: z.string().optional() });
7862
- async function pruneTerminalUploads(directory, preservePath) {
7863
- const entries = await fs.readdir(directory, { withFileTypes: true });
7864
- const files = (await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.startsWith("treeport-upload-")).map(async (entry) => {
7865
- const filePath = path.join(directory, entry.name);
7866
- return fs.stat(filePath).then((stat) => ({
7867
- path: filePath,
7868
- size: stat.size,
7869
- mtimeMs: stat.mtimeMs
7870
- })).catch(() => null);
7871
- }))).filter((file) => file !== null).sort((left, right) => {
7872
- if (left.path === preservePath) return -1;
7873
- if (right.path === preservePath) return 1;
7874
- return right.mtimeMs - left.mtimeMs;
7875
- });
7876
- const expiredBefore = Date.now() - UPLOAD_RETENTION_MS;
7877
- let retainedBytes = 0;
7878
- for (const file of files) {
7879
- const expired = file.mtimeMs < expiredBefore;
8412
+ function denied(status, code, message) {
8413
+ return {
8414
+ allowed: false,
8415
+ principal: null,
8416
+ effectiveOrigin: null,
8417
+ status,
8418
+ code,
8419
+ message
8420
+ };
8421
+ }
8422
+ function headerValues(request, name) {
8423
+ const values = [];
8424
+ for (let index = 0; index < request.rawHeaders.length; index += 2) if (request.rawHeaders[index]?.toLowerCase() === name) values.push(request.rawHeaders[index + 1] ?? "");
8425
+ if (values.length > 0) return values;
8426
+ const value = request.headers[name];
8427
+ if (value === void 0) return [];
8428
+ return Array.isArray(value) ? value : [value];
8429
+ }
8430
+ function singleHeader(request, name) {
8431
+ const values = headerValues(request, name);
8432
+ return {
8433
+ present: values.length > 0,
8434
+ valid: values.length <= 1,
8435
+ value: values.length === 1 ? values[0] ?? null : null
8436
+ };
8437
+ }
8438
+ function hasControlCharacters(value) {
8439
+ return [...value].some((character) => {
8440
+ const code = character.codePointAt(0) ?? 0;
8441
+ return code <= 31 || code === 127;
8442
+ });
8443
+ }
8444
+ function parseHost(value) {
8445
+ if (value.length === 0 || Buffer.byteLength(value) > MAX_HOST_BYTES || hasControlCharacters(value) || /[\s,/@?#\\]/u.test(value)) return null;
8446
+ const candidate = `http://${value}`;
8447
+ if (!URL.canParse(candidate)) return null;
8448
+ const url = new URL(candidate);
8449
+ if (!url.host || url.pathname !== "/") return null;
8450
+ return {
8451
+ host: url.host,
8452
+ hostname: url.hostname.toLowerCase()
8453
+ };
8454
+ }
8455
+ function isLoopbackHostname(hostname) {
8456
+ return [
8457
+ "127.0.0.1",
8458
+ "localhost",
8459
+ "[::1]",
8460
+ "::1"
8461
+ ].includes(hostname);
8462
+ }
8463
+ function isLoopbackAddress(address) {
8464
+ if (!address) return false;
8465
+ const normalized = address.toLowerCase();
8466
+ if (normalized === "::1") return true;
8467
+ const octets = (normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized).split(".");
8468
+ return octets.length === 4 && octets[0] === "127" && octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255);
8469
+ }
8470
+ function validIdentityValue(value, maximumBytes) {
8471
+ return value.length > 0 && value === value.trim() && Buffer.byteLength(value) <= maximumBytes && !hasControlCharacters(value);
8472
+ }
8473
+ function effectiveOriginFor(request, source, incomingHost) {
8474
+ if (source === "local") return `http://${incomingHost.host}`;
8475
+ const forwardedHostHeader = singleHeader(request, "x-forwarded-host");
8476
+ const forwardedProtocolHeader = singleHeader(request, "x-forwarded-proto");
8477
+ if (!forwardedHostHeader.valid || !forwardedHostHeader.present || !forwardedProtocolHeader.valid || !forwardedProtocolHeader.present) return null;
8478
+ const forwardedHost = parseHost(forwardedHostHeader.value ?? "");
8479
+ if (!forwardedHost || forwardedProtocolHeader.value?.toLowerCase() !== "https") return null;
8480
+ return `https://${forwardedHost.host}`;
8481
+ }
8482
+ function allowsOpaqueWebPanelOrigin(request, socketUpgrade) {
8483
+ if (!["GET", "HEAD"].includes(request.method?.toUpperCase() ?? "")) return false;
8484
+ const pathname = new URL(request.url ?? "/", "http://treeport.local").pathname;
8485
+ if (socketUpgrade) return /^\/api\/web-panel-dev\/[a-f0-9]{24}\/@vite-hmr$/u.test(pathname);
8486
+ return /^\/api\/web-panels\/panel_[a-f0-9]{32}\/assets(?:\/|$)/u.test(pathname) || /^\/api\/web-panel-dev\/[a-f0-9]{24}\//u.test(pathname);
8487
+ }
8488
+ function originIsAllowed(request, effectiveOrigin, socketUpgrade) {
8489
+ const originHeader = singleHeader(request, "origin");
8490
+ if (!originHeader.valid) return false;
8491
+ if (originHeader.present) {
8492
+ const value = originHeader.value ?? "";
8493
+ if (value === "null") {
8494
+ if (!allowsOpaqueWebPanelOrigin(request, socketUpgrade)) return false;
8495
+ } else {
8496
+ if (!URL.canParse(value)) return false;
8497
+ const parsed = new URL(value);
8498
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== value || parsed.origin !== effectiveOrigin) return false;
8499
+ }
8500
+ }
8501
+ const fetchSiteHeader = singleHeader(request, "sec-fetch-site");
8502
+ if (!fetchSiteHeader.valid) return false;
8503
+ if ((UNSAFE_METHODS.has(request.method?.toUpperCase() ?? "") || socketUpgrade) && fetchSiteHeader.value?.toLowerCase() === "cross-site") return false;
8504
+ return true;
8505
+ }
8506
+ function authorizeRequest(request, options = {}) {
8507
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
8508
+ const hostHeader = singleHeader(request, "host");
8509
+ const incomingHost = hostHeader.valid && hostHeader.value ? parseHost(hostHeader.value.toLowerCase()) : null;
8510
+ if (!incomingHost) return denied(400, "INVALID_REQUEST_HOST", "The request host is invalid.");
8511
+ const loginHeader = singleHeader(request, "tailscale-user-login");
8512
+ const nameHeader = singleHeader(request, "tailscale-user-name");
8513
+ const profilePictureHeader = singleHeader(request, "tailscale-user-profile-pic");
8514
+ const hasTailscaleIdentity = loginHeader.present || nameHeader.present || profilePictureHeader.present;
8515
+ const headersHaveSingleValues = loginHeader.valid && nameHeader.valid && profilePictureHeader.valid;
8516
+ let principal;
8517
+ if (hasTailscaleIdentity) {
8518
+ const login = loginHeader.value ?? "";
8519
+ const name = nameHeader.value === "" ? null : nameHeader.value;
8520
+ const profilePicture = profilePictureHeader.value === "" ? null : profilePictureHeader.value;
8521
+ const validProfilePicture = profilePicture === null || validIdentityValue(profilePicture, MAX_PROFILE_PICTURE_BYTES) && URL.canParse(profilePicture) && ["http:", "https:"].includes(new URL(profilePicture).protocol);
8522
+ if (!headersHaveSingleValues || !loginHeader.present || !validIdentityValue(login, MAX_LOGIN_BYTES) || name !== null && !validIdentityValue(name, MAX_NAME_BYTES) || !validProfilePicture) return denied(401, "INVALID_TAILSCALE_IDENTITY", "The Tailscale user identity is invalid.");
8523
+ principal = {
8524
+ source: "tailscale",
8525
+ login,
8526
+ name,
8527
+ profilePicture
8528
+ };
8529
+ } else {
8530
+ if (!isLoopbackHostname(incomingHost.hostname)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
8531
+ principal = {
8532
+ source: "local",
8533
+ login: null,
8534
+ name: null,
8535
+ profilePicture: null
8536
+ };
8537
+ }
8538
+ const effectiveOrigin = effectiveOriginFor(request, principal.source, incomingHost);
8539
+ if (!effectiveOrigin) return denied(400, "INVALID_FORWARDED_ORIGIN", "The forwarded request origin is invalid.");
8540
+ if (!originIsAllowed(request, effectiveOrigin, options.socketUpgrade === true)) return denied(403, "INVALID_ORIGIN", "The request origin is not allowed.");
8541
+ return {
8542
+ allowed: true,
8543
+ principal,
8544
+ effectiveOrigin,
8545
+ status: 200,
8546
+ code: null,
8547
+ message: null
8548
+ };
8549
+ }
8550
+ function rejectHttpRequest(request, response, decision) {
8551
+ const apiRequest = request.url?.startsWith("/api/") === true;
8552
+ const body = apiRequest ? `${JSON.stringify({ error: {
8553
+ code: decision.code,
8554
+ message: decision.message
8555
+ } })}\n` : `${decision.message ?? "The request was rejected."}\n`;
8556
+ response.statusCode = decision.status;
8557
+ response.setHeader("cache-control", "no-store");
8558
+ response.setHeader("content-type", apiRequest ? "application/json; charset=utf-8" : "text/plain; charset=utf-8");
8559
+ response.end(request.method === "HEAD" ? void 0 : body);
8560
+ }
8561
+ //#endregion
8562
+ //#region src/server/app.ts
8563
+ const UPLOAD_MIME_EXTENSIONS = /* @__PURE__ */ new Map([
8564
+ ["application/pdf", "pdf"],
8565
+ ["image/gif", "gif"],
8566
+ ["image/jpeg", "jpg"],
8567
+ ["image/png", "png"],
8568
+ ["image/svg+xml", "svg"],
8569
+ ["image/webp", "webp"],
8570
+ ["text/plain", "txt"]
8571
+ ]);
8572
+ const UPLOAD_RETENTION_MS = 1440 * 6e4;
8573
+ const UPLOAD_DIRECTORY_MAX_BYTES = 512 * 1024 * 1024;
8574
+ const terminalPresetDefinitionsQuerySchema = z.object({
8575
+ projectId: z.string().optional(),
8576
+ worktreeId: z.string().optional()
8577
+ });
8578
+ const treeContextFieldsQuerySchema = z.object({ projectId: z.string().min(1) });
8579
+ const operationQuerySchema = z.object({
8580
+ kind: z.enum([
8581
+ "create",
8582
+ "finish",
8583
+ "discard",
8584
+ "project_cleanup",
8585
+ "remove",
8586
+ "external_remove"
8587
+ ]).optional(),
8588
+ projectId: z.string().optional()
8589
+ });
8590
+ const deletePanelQuerySchema = z.object({
8591
+ discardStoredData: z.string().optional(),
8592
+ force: z.string().optional()
8593
+ });
8594
+ async function pruneTerminalUploads(directory, preservePath) {
8595
+ const entries = await fs.readdir(directory, { withFileTypes: true });
8596
+ const files = (await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.startsWith("treeport-upload-")).map(async (entry) => {
8597
+ const filePath = path.join(directory, entry.name);
8598
+ return fs.stat(filePath).then((stat) => ({
8599
+ path: filePath,
8600
+ size: stat.size,
8601
+ mtimeMs: stat.mtimeMs
8602
+ })).catch(() => null);
8603
+ }))).filter((file) => file !== null).sort((left, right) => {
8604
+ if (left.path === preservePath) return -1;
8605
+ if (right.path === preservePath) return 1;
8606
+ return right.mtimeMs - left.mtimeMs;
8607
+ });
8608
+ const expiredBefore = Date.now() - UPLOAD_RETENTION_MS;
8609
+ let retainedBytes = 0;
8610
+ for (const file of files) {
8611
+ const expired = file.mtimeMs < expiredBefore;
7880
8612
  const overQuota = retainedBytes + file.size > UPLOAD_DIRECTORY_MAX_BYTES;
7881
8613
  if (file.path !== preservePath && (expired || overQuota)) {
7882
8614
  await fs.rm(file.path, { force: true });
@@ -7895,7 +8627,13 @@ function queryInput(schema) {
7895
8627
  if (!result.success) throw new DomainError("VALIDATION_ERROR", "Request validation failed", 400, z.flattenError(result.error));
7896
8628
  });
7897
8629
  }
7898
- function createApp({ service, config, tmux, applicationUpdate, terminalMetadata, webDist }) {
8630
+ function createTreeFilesApi(service) {
8631
+ return new Hono().get("/api/panels/:panelId/files", async (context) => context.json(await service.listTreeFiles(context.req.param("panelId")))).post("/api/panels/:panelId/files/read", jsonInput(readTreeFileSchema), async (context) => {
8632
+ const body = context.req.valid("json");
8633
+ return context.json(await service.readTreeFile(context.req.param("panelId"), body.path));
8634
+ }).put("/api/panels/:panelId/files", jsonInput(writeTreeFileSchema), async (context) => context.json(await service.writeTreeFile(context.req.param("panelId"), context.req.valid("json"))));
8635
+ }
8636
+ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata, browserSessions, webDist }) {
7899
8637
  const app = new Hono();
7900
8638
  app.use("/api/*", requestId({
7901
8639
  limitLength: 128,
@@ -7935,10 +8673,52 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
7935
8673
  details: { requestId: requestIdentifier }
7936
8674
  } }, 500);
7937
8675
  });
8676
+ const browserApi = new Hono().get("/api/browser/status", async (context) => {
8677
+ if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
8678
+ return context.json(await browserSessions.status());
8679
+ }).post("/api/browser/install", async (context) => {
8680
+ if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
8681
+ return context.json({ message: await browserSessions.install() });
8682
+ }).delete("/api/browser/install", async (context) => {
8683
+ if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
8684
+ await browserSessions.remove();
8685
+ return context.json({ ok: true });
8686
+ }).put("/api/worktrees/:worktreeId/web-panel-definitions/:definitionId/permission-grant", jsonInput(updateWebPanelPermissionGrantSchema), async (context) => {
8687
+ const body = context.req.valid("json");
8688
+ const definition = await service.setWebPanelPermissionGrant(context.req.param("worktreeId"), context.req.param("definitionId"), body.granted, body.permissions);
8689
+ return context.json({ definition });
8690
+ }).post("/api/panels/:panelId/browser-agent", jsonInput(browserAgentCommandSchema), async (context) => {
8691
+ if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
8692
+ const panelId = context.req.param("panelId");
8693
+ const input = context.req.valid("json");
8694
+ return context.json({ output: await browserSessions.agentCommand(panelId, input).catch((cause) => {
8695
+ if (cause instanceof DomainError) throw cause;
8696
+ throw new DomainError("BROWSER_COMMAND_FAILED", cause instanceof Error ? cause.message : "The Browser command failed.", 409, {
8697
+ command: input.command,
8698
+ recovery: input.command === "snapshot" ? `Retry \`treeport browser snapshot --panel ${panelId}\`.` : input.command === "screenshot" ? `Open Browser ${panelId} in Treeport, then retry the screenshot.` : `Run \`treeport browser snapshot --panel ${panelId}\`, then retry this Browser command.`
8699
+ });
8700
+ }) });
8701
+ }).post("/api/panels/:panelId/browser-owner-ticket", jsonInput(browserOwnerTicketRequestSchema), async (context) => {
8702
+ if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Browser service is unavailable", 503);
8703
+ const remoteAddress = getConnInfo(context).remote.address;
8704
+ const proxiedIdentity = [
8705
+ "tailscale-user-login",
8706
+ "tailscale-user-name",
8707
+ "tailscale-user-profile-pic"
8708
+ ].some((name) => context.req.header(name) !== void 0);
8709
+ if (!isLoopbackAddress(remoteAddress) || proxiedIdentity) throw new DomainError("BROWSER_LOCAL_OWNER_REQUIRED", "Only a local desktop app can own this Browser.", 403);
8710
+ return context.json(await browserSessions.issueOwnerTicket(context.req.param("panelId"), context.req.valid("json").clientId));
8711
+ }).post("/api/panels/:panelId/browser-ticket", jsonInput(browserTicketRequestSchema), async (context) => {
8712
+ if (!browserSessions) throw new DomainError("BROWSER_UNAVAILABLE", "Hosted browser service is unavailable", 503);
8713
+ const body = context.req.valid("json");
8714
+ return context.json({ ticket: await browserSessions.issueTicket(context.req.param("panelId"), body.clientId, body.visible) });
8715
+ });
8716
+ app.route("/", browserApi);
8717
+ app.route("/", createTreeFilesApi(service));
7938
8718
  const api = new Hono().get("/api/health", (context) => context.json({
7939
8719
  ok: true,
7940
8720
  version: config.appVersion ?? "development",
7941
- protocolVersion: 2,
8721
+ protocolVersion: 3,
7942
8722
  hostname: os.hostname(),
7943
8723
  pid: process.pid,
7944
8724
  instanceId: config.instanceId ?? null,
@@ -7951,7 +8731,7 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
7951
8731
  }).post("/api/update", async (context) => {
7952
8732
  context.header("Cache-Control", "no-store");
7953
8733
  return context.json(await applicationUpdate.start(), 202);
7954
- }).get("/api/terminal-presets", async (context) => context.json({ presets: await service.listTerminalPresets() })).get("/api/terminal-preset-definitions", queryInput(terminalPresetDefinitionsQuerySchema), async (context) => context.json(await service.listTerminalPresetDefinitions(context.req.valid("query")))).post("/api/terminal-presets", jsonInput(createTerminalPresetSchema), async (context) => {
8734
+ }).get("/api/terminal-presets", async (context) => context.json({ presets: await service.listTerminalPresets() })).get("/api/tree-context-fields", queryInput(treeContextFieldsQuerySchema), async (context) => context.json(await service.listTreeContextFields(context.req.valid("query").projectId))).get("/api/terminal-preset-definitions", queryInput(terminalPresetDefinitionsQuerySchema), async (context) => context.json(await service.listTerminalPresetDefinitions(context.req.valid("query")))).post("/api/terminal-presets", jsonInput(createTerminalPresetSchema), async (context) => {
7955
8735
  const body = context.req.valid("json");
7956
8736
  return context.json({ preset: await service.createTerminalPreset(body) }, 201);
7957
8737
  }).patch("/api/terminal-presets/:presetId", jsonInput(updateTerminalPresetSchema), async (context) => {
@@ -8003,19 +8783,23 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
8003
8783
  let initialTerminal;
8004
8784
  if (body.initialTerminal) {
8005
8785
  initialTerminal = { name: body.initialTerminal.name };
8786
+ if (body.initialTerminal.initialTitle) initialTerminal.initialTitle = body.initialTerminal.initialTitle;
8006
8787
  if (body.initialTerminal.argv) initialTerminal.argv = body.initialTerminal.argv;
8007
8788
  if (body.initialTerminal.returnToShell) initialTerminal.returnToShell = true;
8008
8789
  if (body.initialTerminal.initialSize) initialTerminal.initialSize = body.initialTerminal.initialSize;
8009
8790
  }
8010
- return context.json({ operation: await service.beginCreateWorktree(context.req.param("projectId"), body.name, body.base, initialTerminal, body.sourceWorktreeId) }, 202);
8791
+ return context.json({ operation: await service.beginCreateWorktree(context.req.param("projectId"), body.name, body.base, initialTerminal, body.sourceWorktreeId, body.context) }, 202);
8011
8792
  }).get("/api/worktrees/:worktreeId", async (context) => {
8012
8793
  const worktreeId = context.req.param("worktreeId");
8013
8794
  await service.refreshPr(worktreeId, false);
8014
8795
  return context.json({ worktree: await service.getWorktreeSnapshot(worktreeId) });
8015
- }).post("/api/worktrees/:worktreeId/open", jsonInput(requestWorkspaceOpenSchema), async (context) => {
8796
+ }).get("/api/worktrees/:worktreeId/context", async (context) => context.json({ context: await service.getWorktreeContext(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/open", jsonInput(requestWorkspaceOpenSchema), async (context) => {
8016
8797
  await service.requestWorkspaceOpen(context.req.param("worktreeId"), context.req.valid("json").sourceTerminalId);
8017
8798
  return context.json({ ok: true });
8018
- }).get("/api/worktrees/:worktreeId/web-panel-definitions", async (context) => context.json({ definitions: await service.listWebPanelDefinitions(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/panels", jsonInput(createWebPanelSchema), async (context) => {
8799
+ }).post("/api/worktrees/:worktreeId/browser-panels", jsonInput(createBrowserPanelSchema), async (context) => {
8800
+ const body = context.req.valid("json");
8801
+ return context.json(await service.openBrowserPanel(context.req.param("worktreeId"), body.url, body.sourceTerminalId ?? null, null), 201);
8802
+ }).post("/api/terminals/:terminalId/browser-panels/open", jsonInput(openBrowserPanelFromTerminalSchema), async (context) => context.json(await service.openBrowserPanelFromTerminal(context.req.param("terminalId"), context.req.valid("json").url), 201)).get("/api/worktrees/:worktreeId/web-panel-definitions", async (context) => context.json({ definitions: await service.listWebPanelDefinitions(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/panels", jsonInput(createWebPanelSchema), async (context) => {
8019
8803
  const body = context.req.valid("json");
8020
8804
  return context.json({ panel: await service.createWebPanel(context.req.param("worktreeId"), body.definitionId, {
8021
8805
  input: body.input ?? null,
@@ -8027,10 +8811,13 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
8027
8811
  input: body.input ?? null,
8028
8812
  cwd: body.launchCwd ?? null
8029
8813
  }, body.newInstance ?? false, body.sourceTerminalId ?? null));
8030
- }).delete("/api/panels/:panelId", queryInput(discardStoredDataQuerySchema), async (context) => {
8031
- await service.deleteWebPanel(context.req.param("panelId"), context.req.valid("query").discardStoredData === "true");
8814
+ }).delete("/api/panels/:panelId", queryInput(deletePanelQuerySchema), async (context) => {
8815
+ const panelId = context.req.param("panelId");
8816
+ const query = context.req.valid("query");
8817
+ if (await browserSessions?.requestPanelClose(panelId, query.force === "true") === false) throw new DomainError("BROWSER_BEFORE_UNLOAD", "Changes you made may not be saved.", 409);
8818
+ await service.deletePanel(panelId, query.discardStoredData === "true");
8032
8819
  return context.json({ ok: true });
8033
- }).get("/api/panels/:panelId/context", async (context) => context.json({ context: await service.getWebPanelContext(context.req.param("panelId")) })).get("/api/panels/:panelId/diff", async (context) => context.json({ diff: await service.getWebPanelDiff(context.req.param("panelId")) })).get("/api/panels/:panelId/network/listeners", async (context) => context.json({ discovery: await service.getWebPanelListeners(context.req.param("panelId")) })).get("/api/panels/:panelId/storage", async (context) => context.json({ hasData: await service.hasWebPanelStorage(context.req.param("panelId")) })).post("/api/panels/:panelId/storage/get", jsonInput(getWebPanelStorageSchema), async (context) => {
8820
+ }).get("/api/panels/:panelId/context", async (context) => context.json({ context: await service.getWebPanelContext(context.req.param("panelId")) })).get("/api/panels/:panelId/diff", async (context) => context.json({ diff: await service.getWebPanelDiff(context.req.param("panelId")) })).get("/api/panels/:panelId/network/listeners", async (context) => context.json({ discovery: await service.getPanelListeners(context.req.param("panelId")) })).get("/api/panels/:panelId/storage", async (context) => context.json({ hasData: await service.hasWebPanelStorage(context.req.param("panelId")) })).post("/api/panels/:panelId/storage/get", jsonInput(getWebPanelStorageSchema), async (context) => {
8034
8821
  const body = context.req.valid("json");
8035
8822
  return context.json({ value: await service.getWebPanelStorage(context.req.param("panelId"), body.key) });
8036
8823
  }).put("/api/panels/:panelId/storage", jsonInput(setWebPanelStorageSchema), async (context) => {
@@ -8091,6 +8878,7 @@ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata,
8091
8878
  }).post("/api/worktrees/:worktreeId/terminals", jsonInput(createTerminalSchema), async (context) => {
8092
8879
  const body = context.req.valid("json");
8093
8880
  const options = {};
8881
+ if (body.initialTitle) options.initialTitle = body.initialTitle;
8094
8882
  if (body.returnToShell) options.returnToShell = true;
8095
8883
  if (body.closeOnSuccess) options.closeOnSuccess = true;
8096
8884
  if (body.initialSize) options.initialSize = body.initialSize;
@@ -8445,18 +9233,1879 @@ function createApplicationUpdateManager(config, dependencies = {}) {
8445
9233
  launchError = launchResult.error instanceof Error ? launchResult.error.message : "Treeport could not start the update process.";
8446
9234
  throw new DomainError("APPLICATION_UPDATE_START_FAILED", "Treeport could not start the update process.", 500);
8447
9235
  }
8448
- return status();
8449
- },
8450
- dispose() {
8451
- disposed = true;
8452
- pollingStarted = false;
8453
- if (pollTimer) {
8454
- clearTimeout(pollTimer);
8455
- pollTimer = null;
9236
+ return status();
9237
+ },
9238
+ dispose() {
9239
+ disposed = true;
9240
+ pollingStarted = false;
9241
+ if (pollTimer) {
9242
+ clearTimeout(pollTimer);
9243
+ pollTimer = null;
9244
+ }
9245
+ }
9246
+ };
9247
+ }
9248
+ //#endregion
9249
+ //#region src/server/playwright-browser.ts
9250
+ const DEFAULT_VIEWPORT = {
9251
+ width: 1280,
9252
+ height: 800
9253
+ };
9254
+ const DEFAULT_MAX_FRAME_RATE = 15;
9255
+ var PlaywrightBrowserHost = class {
9256
+ cachePath;
9257
+ profilePath;
9258
+ browser = null;
9259
+ context = null;
9260
+ pages = /* @__PURE__ */ new Set();
9261
+ operation = Promise.resolve();
9262
+ constructor(cachePath, profilePath) {
9263
+ this.cachePath = cachePath;
9264
+ this.profilePath = profilePath;
9265
+ }
9266
+ get started() {
9267
+ return this.context !== null;
9268
+ }
9269
+ schedule(operation) {
9270
+ const result = this.operation.then(operation);
9271
+ this.operation = result.then(() => void 0, () => void 0);
9272
+ return result;
9273
+ }
9274
+ openPage() {
9275
+ return this.schedule(async () => {
9276
+ let context = this.context;
9277
+ let browser = this.browser;
9278
+ let initialPage = null;
9279
+ if (!context || !browser?.isConnected()) {
9280
+ await fs.mkdir(this.profilePath, {
9281
+ recursive: true,
9282
+ mode: 448
9283
+ });
9284
+ await fs.chmod(this.profilePath, 448);
9285
+ process.env.PLAYWRIGHT_BROWSERS_PATH = this.cachePath;
9286
+ const { chromium } = await import("playwright");
9287
+ context = await chromium.launchPersistentContext(this.profilePath, {
9288
+ channel: "chromium",
9289
+ headless: true,
9290
+ acceptDownloads: false,
9291
+ viewport: DEFAULT_VIEWPORT
9292
+ });
9293
+ browser = context.browser();
9294
+ if (!browser) {
9295
+ await context.close();
9296
+ throw new Error("Playwright did not expose the hosted browser process.");
9297
+ }
9298
+ this.context = context;
9299
+ this.browser = browser;
9300
+ initialPage = context.pages()[0] ?? null;
9301
+ browser.once("disconnected", () => {
9302
+ if (this.browser === browser) {
9303
+ this.browser = null;
9304
+ this.context = null;
9305
+ this.pages.clear();
9306
+ }
9307
+ });
9308
+ }
9309
+ const page = initialPage && !initialPage.isClosed() ? initialPage : await context.newPage();
9310
+ this.pages.add(page);
9311
+ return {
9312
+ browser,
9313
+ context,
9314
+ page
9315
+ };
9316
+ });
9317
+ }
9318
+ closePage(page) {
9319
+ return this.schedule(async () => {
9320
+ this.pages.delete(page);
9321
+ await page.close().catch(() => void 0);
9322
+ if (this.pages.size > 0 || !this.context) return;
9323
+ const context = this.context;
9324
+ this.context = null;
9325
+ this.browser = null;
9326
+ await context.close().catch(() => void 0);
9327
+ });
9328
+ }
9329
+ close() {
9330
+ return this.schedule(async () => {
9331
+ const context = this.context;
9332
+ this.context = null;
9333
+ this.browser = null;
9334
+ this.pages.clear();
9335
+ await context?.close().catch(() => void 0);
9336
+ });
9337
+ }
9338
+ };
9339
+ var LatestBrowserFrameProducer = class {
9340
+ publish;
9341
+ acknowledge;
9342
+ maxFrameRate;
9343
+ active = false;
9344
+ pending = null;
9345
+ timer = null;
9346
+ lastPublishedAt = 0;
9347
+ constructor(publish, acknowledge, maxFrameRate = DEFAULT_MAX_FRAME_RATE) {
9348
+ this.publish = publish;
9349
+ this.acknowledge = acknowledge;
9350
+ this.maxFrameRate = maxFrameRate;
9351
+ }
9352
+ start() {
9353
+ this.active = true;
9354
+ this.lastPublishedAt = 0;
9355
+ }
9356
+ receive(frame) {
9357
+ if (!this.active) {
9358
+ this.acknowledge(frame.sessionId);
9359
+ return;
9360
+ }
9361
+ if (this.pending) this.acknowledge(this.pending.sessionId);
9362
+ this.pending = frame;
9363
+ if (this.timer) return;
9364
+ const minimumInterval = 1e3 / this.maxFrameRate;
9365
+ const delay = Math.max(0, this.lastPublishedAt + minimumInterval - Date.now());
9366
+ if (delay === 0) {
9367
+ this.publishPending();
9368
+ return;
9369
+ }
9370
+ this.timer = setTimeout(() => {
9371
+ this.timer = null;
9372
+ this.publishPending();
9373
+ }, delay);
9374
+ this.timer.unref?.();
9375
+ }
9376
+ stop() {
9377
+ this.active = false;
9378
+ if (this.timer) {
9379
+ clearTimeout(this.timer);
9380
+ this.timer = null;
9381
+ }
9382
+ if (this.pending) {
9383
+ this.acknowledge(this.pending.sessionId);
9384
+ this.pending = null;
9385
+ }
9386
+ }
9387
+ publishPending() {
9388
+ const frame = this.pending;
9389
+ this.pending = null;
9390
+ if (!frame) return;
9391
+ if (!this.active) {
9392
+ this.acknowledge(frame.sessionId);
9393
+ return;
9394
+ }
9395
+ this.lastPublishedAt = Date.now();
9396
+ try {
9397
+ const data = Buffer.from(frame.data, "base64");
9398
+ if (data.byteLength <= 8 * 1024 * 1024) this.publish({
9399
+ mimeType: "image/jpeg",
9400
+ timestamp: frame.metadata.timestamp ? frame.metadata.timestamp * 1e3 : Date.now(),
9401
+ width: frame.metadata.deviceWidth,
9402
+ height: frame.metadata.deviceHeight,
9403
+ data
9404
+ });
9405
+ } finally {
9406
+ this.acknowledge(frame.sessionId);
9407
+ }
9408
+ }
9409
+ };
9410
+ var PlaywrightBrowser = class {
9411
+ host;
9412
+ workspacePath;
9413
+ callbacks;
9414
+ browser = null;
9415
+ context = null;
9416
+ page = null;
9417
+ cdp = null;
9418
+ frameProducer = null;
9419
+ screencasting = false;
9420
+ screencastTail = Promise.resolve();
9421
+ closing = false;
9422
+ dialogHandler = null;
9423
+ consoleMessages = [];
9424
+ requests = [];
9425
+ historyRevision = 0;
9426
+ titleTimer = null;
9427
+ stateValue = {
9428
+ url: "about:blank",
9429
+ title: "",
9430
+ loading: false,
9431
+ canGoBack: false,
9432
+ canGoForward: false,
9433
+ viewport: { ...DEFAULT_VIEWPORT }
9434
+ };
9435
+ constructor(host, workspacePath, callbacks) {
9436
+ this.host = host;
9437
+ this.workspacePath = workspacePath;
9438
+ this.callbacks = callbacks;
9439
+ }
9440
+ static async status(cachePath) {
9441
+ process.env.PLAYWRIGHT_BROWSERS_PATH = cachePath;
9442
+ const [{ chromium }, packageJson] = await Promise.all([import("playwright"), import("playwright/package.json", { with: { type: "json" } })]);
9443
+ const executablePath = chromium.executablePath();
9444
+ const installed = await fs.access(executablePath).then(() => true, () => false);
9445
+ const browserRevision = executablePath.split(/[/\\]/u).find((part) => part.startsWith("chromium-"));
9446
+ let launchReady = false;
9447
+ let launchError = null;
9448
+ if (installed) {
9449
+ const statusProfile = await fs.mkdtemp(path.join(cachePath, ".launch-status-"));
9450
+ const context = await chromium.launchPersistentContext(statusProfile, {
9451
+ channel: "chromium",
9452
+ headless: true,
9453
+ acceptDownloads: false
9454
+ }).catch((error) => {
9455
+ launchError = error instanceof Error ? error.message : String(error);
9456
+ return null;
9457
+ });
9458
+ if (context) {
9459
+ launchReady = true;
9460
+ await context.close();
9461
+ }
9462
+ await fs.rm(statusProfile, {
9463
+ recursive: true,
9464
+ force: true
9465
+ });
9466
+ }
9467
+ return {
9468
+ installed,
9469
+ executablePath,
9470
+ playwrightVersion: String(packageJson.default.version),
9471
+ browserRevision: browserRevision ?? "unknown",
9472
+ channel: "chromium",
9473
+ launchReady,
9474
+ launchError
9475
+ };
9476
+ }
9477
+ get state() {
9478
+ return this.stateValue;
9479
+ }
9480
+ get connected() {
9481
+ return this.browser?.isConnected() ?? false;
9482
+ }
9483
+ async launch() {
9484
+ if (this.browser) return;
9485
+ const { browser, context, page } = await this.host.openPage();
9486
+ this.context = context;
9487
+ this.browser = browser;
9488
+ browser.once("disconnected", () => {
9489
+ if (!this.closing) this.callbacks.crashed("The hosted browser process stopped.");
9490
+ });
9491
+ this.page = page;
9492
+ await page.setViewportSize(this.stateValue.viewport);
9493
+ const cdp = await context.newCDPSession(page);
9494
+ this.cdp = cdp;
9495
+ this.frameProducer = new LatestBrowserFrameProducer((frame) => this.callbacks.frame(frame), (sessionId) => {
9496
+ cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => void 0);
9497
+ });
9498
+ cdp.on("Page.screencastFrame", (frame) => this.frameProducer?.receive(frame));
9499
+ page.on("popup", (candidate) => {
9500
+ (async () => {
9501
+ const supportedUrl = (value) => {
9502
+ if (!URL.canParse(value)) return null;
9503
+ const url = new URL(value);
9504
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
9505
+ };
9506
+ let popupUrl = supportedUrl(candidate.url());
9507
+ if (!popupUrl) {
9508
+ await candidate.waitForURL((url) => supportedUrl(url.href) !== null, { timeout: 5e3 }).catch(() => void 0);
9509
+ popupUrl = supportedUrl(candidate.url());
9510
+ }
9511
+ if (popupUrl) this.callbacks.popup(popupUrl);
9512
+ else this.callbacks.navigationError("The popup did not supply a supported HTTP or HTTPS URL.");
9513
+ await candidate.close().catch(() => void 0);
9514
+ })();
9515
+ });
9516
+ page.on("download", (download) => {
9517
+ this.callbacks.navigationError("Downloads are not supported in Browser.");
9518
+ download.cancel();
9519
+ });
9520
+ page.on("filechooser", (chooser) => {
9521
+ this.callbacks.navigationError("File pickers are not supported in Browser.");
9522
+ chooser.setFiles([]).catch(() => void 0);
9523
+ });
9524
+ page.on("close", () => {
9525
+ if (!this.closing) this.callbacks.crashed("The hosted browser page closed.");
9526
+ });
9527
+ page.on("console", (message) => {
9528
+ this.consoleMessages.push(`${message.type()}: ${message.text()}`);
9529
+ if (this.consoleMessages.length > 1e3) this.consoleMessages.shift();
9530
+ });
9531
+ page.on("pageerror", (error) => {
9532
+ this.consoleMessages.push(`error: ${error.message}`);
9533
+ if (this.consoleMessages.length > 1e3) this.consoleMessages.shift();
9534
+ });
9535
+ page.on("request", (request) => {
9536
+ this.requests.push(`${request.method()} ${request.url()}`);
9537
+ if (this.requests.length > 2e3) this.requests.shift();
9538
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame()) this.updateState({ loading: true });
9539
+ });
9540
+ page.on("requestfailed", (request) => {
9541
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
9542
+ this.updateState({ loading: false });
9543
+ this.callbacks.navigationError(request.failure()?.errorText ?? "Navigation failed.");
9544
+ }
9545
+ });
9546
+ page.on("framenavigated", (frame) => {
9547
+ if (frame === page.mainFrame()) this.refreshPageState();
9548
+ });
9549
+ page.on("domcontentloaded", () => void this.refreshPageState());
9550
+ page.on("load", () => {
9551
+ this.updateState({ loading: false });
9552
+ this.refreshPageState();
9553
+ });
9554
+ this.dialogHandler = (dialog) => void dialog.dismiss();
9555
+ page.on("dialog", this.dialogHandler);
9556
+ page.once("crash", () => this.callbacks.crashed("The hosted browser page crashed."));
9557
+ this.cdp.on("Page.navigatedWithinDocument", () => {
9558
+ this.refreshPageState();
9559
+ });
9560
+ await this.cdp.send("Page.enable");
9561
+ this.titleTimer = setInterval(() => {
9562
+ page.title().then((title) => {
9563
+ if (title !== this.stateValue.title) this.updateState({ title });
9564
+ }).catch(() => void 0);
9565
+ }, 500);
9566
+ this.titleTimer.unref();
9567
+ await this.refreshPageState();
9568
+ }
9569
+ updateState(patch) {
9570
+ this.stateValue = {
9571
+ ...this.stateValue,
9572
+ ...patch,
9573
+ viewport: patch.viewport ?? this.stateValue.viewport
9574
+ };
9575
+ this.callbacks.state(this.stateValue);
9576
+ }
9577
+ async refreshPageState() {
9578
+ const page = this.page;
9579
+ const cdp = this.cdp;
9580
+ if (!page || !cdp || page.isClosed()) return;
9581
+ const revision = ++this.historyRevision;
9582
+ const [title, history] = await Promise.all([page.title().catch(() => ""), cdp.send("Page.getNavigationHistory").catch(() => ({
9583
+ currentIndex: 0,
9584
+ entries: [{
9585
+ id: 0,
9586
+ url: page.url(),
9587
+ userTypedURL: "",
9588
+ title: "",
9589
+ transitionType: "typed"
9590
+ }]
9591
+ }))]);
9592
+ if (revision !== this.historyRevision || page.isClosed()) return;
9593
+ this.updateState({
9594
+ url: page.url(),
9595
+ title,
9596
+ canGoBack: history.currentIndex > 0,
9597
+ canGoForward: history.currentIndex < history.entries.length - 1
9598
+ });
9599
+ }
9600
+ async setScreencasting(enabled) {
9601
+ const operation = this.screencastTail.then(async () => {
9602
+ const page = this.page;
9603
+ const cdp = this.cdp;
9604
+ const frameProducer = this.frameProducer;
9605
+ if (!page || page.isClosed() || !cdp || !frameProducer || enabled === this.screencasting) return;
9606
+ this.screencasting = enabled;
9607
+ if (!enabled) {
9608
+ frameProducer.stop();
9609
+ await cdp.send("Page.stopScreencast").catch(() => void 0);
9610
+ return;
9611
+ }
9612
+ frameProducer.start();
9613
+ await cdp.send("Page.startScreencast", {
9614
+ format: "jpeg",
9615
+ quality: 75,
9616
+ maxWidth: this.stateValue.viewport.width,
9617
+ maxHeight: this.stateValue.viewport.height,
9618
+ everyNthFrame: 1
9619
+ }).catch((error) => {
9620
+ this.screencasting = false;
9621
+ frameProducer.stop();
9622
+ throw error;
9623
+ });
9624
+ });
9625
+ this.screencastTail = operation.catch(() => void 0);
9626
+ return operation;
9627
+ }
9628
+ async command(message) {
9629
+ const page = this.page;
9630
+ if (!page || page.isClosed()) throw new Error("The hosted browser page is unavailable.");
9631
+ if (message.type === "navigate") {
9632
+ this.updateState({ loading: true });
9633
+ await page.goto(message.url, { waitUntil: "commit" }).catch((error) => {
9634
+ this.updateState({ loading: false });
9635
+ throw error;
9636
+ });
9637
+ await this.refreshPageState();
9638
+ return;
9639
+ }
9640
+ if (message.type === "back") {
9641
+ await page.goBack({ waitUntil: "commit" });
9642
+ await this.refreshPageState();
9643
+ return;
9644
+ }
9645
+ if (message.type === "forward") {
9646
+ await page.goForward({ waitUntil: "commit" });
9647
+ await this.refreshPageState();
9648
+ return;
9649
+ }
9650
+ if (message.type === "reload") {
9651
+ this.updateState({ loading: true });
9652
+ await page.reload({ waitUntil: "commit" }).catch((error) => {
9653
+ this.updateState({ loading: false });
9654
+ throw error;
9655
+ });
9656
+ await this.refreshPageState();
9657
+ return;
9658
+ }
9659
+ if (message.type === "stop") {
9660
+ await this.cdp?.send("Page.stopLoading");
9661
+ this.updateState({ loading: false });
9662
+ return;
9663
+ }
9664
+ if (message.type === "resize") {
9665
+ const wasScreencasting = this.screencasting;
9666
+ if (wasScreencasting) await this.setScreencasting(false);
9667
+ await page.setViewportSize({
9668
+ width: message.width,
9669
+ height: message.height
9670
+ });
9671
+ this.updateState({ viewport: {
9672
+ width: message.width,
9673
+ height: message.height
9674
+ } });
9675
+ if (wasScreencasting) await this.setScreencasting(true);
9676
+ return;
9677
+ }
9678
+ if (message.type === "pointer") {
9679
+ if (message.phase === "move") await page.mouse.move(message.x, message.y);
9680
+ else if (message.phase === "down") {
9681
+ await page.mouse.move(message.x, message.y);
9682
+ await page.mouse.down({ button: message.button ?? "left" });
9683
+ } else {
9684
+ await page.mouse.move(message.x, message.y);
9685
+ await page.mouse.up({ button: message.button ?? "left" });
9686
+ }
9687
+ return;
9688
+ }
9689
+ if (message.type === "wheel") {
9690
+ await page.mouse.wheel(message.deltaX, message.deltaY);
9691
+ return;
9692
+ }
9693
+ if (message.type === "key") {
9694
+ if (message.phase === "down") await page.keyboard.down(message.key);
9695
+ else await page.keyboard.up(message.key);
9696
+ return;
9697
+ }
9698
+ if (message.type === "insertText") await page.keyboard.insertText(message.text);
9699
+ }
9700
+ async agentCommand(input) {
9701
+ const page = this.page;
9702
+ if (!page || page.isClosed()) throw new Error("The hosted browser page is unavailable.");
9703
+ const target = (value) => page.locator(`aria-ref=${value}`);
9704
+ if (input.command === "snapshot") return Reflect.apply(page.ariaSnapshot, page, [{ mode: "ai" }]);
9705
+ if (input.command === "click") {
9706
+ await target(input.args[0]).click();
9707
+ return `Clicked ${input.args[0]}`;
9708
+ }
9709
+ if (input.command === "fill") {
9710
+ await target(input.args[0]).fill(input.args[1]);
9711
+ return `Filled ${input.args[0]}`;
9712
+ }
9713
+ if (input.command === "press") {
9714
+ await page.keyboard.press(input.args[0]);
9715
+ return `Pressed ${input.args[0]}`;
9716
+ }
9717
+ if (input.command === "console") {
9718
+ const minimum = input.args[0] ?? "info";
9719
+ const levels = [
9720
+ "debug",
9721
+ "info",
9722
+ "warning",
9723
+ "error"
9724
+ ];
9725
+ const minimumIndex = Math.max(0, levels.indexOf(minimum));
9726
+ return this.consoleMessages.filter((line) => {
9727
+ const level = line.slice(0, line.indexOf(":"));
9728
+ const index = levels.indexOf(level);
9729
+ return index < 0 || index >= minimumIndex;
9730
+ }).join("\n") || "No console messages.";
9731
+ }
9732
+ if (input.command === "requests") return this.requests.join("\n") || "No network requests.";
9733
+ if (input.command === "screenshot") {
9734
+ const screenshotPath = path.join(this.workspacePath, `screenshot-${Date.now()}.png`);
9735
+ await page.screenshot({ path: screenshotPath });
9736
+ return `Screenshot saved to ${screenshotPath}`;
9737
+ }
9738
+ if (input.command === "goto") {
9739
+ await page.goto(input.args[0]);
9740
+ await this.refreshPageState();
9741
+ return `Navigated to ${page.url()}`;
9742
+ }
9743
+ if (input.command === "go-back") {
9744
+ await page.goBack();
9745
+ await this.refreshPageState();
9746
+ return `Navigated to ${page.url()}`;
9747
+ }
9748
+ if (input.command === "go-forward") {
9749
+ await page.goForward();
9750
+ await this.refreshPageState();
9751
+ return `Navigated to ${page.url()}`;
9752
+ }
9753
+ await page.reload();
9754
+ await this.refreshPageState();
9755
+ return `Reloaded ${page.url()}`;
9756
+ }
9757
+ async requestClose(force) {
9758
+ const page = this.page;
9759
+ if (!page || page.isClosed()) return true;
9760
+ const previousClosing = this.closing;
9761
+ this.closing = true;
9762
+ if (this.dialogHandler) page.off("dialog", this.dialogHandler);
9763
+ let timer = null;
9764
+ let closeHandler = null;
9765
+ let dialogHandler = null;
9766
+ try {
9767
+ return await new Promise((resolve, reject) => {
9768
+ closeHandler = () => resolve(true);
9769
+ dialogHandler = (dialog) => {
9770
+ if (dialog.type() !== "beforeunload") {
9771
+ dialog.dismiss().catch(reject);
9772
+ return;
9773
+ }
9774
+ if (force) dialog.accept().catch(reject);
9775
+ else dialog.dismiss().then(() => resolve(false), reject);
9776
+ };
9777
+ page.once("close", closeHandler);
9778
+ page.on("dialog", dialogHandler);
9779
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("The page did not finish closing.")), 5e3);
9780
+ timer.unref();
9781
+ page.close({ runBeforeUnload: true }).catch(reject);
9782
+ });
9783
+ } finally {
9784
+ if (timer) clearTimeout(timer);
9785
+ if (closeHandler) page.off("close", closeHandler);
9786
+ if (dialogHandler) page.off("dialog", dialogHandler);
9787
+ if (!page.isClosed() && this.dialogHandler) page.on("dialog", this.dialogHandler);
9788
+ this.closing = previousClosing;
9789
+ }
9790
+ }
9791
+ async close() {
9792
+ if (this.closing) return;
9793
+ this.closing = true;
9794
+ this.frameProducer?.stop();
9795
+ await this.setScreencasting(false).catch(() => void 0);
9796
+ if (this.titleTimer) {
9797
+ clearInterval(this.titleTimer);
9798
+ this.titleTimer = null;
9799
+ }
9800
+ await this.screencastTail;
9801
+ const page = this.page;
9802
+ if (page) await this.host.closePage(page);
9803
+ this.page = null;
9804
+ this.context = null;
9805
+ this.cdp = null;
9806
+ this.frameProducer = null;
9807
+ this.browser = null;
9808
+ }
9809
+ };
9810
+ //#endregion
9811
+ //#region src/server/browser-sessions.ts
9812
+ const MAX_BROWSER_ATTACHMENTS = 8;
9813
+ const MAX_BROWSER_TICKETS = 256;
9814
+ const LOCAL_BROWSER_OWNER_CONTROLLER = "local-owner";
9815
+ const attachmentController = (clientId) => `attachment:${clientId}`;
9816
+ const MAX_BROWSER_SCHEDULED_OPERATIONS = 64;
9817
+ const MAX_BROWSER_REGULAR_OPERATIONS = 46;
9818
+ const playwrightPackageSchema = z.object({ bin: z.object({ playwright: z.string() }).optional() });
9819
+ const defaultBrowserFactory = (host, workspacePath, _title, _panelId, _worktreeId, callbacks) => new PlaywrightBrowser(host, workspacePath, callbacks);
9820
+ const DEFAULT_STATE = {
9821
+ url: "about:blank",
9822
+ title: "",
9823
+ loading: false,
9824
+ canGoBack: false,
9825
+ canGoForward: false,
9826
+ viewport: {
9827
+ width: 1280,
9828
+ height: 800
9829
+ }
9830
+ };
9831
+ var BrowserSessionManager = class {
9832
+ service;
9833
+ config;
9834
+ browserFactory;
9835
+ agentCliRunner;
9836
+ connectLocalAutomation;
9837
+ cachePath;
9838
+ browserHost;
9839
+ sessions = /* @__PURE__ */ new Map();
9840
+ sessionCreations = /* @__PURE__ */ new Map();
9841
+ tickets = /* @__PURE__ */ new Map();
9842
+ ownerTickets = /* @__PURE__ */ new Map();
9843
+ unsubscribe;
9844
+ installing = null;
9845
+ constructor(service, config, browserFactory = defaultBrowserFactory, agentCliRunner = null, connectLocalAutomation = async (endpoint) => {
9846
+ const { chromium } = await import("playwright");
9847
+ return chromium.connectOverCDP(endpoint, { timeout: 1e4 });
9848
+ }) {
9849
+ this.service = service;
9850
+ this.config = config;
9851
+ this.browserFactory = browserFactory;
9852
+ this.agentCliRunner = agentCliRunner;
9853
+ this.connectLocalAutomation = connectLocalAutomation;
9854
+ this.cachePath = path.join(config.cacheDir, "playwright");
9855
+ this.browserHost = new PlaywrightBrowserHost(this.cachePath, path.join(config.dataDir, "browser-profile"));
9856
+ this.unsubscribe = service.events.subscribe((event) => {
9857
+ if (event.type === "panel.removed") this.closePanel(String(event.data.panelId), "Panel closed");
9858
+ else if (event.type === "worktree.removed" && event.data.worktreeId) for (const session of this.sessions.values()) this.service.authorizeBrowserPanel(session.panelId).catch(() => this.closePanel(session.panelId, "Worktree removed"));
9859
+ });
9860
+ }
9861
+ async issueTicket(panelId, clientId, visible = true) {
9862
+ await this.service.authorizeBrowserPanel(panelId);
9863
+ for (const [value, ticket] of this.tickets) if (ticket.expiresAt < Date.now()) this.tickets.delete(value);
9864
+ if (this.tickets.size >= MAX_BROWSER_TICKETS) throw new Error("Too many Browser attachment requests are pending.");
9865
+ const ticket = crypto.randomBytes(32).toString("base64url");
9866
+ this.tickets.set(ticket, {
9867
+ panelId,
9868
+ clientId,
9869
+ visible,
9870
+ expiresAt: Date.now() + 3e4
9871
+ });
9872
+ return ticket;
9873
+ }
9874
+ async issueOwnerTicket(panelId, clientId) {
9875
+ await this.service.authorizeBrowserPanel(panelId);
9876
+ for (const [value, ticket] of this.ownerTickets) if (ticket.expiresAt < Date.now()) this.ownerTickets.delete(value);
9877
+ if (this.ownerTickets.size >= MAX_BROWSER_TICKETS) throw new Error("Too many Browser owner requests are pending.");
9878
+ const ticket = crypto.randomBytes(32).toString("base64url");
9879
+ const currentOwner = this.sessions.get(panelId)?.localOwner;
9880
+ const challenge = currentOwner?.clientId === clientId ? currentOwner.challenge : crypto.randomBytes(32).toString("base64url");
9881
+ this.ownerTickets.set(ticket, {
9882
+ panelId,
9883
+ clientId,
9884
+ challenge,
9885
+ expiresAt: Date.now() + 3e4
9886
+ });
9887
+ return {
9888
+ ticket,
9889
+ challenge
9890
+ };
9891
+ }
9892
+ stateFor(session, attachment) {
9893
+ return {
9894
+ ...session.state,
9895
+ controlled: session.controllerId === attachmentController(attachment.clientId),
9896
+ hasController: session.localOwner !== null || session.controllerId !== null,
9897
+ controller: session.controllerId === attachmentController(attachment.clientId) ? "you" : session.controllerId === "agent" ? "agent" : session.localOwner || session.controllerId ? "other" : "none"
9898
+ };
9899
+ }
9900
+ broadcastState(session, type = "state") {
9901
+ for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
9902
+ type,
9903
+ state: this.stateFor(session, attachment)
9904
+ });
9905
+ }
9906
+ enqueueOperation(session, operation, allowWhenClosing = false) {
9907
+ const scheduler = session.scheduler;
9908
+ if (!scheduler.accepting && !allowWhenClosing) return false;
9909
+ if (operation.coalesceKey) {
9910
+ const existing = scheduler.coalesced.get(operation.coalesceKey);
9911
+ if (existing) {
9912
+ if (existing.message?.type === "wheel" && operation.message?.type === "wheel") existing.message = {
9913
+ type: "wheel",
9914
+ deltaX: Math.max(-1e4, Math.min(1e4, existing.message.deltaX + operation.message.deltaX)),
9915
+ deltaY: Math.max(-1e4, Math.min(1e4, existing.message.deltaY + operation.message.deltaY))
9916
+ };
9917
+ else existing.message = operation.message;
9918
+ existing.execute = operation.execute;
9919
+ existing.completions.push(...operation.completions);
9920
+ existing.required ||= operation.required;
9921
+ return true;
9922
+ }
9923
+ }
9924
+ if (scheduler.queue.length >= MAX_BROWSER_SCHEDULED_OPERATIONS || !operation.required && scheduler.queue.length >= MAX_BROWSER_REGULAR_OPERATIONS) return false;
9925
+ scheduler.queue.push(operation);
9926
+ if (operation.coalesceKey) scheduler.coalesced.set(operation.coalesceKey, operation);
9927
+ this.runScheduler(session);
9928
+ return true;
9929
+ }
9930
+ scheduleOperation(session, execute, options = {}) {
9931
+ return new Promise((resolve, reject) => {
9932
+ if (!this.enqueueOperation(session, {
9933
+ coalesceKey: options.coalesceKey ?? null,
9934
+ message: null,
9935
+ execute: () => execute(),
9936
+ completions: [{
9937
+ resolve,
9938
+ reject
9939
+ }],
9940
+ required: options.required ?? false
9941
+ }, options.allowWhenClosing)) reject(/* @__PURE__ */ new Error(session.scheduler.accepting ? "The Browser command queue is full." : "The Browser session is closing."));
9942
+ });
9943
+ }
9944
+ queueClientOperation(session, attachment, operation) {
9945
+ if (!this.enqueueOperation(session, {
9946
+ ...operation,
9947
+ completions: []
9948
+ })) attachment.transport.sendMessage({
9949
+ type: "navigationError",
9950
+ message: session.scheduler.accepting ? "The Browser command queue is full. Wait and try again." : "The Browser session is closing."
9951
+ });
9952
+ }
9953
+ runScheduler(session) {
9954
+ const scheduler = session.scheduler;
9955
+ if (scheduler.running) return;
9956
+ scheduler.running = true;
9957
+ (async () => {
9958
+ while (scheduler.queue.length > 0) {
9959
+ const operation = scheduler.queue.shift();
9960
+ if (operation.coalesceKey) scheduler.coalesced.delete(operation.coalesceKey);
9961
+ try {
9962
+ await operation.execute(operation.message);
9963
+ for (const completion of operation.completions) completion.resolve();
9964
+ } catch (cause) {
9965
+ const error = cause instanceof Error ? cause : new Error(String(cause));
9966
+ for (const completion of operation.completions) completion.reject(error);
9967
+ }
9968
+ }
9969
+ scheduler.running = false;
9970
+ })();
9971
+ }
9972
+ stopScheduler(session, reason) {
9973
+ session.scheduler.accepting = false;
9974
+ const error = new Error(reason);
9975
+ for (const operation of session.scheduler.queue.splice(0)) for (const completion of operation.completions) completion.reject(error);
9976
+ session.scheduler.coalesced.clear();
9977
+ }
9978
+ broadcastNavigationError(session, message) {
9979
+ for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
9980
+ type: "navigationError",
9981
+ message
9982
+ });
9983
+ }
9984
+ async openPopup(session, url) {
9985
+ await this.service.openBrowserPanelFromPanel(session.panelId, url).catch((cause) => this.broadcastNavigationError(session, `Could not open the popup: ${cause instanceof Error ? cause.message : String(cause)}`));
9986
+ }
9987
+ queuePanelState(session, value) {
9988
+ if (session.closing || !session.persistence.ready) return;
9989
+ const parsed = value.url === "about:blank" ? {
9990
+ success: true,
9991
+ data: "about:blank"
9992
+ } : browserUrlSchema.safeParse(value.url);
9993
+ if (!parsed.success) return;
9994
+ const url = parsed.data === "about:blank" ? parsed.data : new URL(parsed.data).href;
9995
+ const title = value.title.trim().slice(0, 256) || (url === "about:blank" ? "Browser" : new URL(url).host || "Browser");
9996
+ const persistence = session.persistence;
9997
+ if (persistence.pending?.url === url && persistence.pending.title === title || persistence.persistedUrl === url && persistence.persistedTitle === title) return;
9998
+ persistence.pending = {
9999
+ url,
10000
+ title
10001
+ };
10002
+ if (persistence.write) return;
10003
+ const write = (async () => {
10004
+ while (persistence.pending !== null) {
10005
+ const pending = persistence.pending;
10006
+ persistence.pending = null;
10007
+ try {
10008
+ const panel = await this.service.updateBrowserPanelState(session.panelId, pending);
10009
+ persistence.persistedUrl = panel.url;
10010
+ persistence.persistedTitle = panel.title;
10011
+ } catch (cause) {
10012
+ const message = cause instanceof Error ? cause.message : String(cause);
10013
+ for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
10014
+ type: "navigationError",
10015
+ message: `Could not save the Browser address and title: ${message}`
10016
+ });
10017
+ return;
10018
+ }
10019
+ }
10020
+ })();
10021
+ persistence.write = write;
10022
+ write.finally(() => {
10023
+ if (persistence.write !== write) return;
10024
+ persistence.write = null;
10025
+ const pending = persistence.pending;
10026
+ persistence.pending = null;
10027
+ if (pending !== null && !session.closing) this.queuePanelState(session, pending);
10028
+ });
10029
+ }
10030
+ async waitForPanelState(session) {
10031
+ while (session.persistence.write) await session.persistence.write;
10032
+ }
10033
+ async createSession(panelId) {
10034
+ const authorized = await this.service.authorizeBrowserPanel(panelId);
10035
+ const restoredUrl = authorized.panel.url === "about:blank" ? null : browserUrlSchema.parse(authorized.panel.url);
10036
+ const agentDirectory = path.join(this.config.runtimeDir, "browsers", panelId);
10037
+ await fs.rm(agentDirectory, {
10038
+ recursive: true,
10039
+ force: true
10040
+ });
10041
+ await fs.mkdir(agentDirectory, {
10042
+ recursive: true,
10043
+ mode: 448
10044
+ });
10045
+ await fs.chmod(agentDirectory, 448);
10046
+ const session = {
10047
+ panelId,
10048
+ agentDirectory,
10049
+ title: `Treeport ${authorized.panel.title}`,
10050
+ browser: null,
10051
+ launch: null,
10052
+ localOwner: null,
10053
+ localAutomation: null,
10054
+ localAutomationLaunch: null,
10055
+ generation: 0,
10056
+ attachments: /* @__PURE__ */ new Map(),
10057
+ controllerId: null,
10058
+ state: {
10059
+ ...DEFAULT_STATE,
10060
+ url: restoredUrl ?? DEFAULT_STATE.url,
10061
+ title: authorized.panel.title === "Browser" ? "" : authorized.panel.title,
10062
+ viewport: { ...DEFAULT_STATE.viewport }
10063
+ },
10064
+ sequence: 0,
10065
+ latestFrame: null,
10066
+ scheduler: {
10067
+ queue: [],
10068
+ coalesced: /* @__PURE__ */ new Map(),
10069
+ running: false,
10070
+ accepting: true
10071
+ },
10072
+ persistence: {
10073
+ persistedUrl: authorized.panel.url,
10074
+ persistedTitle: authorized.panel.title,
10075
+ pending: null,
10076
+ write: null,
10077
+ ready: true
10078
+ },
10079
+ agentAttached: false,
10080
+ agentSessionName: null,
10081
+ agentProcess: null,
10082
+ crashMessage: null,
10083
+ closing: false,
10084
+ closeOperation: null
10085
+ };
10086
+ this.sessions.set(panelId, session);
10087
+ return session;
10088
+ }
10089
+ async ensurePlaywrightRuntime(session) {
10090
+ if (session.localOwner) throw new Error("This Browser is open in a local Treeport desktop app.");
10091
+ if (session.launch) return session.launch;
10092
+ if ([...this.sessions.values()].filter((candidate) => candidate.localOwner || candidate.launch).length >= 6) throw new Error("Treeport supports at most six active Browser sessions. Close one and try again.");
10093
+ const runtimeGeneration = ++session.generation;
10094
+ const restoredUrl = session.state.url;
10095
+ const restoredTitle = session.state.title;
10096
+ const launch = (async () => {
10097
+ await fs.rm(session.agentDirectory, {
10098
+ recursive: true,
10099
+ force: true
10100
+ });
10101
+ await fs.mkdir(session.agentDirectory, {
10102
+ recursive: true,
10103
+ mode: 448
10104
+ });
10105
+ await fs.chmod(session.agentDirectory, 448);
10106
+ let runtimeReady = false;
10107
+ const browser = this.browserFactory(this.browserHost, session.agentDirectory, session.title, session.panelId, (await this.service.authorizeBrowserPanel(session.panelId)).panel.worktreeId, {
10108
+ state: (state) => {
10109
+ if (!runtimeReady || session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) return;
10110
+ session.state = state;
10111
+ this.queuePanelState(session, state);
10112
+ this.broadcastState(session);
10113
+ },
10114
+ frame: (frame) => {
10115
+ if (runtimeReady && session.browser === browser && session.generation === runtimeGeneration && !session.localOwner) this.publishFrame(session, frame);
10116
+ },
10117
+ popup: (url) => {
10118
+ if (!runtimeReady || session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) return;
10119
+ this.openPopup(session, url);
10120
+ },
10121
+ navigationError: (message) => this.broadcastNavigationError(session, message),
10122
+ crashed: (message) => {
10123
+ if (!runtimeReady || session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) return;
10124
+ session.crashMessage = message;
10125
+ for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
10126
+ type: "browserCrashed",
10127
+ message
10128
+ });
10129
+ }
10130
+ });
10131
+ session.browser = browser;
10132
+ await browser.launch();
10133
+ if (session.browser !== browser || session.generation !== runtimeGeneration || session.localOwner) {
10134
+ await browser.close();
10135
+ throw new Error("The Browser runtime changed during launch.");
10136
+ }
10137
+ session.state = browser.state;
10138
+ if (restoredUrl !== "about:blank") session.state = await browser.command({
10139
+ type: "navigate",
10140
+ url: restoredUrl
10141
+ }).then(() => true, () => false) ? browser.state : {
10142
+ ...browser.state,
10143
+ url: restoredUrl,
10144
+ title: restoredTitle
10145
+ };
10146
+ runtimeReady = true;
10147
+ session.state = browser.state;
10148
+ this.queuePanelState(session, session.state);
10149
+ this.broadcastState(session);
10150
+ return browser;
10151
+ })();
10152
+ session.launch = launch;
10153
+ launch.catch(() => {
10154
+ if (session.launch === launch) {
10155
+ session.launch = null;
10156
+ session.browser = null;
10157
+ }
10158
+ });
10159
+ return launch;
10160
+ }
10161
+ browserFor(session) {
10162
+ return this.ensurePlaywrightRuntime(session);
10163
+ }
10164
+ async getSession(panelId) {
10165
+ const existing = this.sessions.get(panelId);
10166
+ if (existing) {
10167
+ await this.service.authorizeBrowserPanel(panelId);
10168
+ return existing;
10169
+ }
10170
+ const pending = this.sessionCreations.get(panelId);
10171
+ if (pending) return pending;
10172
+ const creation = this.createSession(panelId).finally(() => {
10173
+ if (this.sessionCreations.get(panelId) === creation) this.sessionCreations.delete(panelId);
10174
+ });
10175
+ this.sessionCreations.set(panelId, creation);
10176
+ return creation;
10177
+ }
10178
+ async accept(ticketValue, transport) {
10179
+ const ticket = this.tickets.get(ticketValue);
10180
+ this.tickets.delete(ticketValue);
10181
+ if (!ticket || ticket.expiresAt < Date.now()) throw new Error("INVALID_BROWSER_TICKET");
10182
+ const session = await this.getSession(ticket.panelId);
10183
+ const supersededAttachments = [...session.attachments.values()].filter((candidate) => candidate.clientId === ticket.clientId);
10184
+ if (session.attachments.size - supersededAttachments.length >= MAX_BROWSER_ATTACHMENTS) {
10185
+ transport.sendMessage({
10186
+ type: "browserUnavailable",
10187
+ message: "This Browser already has the maximum of eight attached clients.",
10188
+ installCommand: null
10189
+ });
10190
+ return transport.id;
10191
+ }
10192
+ const attachment = {
10193
+ id: transport.id,
10194
+ clientId: ticket.clientId,
10195
+ transport,
10196
+ visible: ticket.visible,
10197
+ closing: false,
10198
+ awaitingFrame: null,
10199
+ pendingFrame: null,
10200
+ viewport: { ...session.state.viewport }
10201
+ };
10202
+ session.attachments.set(attachment.id, attachment);
10203
+ try {
10204
+ if (!session.localOwner) await this.browserFor(session);
10205
+ if (!transport.isConnected()) {
10206
+ this.close(attachment.id);
10207
+ return attachment.id;
10208
+ }
10209
+ await this.scheduleOperation(session, async () => {
10210
+ if (attachment.closing) return;
10211
+ for (const superseded of supersededAttachments) {
10212
+ if (session.attachments.get(superseded.id) !== superseded) continue;
10213
+ superseded.closing = true;
10214
+ session.attachments.delete(superseded.id);
10215
+ superseded.transport.disconnect();
10216
+ }
10217
+ if (attachment.visible && session.controllerId === null) session.controllerId = attachmentController(attachment.clientId);
10218
+ await this.updateScreencast(session);
10219
+ }, { required: true });
10220
+ transport.sendMessage({
10221
+ type: "ready",
10222
+ state: this.stateFor(session, attachment)
10223
+ });
10224
+ this.broadcastState(session, "controlChanged");
10225
+ if (session.crashMessage) transport.sendMessage({
10226
+ type: "browserCrashed",
10227
+ message: session.crashMessage
10228
+ });
10229
+ this.sendLatestFrame(session, attachment);
10230
+ } catch (error) {
10231
+ attachment.closing = true;
10232
+ session.attachments.delete(attachment.id);
10233
+ if (session.controllerId === attachmentController(attachment.clientId) && ![...session.attachments.values()].some((candidate) => candidate.clientId === attachment.clientId)) session.controllerId = session.localOwner ? LOCAL_BROWSER_OWNER_CONTROLLER : null;
10234
+ transport.sendMessage({
10235
+ type: "browserUnavailable",
10236
+ message: error instanceof Error ? error.message : String(error),
10237
+ installCommand: session.localOwner ? null : "treeport browser install"
10238
+ });
10239
+ if (!session.localOwner) await this.closePlaywrightRuntime(session).catch(() => void 0);
10240
+ }
10241
+ return attachment.id;
10242
+ }
10243
+ async acceptOwner(auth, transport) {
10244
+ const ticket = this.ownerTickets.get(auth.ticket);
10245
+ this.ownerTickets.delete(auth.ticket);
10246
+ if (!ticket || ticket.expiresAt < Date.now() || ticket.challenge !== auth.challenge || !browserOwnerEndpointSchema.safeParse(auth.endpoint).success) throw new Error("INVALID_BROWSER_OWNER_TICKET");
10247
+ const identityResponse = await fetch(new URL("identity", auth.endpoint), {
10248
+ signal: AbortSignal.timeout(3e3),
10249
+ redirect: "error"
10250
+ });
10251
+ const identity = z.strictObject({
10252
+ panelId: z.string().min(1).max(128),
10253
+ challenge: z.string().min(32).max(256)
10254
+ }).parse(await identityResponse.json());
10255
+ if (!identityResponse.ok || identity.panelId !== ticket.panelId || identity.challenge !== ticket.challenge) throw new Error("INVALID_BROWSER_OWNER_IDENTITY");
10256
+ const session = await this.getSession(ticket.panelId);
10257
+ await this.scheduleOperation(session, async () => {
10258
+ const previousOwner = session.localOwner;
10259
+ if (previousOwner?.transport.isConnected()) {
10260
+ transport.send({
10261
+ type: "claimRejected",
10262
+ message: "This Browser is open in another local desktop app."
10263
+ });
10264
+ transport.disconnect();
10265
+ return;
10266
+ }
10267
+ if ([...this.sessions.values()].filter((candidate) => candidate !== session && (candidate.localOwner !== null || candidate.launch !== null)).length >= 6) {
10268
+ transport.send({
10269
+ type: "claimRejected",
10270
+ message: "Treeport supports at most six active Browser sessions. Close one and try again."
10271
+ });
10272
+ transport.disconnect();
10273
+ return;
10274
+ }
10275
+ let resolveReady = null;
10276
+ const readyPromise = new Promise((resolve) => {
10277
+ resolveReady = resolve;
10278
+ });
10279
+ const resumed = previousOwner?.clientId === ticket.clientId;
10280
+ let owner;
10281
+ if (previousOwner) {
10282
+ previousOwner.resolveReady?.();
10283
+ for (const request of previousOwner.requests.values()) {
10284
+ clearTimeout(request.timer);
10285
+ request.resolve(false);
10286
+ }
10287
+ previousOwner.requests.clear();
10288
+ if (previousOwner.endpoint !== auth.endpoint) {
10289
+ await this.closeLocalAutomation(session);
10290
+ session.latestFrame = null;
10291
+ session.sequence = 0;
10292
+ }
10293
+ previousOwner.transport = transport;
10294
+ previousOwner.clientId = ticket.clientId;
10295
+ previousOwner.endpoint = auth.endpoint;
10296
+ previousOwner.challenge = auth.challenge;
10297
+ previousOwner.generation = resumed ? previousOwner.generation : ++session.generation;
10298
+ previousOwner.revision = -1;
10299
+ previousOwner.ready = false;
10300
+ previousOwner.controller = "none";
10301
+ previousOwner.retainPaint = false;
10302
+ previousOwner.readyPromise = readyPromise;
10303
+ previousOwner.resolveReady = resolveReady;
10304
+ owner = previousOwner;
10305
+ } else {
10306
+ await this.closePlaywrightRuntime(session);
10307
+ owner = {
10308
+ transport,
10309
+ clientId: ticket.clientId,
10310
+ endpoint: auth.endpoint,
10311
+ challenge: auth.challenge,
10312
+ generation: ++session.generation,
10313
+ revision: -1,
10314
+ ready: false,
10315
+ controller: "none",
10316
+ retainPaint: false,
10317
+ readyPromise,
10318
+ resolveReady,
10319
+ requests: /* @__PURE__ */ new Map()
10320
+ };
10321
+ session.localOwner = owner;
10322
+ session.controllerId = LOCAL_BROWSER_OWNER_CONTROLLER;
10323
+ session.latestFrame = null;
10324
+ session.sequence = 0;
10325
+ }
10326
+ if (!transport.isConnected()) return;
10327
+ session.controllerId ??= LOCAL_BROWSER_OWNER_CONTROLLER;
10328
+ transport.send({
10329
+ type: "claimGranted",
10330
+ panelId: session.panelId,
10331
+ generation: owner.generation,
10332
+ resumed,
10333
+ state: session.state
10334
+ });
10335
+ if (!previousOwner) this.broadcastState(session, "controlChanged");
10336
+ }, { required: true });
10337
+ return transport.id;
10338
+ }
10339
+ ownerMessage(connectionId, value) {
10340
+ const session = [...this.sessions.values()].find((candidate) => candidate.localOwner?.transport.id === connectionId);
10341
+ const message = parseBrowserOwnerClientMessage(value);
10342
+ const owner = session?.localOwner;
10343
+ if (!session || !owner || !message) {
10344
+ owner?.transport.disconnect();
10345
+ return;
10346
+ }
10347
+ if (message.generation !== owner.generation) return;
10348
+ if (message.type === "ready" || message.type === "state") {
10349
+ if (message.revision <= owner.revision) return;
10350
+ owner.revision = message.revision;
10351
+ session.state = message.state;
10352
+ this.queuePanelState(session, message.state);
10353
+ this.broadcastState(session);
10354
+ if (message.type === "ready" && !owner.ready) {
10355
+ owner.ready = true;
10356
+ owner.resolveReady?.();
10357
+ owner.resolveReady = null;
10358
+ this.scheduleOperation(session, () => this.updateScreencast(session), { required: true }).catch((cause) => this.broadcastNavigationError(session, cause instanceof Error ? cause.message : String(cause)));
10359
+ }
10360
+ return;
10361
+ }
10362
+ if (message.type === "takeControl") {
10363
+ this.scheduleOperation(session, async () => {
10364
+ if (session.localOwner !== owner) return;
10365
+ await this.updateScreencast(session, LOCAL_BROWSER_OWNER_CONTROLLER);
10366
+ if (session.localOwner !== owner) throw new Error("The local Browser owner changed.");
10367
+ session.controllerId = LOCAL_BROWSER_OWNER_CONTROLLER;
10368
+ this.broadcastState(session, "controlChanged");
10369
+ }, { required: true }).catch(() => void 0);
10370
+ return;
10371
+ }
10372
+ if (message.type === "released") {
10373
+ this.scheduleOperation(session, () => this.releaseLocalOwner(session, owner), { required: true }).catch((cause) => this.broadcastNavigationError(session, cause instanceof Error ? cause.message : String(cause)));
10374
+ return;
10375
+ }
10376
+ if (message.type === "popup") {
10377
+ this.openPopup(session, message.url);
10378
+ return;
10379
+ }
10380
+ if (message.type === "crashed") {
10381
+ session.crashMessage = message.message;
10382
+ for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
10383
+ type: "browserCrashed",
10384
+ message: message.message
10385
+ });
10386
+ return;
10387
+ }
10388
+ const request = owner.requests.get(message.requestId);
10389
+ if (!request) return;
10390
+ owner.requests.delete(message.requestId);
10391
+ clearTimeout(request.timer);
10392
+ request.resolve(message.type === "runtimeControlResult" ? message.accepted : message.canClose);
10393
+ }
10394
+ closeOwner(connectionId) {
10395
+ const session = [...this.sessions.values()].find((candidate) => candidate.localOwner?.transport.id === connectionId);
10396
+ const owner = session?.localOwner;
10397
+ if (!session || !owner) return;
10398
+ owner.ready = false;
10399
+ owner.resolveReady?.();
10400
+ owner.resolveReady = null;
10401
+ for (const request of owner.requests.values()) {
10402
+ clearTimeout(request.timer);
10403
+ request.resolve(false);
10404
+ }
10405
+ owner.requests.clear();
10406
+ }
10407
+ async releaseLocalOwner(session, owner) {
10408
+ if (session.localOwner !== owner) return;
10409
+ session.localOwner = null;
10410
+ owner.ready = false;
10411
+ owner.resolveReady?.();
10412
+ owner.resolveReady = null;
10413
+ for (const request of owner.requests.values()) {
10414
+ clearTimeout(request.timer);
10415
+ request.resolve(false);
10416
+ }
10417
+ owner.requests.clear();
10418
+ await this.closeLocalAutomation(session);
10419
+ session.generation += 1;
10420
+ session.latestFrame = null;
10421
+ session.sequence = 0;
10422
+ if (session.controllerId === LOCAL_BROWSER_OWNER_CONTROLLER || session.controllerId === "agent") {
10423
+ const attachment = [...session.attachments.values()].find((candidate) => !candidate.closing && candidate.visible && candidate.transport.isConnected());
10424
+ session.controllerId = attachment ? attachmentController(attachment.clientId) : null;
10425
+ }
10426
+ session.agentProcess?.kill("SIGTERM");
10427
+ session.agentProcess = null;
10428
+ await this.detachAgent(session);
10429
+ this.broadcastState(session, "controlChanged");
10430
+ if (![...session.attachments.values()].some((attachment) => !attachment.closing && attachment.visible && attachment.transport.isConnected())) return;
10431
+ const runtimeError = await this.browserFor(session).then(() => this.updateScreencast(session)).then(() => null, (cause) => cause);
10432
+ if (runtimeError !== null) {
10433
+ for (const attachment of session.attachments.values()) attachment.transport.sendMessage({
10434
+ type: "browserUnavailable",
10435
+ message: runtimeError instanceof Error ? runtimeError.message : String(runtimeError),
10436
+ installCommand: "treeport browser install"
10437
+ });
10438
+ await this.closePlaywrightRuntime(session).catch(() => void 0);
10439
+ }
10440
+ }
10441
+ requestLocalOwner(owner, message) {
10442
+ if (!owner.transport.isConnected()) return Promise.resolve(false);
10443
+ const requestId = crypto.randomUUID();
10444
+ return new Promise((resolve) => {
10445
+ const timer = setTimeout(() => {
10446
+ owner.requests.delete(requestId);
10447
+ resolve(false);
10448
+ }, 5e3);
10449
+ timer.unref();
10450
+ owner.requests.set(requestId, {
10451
+ resolve,
10452
+ timer
10453
+ });
10454
+ if (!owner.transport.send({
10455
+ ...message,
10456
+ generation: owner.generation,
10457
+ requestId
10458
+ })) {
10459
+ clearTimeout(timer);
10460
+ owner.requests.delete(requestId);
10461
+ resolve(false);
10462
+ }
10463
+ });
10464
+ }
10465
+ async setLocalOwnerRuntimeControl(session, owner, controller, retainPaint) {
10466
+ if (owner.controller === controller && owner.retainPaint === retainPaint) return;
10467
+ if (!await this.requestLocalOwner(owner, {
10468
+ type: "runtimeControl",
10469
+ controller,
10470
+ retainPaint
10471
+ }) || session.localOwner !== owner || session.generation !== owner.generation) throw new Error("The local Browser owner did not accept control.");
10472
+ owner.controller = controller;
10473
+ owner.retainPaint = retainPaint;
10474
+ }
10475
+ sendLatestFrame(session, attachment) {
10476
+ const frame = session.latestFrame;
10477
+ if (!frame || !attachment.visible || attachment.awaitingFrame !== null || !attachment.transport.isConnected()) return;
10478
+ if (attachment.transport.sendFrame(frame)) attachment.awaitingFrame = frame.sequence;
10479
+ }
10480
+ publishFrame(session, value) {
10481
+ if (value.data.byteLength > 8388608) return;
10482
+ const frame = {
10483
+ ...value,
10484
+ sequence: ++session.sequence
10485
+ };
10486
+ session.latestFrame = frame;
10487
+ for (const attachment of session.attachments.values()) {
10488
+ if (!attachment.visible || !attachment.transport.isConnected()) continue;
10489
+ if (attachment.awaitingFrame !== null) {
10490
+ attachment.pendingFrame = frame;
10491
+ continue;
10492
+ }
10493
+ if (attachment.transport.sendFrame(frame)) attachment.awaitingFrame = frame.sequence;
10494
+ }
10495
+ }
10496
+ message(connectionId, value) {
10497
+ const entry = [...this.sessions.values()].map((session) => ({
10498
+ session,
10499
+ attachment: session.attachments.get(connectionId)
10500
+ })).find((candidate) => candidate.attachment !== void 0);
10501
+ if (!entry) return;
10502
+ const message = parseBrowserClientMessage(value);
10503
+ if (!message) {
10504
+ entry.attachment.transport.disconnect();
10505
+ return;
10506
+ }
10507
+ const { session, attachment } = entry;
10508
+ if (attachment.closing) return;
10509
+ if (message.type === "frameAck") {
10510
+ if (attachment.awaitingFrame !== message.sequence) return;
10511
+ attachment.awaitingFrame = null;
10512
+ const pending = attachment.pendingFrame;
10513
+ attachment.pendingFrame = null;
10514
+ if (pending && attachment.transport.sendFrame(pending)) attachment.awaitingFrame = pending.sequence;
10515
+ return;
10516
+ }
10517
+ if (message.type === "setVisible") {
10518
+ attachment.visible = message.visible;
10519
+ if (message.visible) this.sendLatestFrame(session, attachment);
10520
+ else {
10521
+ attachment.awaitingFrame = null;
10522
+ attachment.pendingFrame = null;
10523
+ }
10524
+ this.queueClientOperation(session, attachment, {
10525
+ coalesceKey: `screencast:${session.panelId}`,
10526
+ message: null,
10527
+ execute: async () => {
10528
+ const previousController = session.controllerId;
10529
+ let nextController = previousController;
10530
+ if (attachment.visible && previousController === null) nextController = attachmentController(attachment.clientId);
10531
+ else if (!attachment.visible && previousController === attachmentController(attachment.clientId)) {
10532
+ const nextAttachment = [...session.attachments.values()].find((candidate) => candidate.id !== attachment.id && !candidate.closing && candidate.visible);
10533
+ nextController = nextAttachment ? attachmentController(nextAttachment.clientId) : session.localOwner ? LOCAL_BROWSER_OWNER_CONTROLLER : null;
10534
+ }
10535
+ await this.updateScreencast(session, nextController);
10536
+ session.controllerId = nextController;
10537
+ if (nextController !== previousController) this.broadcastState(session, "controlChanged");
10538
+ },
10539
+ required: true
10540
+ });
10541
+ return;
10542
+ }
10543
+ if (message.type === "resize") {
10544
+ attachment.viewport = {
10545
+ width: message.width,
10546
+ height: message.height
10547
+ };
10548
+ if (session.localOwner) return;
10549
+ this.queueClientOperation(session, attachment, {
10550
+ coalesceKey: `resize:${attachment.id}`,
10551
+ message,
10552
+ execute: async (queuedMessage) => {
10553
+ if (queuedMessage?.type !== "resize" || attachment.closing || session.attachments.get(attachment.id) !== attachment || session.controllerId !== attachmentController(attachment.clientId)) return;
10554
+ try {
10555
+ await (await this.browserFor(session)).command(queuedMessage);
10556
+ } catch (cause) {
10557
+ attachment.transport.sendMessage({
10558
+ type: "navigationError",
10559
+ message: cause instanceof Error ? cause.message : String(cause)
10560
+ });
10561
+ }
10562
+ },
10563
+ required: false
10564
+ });
10565
+ return;
10566
+ }
10567
+ if (message.type === "takeControl") {
10568
+ this.queueClientOperation(session, attachment, {
10569
+ coalesceKey: null,
10570
+ message: null,
10571
+ execute: async () => {
10572
+ if (attachment.closing || session.attachments.get(attachment.id) !== attachment) return;
10573
+ const previousController = session.controllerId;
10574
+ const nextController = attachmentController(attachment.clientId);
10575
+ try {
10576
+ if (session.localOwner) await this.updateScreencast(session, nextController);
10577
+ else await (await this.browserFor(session)).command({
10578
+ type: "resize",
10579
+ ...attachment.viewport
10580
+ });
10581
+ session.controllerId = nextController;
10582
+ if (previousController !== nextController) this.broadcastState(session, "controlChanged");
10583
+ } catch (cause) {
10584
+ attachment.transport.sendMessage({
10585
+ type: "navigationError",
10586
+ message: cause instanceof Error ? cause.message : String(cause)
10587
+ });
10588
+ }
10589
+ },
10590
+ required: false
10591
+ });
10592
+ return;
10593
+ }
10594
+ const coalesceKey = message.type === "pointer" && message.phase === "move" ? `pointer-move:${attachment.id}` : message.type === "wheel" ? `wheel:${attachment.id}` : null;
10595
+ this.queueClientOperation(session, attachment, {
10596
+ coalesceKey,
10597
+ message,
10598
+ execute: async (queuedMessage) => {
10599
+ if (!queuedMessage || attachment.closing || session.attachments.get(attachment.id) !== attachment) return;
10600
+ if (session.controllerId !== attachmentController(attachment.clientId)) {
10601
+ attachment.transport.sendMessage({
10602
+ type: "navigationError",
10603
+ message: "Take control before you interact with this browser."
10604
+ });
10605
+ return;
10606
+ }
10607
+ try {
10608
+ const localOwner = session.localOwner;
10609
+ if (localOwner) await this.executeLocalClientCommand(session, localOwner, queuedMessage);
10610
+ else {
10611
+ const browser = await this.browserFor(session);
10612
+ await browser.command(queuedMessage);
10613
+ this.queuePanelState(session, browser.state);
10614
+ await this.waitForPanelState(session);
10615
+ }
10616
+ } catch (cause) {
10617
+ attachment.transport.sendMessage({
10618
+ type: "navigationError",
10619
+ message: cause instanceof Error ? cause.message : String(cause)
10620
+ });
10621
+ }
10622
+ },
10623
+ required: false
10624
+ });
10625
+ }
10626
+ async updateScreencast(session, controllerId = session.controllerId) {
10627
+ const visible = [...session.attachments.values()].some((attachment) => !attachment.closing && attachment.visible && attachment.transport.isConnected());
10628
+ const localOwner = session.localOwner;
10629
+ if (localOwner) {
10630
+ const controller = controllerId === "agent" ? "agent" : controllerId && controllerId !== LOCAL_BROWSER_OWNER_CONTROLLER ? "other" : "none";
10631
+ const retainPaint = visible || controller === "agent";
10632
+ if (visible) {
10633
+ await this.setLocalOwnerRuntimeControl(session, localOwner, controller, retainPaint);
10634
+ await this.setLocalScreencasting(session, localOwner, true);
10635
+ } else {
10636
+ await this.setLocalScreencasting(session, localOwner, false);
10637
+ await this.setLocalOwnerRuntimeControl(session, localOwner, controller, retainPaint);
10638
+ }
10639
+ return;
10640
+ }
10641
+ if (!session.launch) return;
10642
+ await (await session.launch.catch(() => null))?.setScreencasting(visible).catch(() => void 0);
10643
+ }
10644
+ async detachAgent(session) {
10645
+ const name = session.agentSessionName;
10646
+ if (!session.agentAttached || !name) return;
10647
+ session.agentAttached = false;
10648
+ session.agentSessionName = null;
10649
+ await this.executeAgentCli(session, [`-s=${name}`, "detach"]).catch(() => void 0);
10650
+ }
10651
+ async closePlaywrightRuntime(session) {
10652
+ await this.waitForPanelState(session);
10653
+ await this.detachAgent(session);
10654
+ const launch = session.launch;
10655
+ await (await launch?.catch(() => session.browser))?.close();
10656
+ if (session.launch === launch) {
10657
+ session.launch = null;
10658
+ session.browser = null;
10659
+ }
10660
+ session.latestFrame = null;
10661
+ session.sequence = 0;
10662
+ await fs.rm(session.agentDirectory, {
10663
+ recursive: true,
10664
+ force: true
10665
+ });
10666
+ await fs.mkdir(session.agentDirectory, {
10667
+ recursive: true,
10668
+ mode: 448
10669
+ });
10670
+ await fs.chmod(session.agentDirectory, 448);
10671
+ }
10672
+ async destroySession(session, reason) {
10673
+ await this.waitForPanelState(session);
10674
+ if (session.localOwner) {
10675
+ const owner = session.localOwner;
10676
+ session.localOwner = null;
10677
+ owner.resolveReady?.();
10678
+ owner.resolveReady = null;
10679
+ await this.closeLocalAutomation(session);
10680
+ for (const request of owner.requests.values()) {
10681
+ clearTimeout(request.timer);
10682
+ request.resolve(false);
10683
+ }
10684
+ owner.requests.clear();
10685
+ owner.transport.send({
10686
+ type: "closed",
10687
+ reason
10688
+ });
10689
+ owner.transport.disconnect();
10690
+ }
10691
+ if (session.launch || session.browser) await this.closePlaywrightRuntime(session);
10692
+ else await this.detachAgent(session);
10693
+ await fs.rm(session.agentDirectory, {
10694
+ recursive: true,
10695
+ force: true
10696
+ });
10697
+ if (this.sessions.get(session.panelId) === session) this.sessions.delete(session.panelId);
10698
+ for (const attachment of session.attachments.values()) {
10699
+ attachment.closing = true;
10700
+ attachment.transport.sendMessage({
10701
+ type: "closed",
10702
+ reason
10703
+ });
10704
+ attachment.transport.disconnect();
10705
+ }
10706
+ }
10707
+ close(connectionId) {
10708
+ for (const session of this.sessions.values()) {
10709
+ const attachment = session.attachments.get(connectionId);
10710
+ if (!attachment || attachment.closing) continue;
10711
+ attachment.closing = true;
10712
+ this.scheduleOperation(session, async () => {
10713
+ session.attachments.delete(connectionId);
10714
+ await this.updateScreencast(session);
10715
+ }, { required: true }).catch(() => void 0);
10716
+ return;
10717
+ }
10718
+ }
10719
+ async requestPanelClose(panelId, force = false) {
10720
+ const session = this.sessions.get(panelId) ?? await this.sessionCreations.get(panelId)?.catch(() => null);
10721
+ if (!session) return true;
10722
+ let canClose = false;
10723
+ await this.scheduleOperation(session, async () => {
10724
+ if (session.localOwner) canClose = await this.requestLocalOwner(session.localOwner, {
10725
+ type: "closeRequest",
10726
+ force
10727
+ });
10728
+ else if (session.launch || session.browser) canClose = await (await (session.launch ?? Promise.resolve(session.browser))).requestClose(force);
10729
+ else canClose = true;
10730
+ if (canClose) {
10731
+ session.closing = true;
10732
+ this.stopScheduler(session, "Browser closed.");
10733
+ }
10734
+ }, { required: true });
10735
+ return canClose;
10736
+ }
10737
+ async closePanel(panelId, reason) {
10738
+ const session = this.sessions.get(panelId) ?? await this.sessionCreations.get(panelId)?.catch(() => null);
10739
+ if (!session) return;
10740
+ if (session.closeOperation) return session.closeOperation;
10741
+ session.closing = true;
10742
+ this.stopScheduler(session, reason);
10743
+ session.agentProcess?.kill("SIGTERM");
10744
+ const closeOperation = this.scheduleOperation(session, () => this.destroySession(session, reason), {
10745
+ required: true,
10746
+ allowWhenClosing: true
10747
+ });
10748
+ session.closeOperation = closeOperation;
10749
+ return closeOperation;
10750
+ }
10751
+ async playwrightCliPath() {
10752
+ const packageJsonPath = createRequire(import.meta.url).resolve("@playwright/cli/package.json");
10753
+ return path.join(path.dirname(packageJsonPath), "playwright-cli.js");
10754
+ }
10755
+ executeAgentCli(session, args) {
10756
+ if (this.agentCliRunner) return this.agentCliRunner({
10757
+ panelId: session.panelId,
10758
+ agentDirectory: session.agentDirectory
10759
+ }, args);
10760
+ return this.runAgentCli(session, args);
10761
+ }
10762
+ async runAgentCli(session, args) {
10763
+ const cli = await this.playwrightCliPath();
10764
+ return new Promise((resolve, reject) => {
10765
+ const child = spawn(process.execPath, [cli, ...args], {
10766
+ cwd: session.agentDirectory,
10767
+ env: process.env,
10768
+ stdio: [
10769
+ "ignore",
10770
+ "pipe",
10771
+ "pipe"
10772
+ ],
10773
+ timeout: 12e4,
10774
+ killSignal: "SIGTERM"
10775
+ });
10776
+ session.agentProcess = child;
10777
+ let output = "";
10778
+ const append = (data) => {
10779
+ if (output.length < 10 * 1024 * 1024) output += String(data);
10780
+ };
10781
+ child.stdout.on("data", append);
10782
+ child.stderr.on("data", append);
10783
+ child.once("error", (error) => {
10784
+ if (session.agentProcess === child) session.agentProcess = null;
10785
+ reject(error);
10786
+ });
10787
+ child.once("exit", (code) => {
10788
+ if (session.agentProcess === child) session.agentProcess = null;
10789
+ if (code === 0) resolve(output.trim());
10790
+ else reject(new Error(output.trim() || `Playwright Agent CLI exited with ${code}`));
10791
+ });
10792
+ });
10793
+ }
10794
+ ensureLocalAutomation(session, owner) {
10795
+ const current = session.localAutomation;
10796
+ if (current?.generation === owner.generation && current.browser.isConnected()) return Promise.resolve(current);
10797
+ if (session.localAutomationLaunch) return session.localAutomationLaunch;
10798
+ if (current) {
10799
+ session.localAutomation = null;
10800
+ current.frameProducer.stop();
10801
+ }
10802
+ const launch = (async () => {
10803
+ if (current) {
10804
+ await current.cdp.send("Page.stopScreencast").catch(() => void 0);
10805
+ await current.cdp.detach().catch(() => void 0);
10806
+ await current.browser.close().catch(() => void 0);
10807
+ }
10808
+ if (!owner.ready) {
10809
+ let readinessTimer = null;
10810
+ await Promise.race([owner.readyPromise, new Promise((_resolve, reject) => {
10811
+ readinessTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("The visible local Browser did not become ready within 15 seconds.")), 15e3);
10812
+ readinessTimer.unref();
10813
+ })]).finally(() => {
10814
+ if (readinessTimer) clearTimeout(readinessTimer);
10815
+ });
10816
+ }
10817
+ if (!owner.ready || session.localOwner !== owner || session.generation !== owner.generation) throw new Error("The local Browser owner changed before it was ready.");
10818
+ const browser = await this.connectLocalAutomation(owner.endpoint);
10819
+ const context = browser.contexts()[0];
10820
+ const page = context?.pages()[0];
10821
+ if (!context || !page) {
10822
+ await browser.close().catch(() => void 0);
10823
+ throw new Error("The local Browser page is not available.");
10824
+ }
10825
+ if (session.localOwner !== owner || session.generation !== owner.generation) {
10826
+ await browser.close().catch(() => void 0);
10827
+ throw new Error("The local Browser owner changed.");
10828
+ }
10829
+ const cdp = await context.newCDPSession(page);
10830
+ const frameProducer = new LatestBrowserFrameProducer((frame) => {
10831
+ if (session.localOwner === owner && session.localAutomation?.generation === owner.generation) this.publishFrame(session, frame);
10832
+ }, (sessionId) => {
10833
+ cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => void 0);
10834
+ });
10835
+ const automation = {
10836
+ browser,
10837
+ page,
10838
+ cdp,
10839
+ frameProducer,
10840
+ generation: owner.generation,
10841
+ console: [],
10842
+ requests: [],
10843
+ screencasting: false,
10844
+ screencastTail: Promise.resolve()
10845
+ };
10846
+ cdp.on("Page.screencastFrame", (frame) => frameProducer.receive(frame));
10847
+ browser.once("disconnected", () => {
10848
+ if (session.localAutomation !== automation) return;
10849
+ automation.frameProducer.stop();
10850
+ automation.screencasting = false;
10851
+ session.localAutomation = null;
10852
+ this.scheduleOperation(session, () => this.updateScreencast(session), { required: true }).catch((cause) => this.broadcastNavigationError(session, cause instanceof Error ? cause.message : String(cause)));
10853
+ });
10854
+ await cdp.send("Page.enable");
10855
+ page.on("console", (message) => {
10856
+ automation.console.push(`${message.type()}: ${message.text()}`);
10857
+ if (automation.console.length > 1e3) automation.console.shift();
10858
+ });
10859
+ page.on("pageerror", (error) => {
10860
+ automation.console.push(`error: ${error.message}`);
10861
+ if (automation.console.length > 1e3) automation.console.shift();
10862
+ });
10863
+ page.on("request", (request) => {
10864
+ automation.requests.push(`${request.method()} ${request.url()}`);
10865
+ if (automation.requests.length > 2e3) automation.requests.shift();
10866
+ });
10867
+ session.localAutomation = automation;
10868
+ return automation;
10869
+ })();
10870
+ session.localAutomationLaunch = launch;
10871
+ const clearLaunch = () => {
10872
+ if (session.localAutomationLaunch === launch) session.localAutomationLaunch = null;
10873
+ };
10874
+ launch.then(clearLaunch, clearLaunch);
10875
+ return launch;
10876
+ }
10877
+ async closeLocalAutomation(session) {
10878
+ const launch = session.localAutomationLaunch;
10879
+ const automation = session.localAutomation ?? await launch?.catch(() => null);
10880
+ session.localAutomation = null;
10881
+ session.localAutomationLaunch = null;
10882
+ if (automation) {
10883
+ automation.frameProducer.stop();
10884
+ await automation.cdp.send("Page.stopScreencast").catch(() => void 0);
10885
+ await automation.cdp.detach().catch(() => void 0);
10886
+ await automation.browser.close().catch(() => void 0);
10887
+ }
10888
+ }
10889
+ async setLocalScreencasting(session, owner, enabled) {
10890
+ const automation = enabled ? await this.ensureLocalAutomation(session, owner) : session.localAutomation;
10891
+ if (!automation || automation.generation !== owner.generation) return;
10892
+ const operation = automation.screencastTail.then(async () => {
10893
+ if (session.localOwner !== owner || session.localAutomation !== automation || automation.screencasting === enabled) return;
10894
+ automation.screencasting = enabled;
10895
+ if (!enabled) {
10896
+ automation.frameProducer.stop();
10897
+ await automation.cdp.send("Page.stopScreencast").catch(() => void 0);
10898
+ return;
10899
+ }
10900
+ automation.frameProducer.start();
10901
+ const width = Math.max(320, session.state.viewport.width || 1280);
10902
+ const height = Math.max(200, session.state.viewport.height || 800);
10903
+ await automation.cdp.send("Page.startScreencast", {
10904
+ format: "jpeg",
10905
+ quality: 75,
10906
+ maxWidth: width,
10907
+ maxHeight: height,
10908
+ everyNthFrame: 1
10909
+ }).catch((cause) => {
10910
+ automation.screencasting = false;
10911
+ automation.frameProducer.stop();
10912
+ throw cause;
10913
+ });
10914
+ });
10915
+ automation.screencastTail = operation.catch(() => void 0);
10916
+ await operation;
10917
+ }
10918
+ async executeLocalClientCommand(session, owner, message) {
10919
+ const automation = await this.ensureLocalAutomation(session, owner);
10920
+ const page = automation.page;
10921
+ if (message.type === "navigate") await page.goto(message.url, { waitUntil: "commit" });
10922
+ else if (message.type === "back") await page.goBack({ waitUntil: "commit" });
10923
+ else if (message.type === "forward") await page.goForward({ waitUntil: "commit" });
10924
+ else if (message.type === "reload") await page.reload({ waitUntil: "commit" });
10925
+ else if (message.type === "stop") await automation.cdp.send("Page.stopLoading");
10926
+ else if (message.type === "pointer") {
10927
+ await page.mouse.move(message.x, message.y);
10928
+ if (message.phase === "down") await page.mouse.down({ button: message.button ?? "left" });
10929
+ else if (message.phase === "up") await page.mouse.up({ button: message.button ?? "left" });
10930
+ } else if (message.type === "wheel") await page.mouse.wheel(message.deltaX, message.deltaY);
10931
+ else if (message.type === "key") if (message.phase === "down") await page.keyboard.down(message.key);
10932
+ else await page.keyboard.up(message.key);
10933
+ else if (message.type === "insertText") await page.keyboard.insertText(message.text);
10934
+ }
10935
+ async executeLocalAgentCommand(session, owner, input) {
10936
+ const automation = await this.ensureLocalAutomation(session, owner);
10937
+ const page = automation.page;
10938
+ const target = (value) => page.locator(`aria-ref=${value}`);
10939
+ if (input.command === "snapshot") return Reflect.apply(page.ariaSnapshot, page, [{ mode: "ai" }]);
10940
+ if (input.command === "click") {
10941
+ await target(input.args[0]).click();
10942
+ return `Clicked ${input.args[0]}`;
10943
+ }
10944
+ if (input.command === "fill") {
10945
+ await target(input.args[0]).fill(input.args[1]);
10946
+ return `Filled ${input.args[0]}`;
10947
+ }
10948
+ if (input.command === "press") {
10949
+ await page.keyboard.press(input.args[0]);
10950
+ return `Pressed ${input.args[0]}`;
10951
+ }
10952
+ if (input.command === "console") {
10953
+ const minimum = input.args[0] ?? "info";
10954
+ const levels = [
10955
+ "debug",
10956
+ "info",
10957
+ "warning",
10958
+ "error"
10959
+ ];
10960
+ const minimumIndex = Math.max(0, levels.indexOf(minimum));
10961
+ return automation.console.filter((line) => {
10962
+ const level = line.slice(0, line.indexOf(":"));
10963
+ const index = levels.indexOf(level);
10964
+ return index < 0 || index >= minimumIndex;
10965
+ }).join("\n") || "No console messages.";
10966
+ }
10967
+ if (input.command === "requests") return automation.requests.join("\n") || "No network requests.";
10968
+ if (input.command === "screenshot") {
10969
+ const screenshotPath = path.join(session.agentDirectory, `screenshot-${Date.now()}.png`);
10970
+ await page.screenshot({ path: screenshotPath });
10971
+ return `Screenshot saved to ${screenshotPath}`;
10972
+ }
10973
+ if (input.command === "goto") {
10974
+ await page.goto(input.args[0]);
10975
+ return `Navigated to ${page.url()}`;
10976
+ }
10977
+ if (input.command === "go-back") {
10978
+ await page.goBack();
10979
+ return `Navigated to ${page.url()}`;
10980
+ }
10981
+ if (input.command === "go-forward") {
10982
+ await page.goForward();
10983
+ return `Navigated to ${page.url()}`;
10984
+ }
10985
+ await page.reload();
10986
+ return `Reloaded ${page.url()}`;
10987
+ }
10988
+ async agentCommand(panelId, input) {
10989
+ const session = await this.getSession(panelId);
10990
+ let result = "";
10991
+ await this.scheduleOperation(session, async () => {
10992
+ const localOwner = session.localOwner;
10993
+ const browser = localOwner ? null : await this.browserFor(session).catch(async (error) => {
10994
+ await this.closePlaywrightRuntime(session).catch(() => void 0);
10995
+ throw error;
10996
+ });
10997
+ const previousController = session.controllerId;
10998
+ let agentControlled = false;
10999
+ try {
11000
+ if (localOwner) {
11001
+ await this.updateScreencast(session, "agent");
11002
+ if (session.localOwner !== localOwner || localOwner.generation !== session.generation) throw new Error("The local Browser owner changed.");
11003
+ }
11004
+ session.controllerId = "agent";
11005
+ agentControlled = true;
11006
+ this.broadcastState(session, "controlChanged");
11007
+ if (localOwner) result = await this.executeLocalAgentCommand(session, localOwner, input);
11008
+ else if (this.agentCliRunner) {
11009
+ const name = session.agentSessionName ?? `treeport-${panelId}-${session.generation}-${crypto.randomBytes(6).toString("hex")}`;
11010
+ session.agentSessionName = name;
11011
+ if (!session.agentAttached) {
11012
+ await this.executeAgentCli(session, [
11013
+ "attach",
11014
+ `treeport-${panelId}`,
11015
+ "--session",
11016
+ name
11017
+ ]);
11018
+ session.agentAttached = true;
11019
+ }
11020
+ result = await this.executeAgentCli(session, [
11021
+ `-s=${name}`,
11022
+ input.command,
11023
+ "--",
11024
+ ...input.args
11025
+ ]);
11026
+ this.queuePanelState(session, browser.state);
11027
+ } else {
11028
+ result = await browser.agentCommand(input);
11029
+ this.queuePanelState(session, browser.state);
11030
+ }
11031
+ await this.waitForPanelState(session);
11032
+ } catch (cause) {
11033
+ if (!localOwner) session.agentAttached = false;
11034
+ throw cause;
11035
+ } finally {
11036
+ if (agentControlled) {
11037
+ if (localOwner && session.localOwner === localOwner) {
11038
+ const nextController = previousController && previousController !== "agent" ? previousController : LOCAL_BROWSER_OWNER_CONTROLLER;
11039
+ if (await this.updateScreencast(session, nextController).then(() => true, () => false)) session.controllerId = nextController;
11040
+ } else {
11041
+ const nextAttachment = [...session.attachments.values()].find((attachment) => !attachment.closing && attachment.visible);
11042
+ session.controllerId = previousController && previousController !== "agent" ? previousController : nextAttachment ? attachmentController(nextAttachment.clientId) : null;
11043
+ }
11044
+ this.broadcastState(session, "controlChanged");
11045
+ }
8456
11046
  }
11047
+ });
11048
+ return result;
11049
+ }
11050
+ async status() {
11051
+ return PlaywrightBrowser.status(this.cachePath);
11052
+ }
11053
+ async install() {
11054
+ if (this.installing) return this.installing;
11055
+ this.installing = (async () => {
11056
+ const packageJsonPath = createRequire(import.meta.url).resolve("playwright/package.json");
11057
+ const packageJson = playwrightPackageSchema.parse(JSON.parse(await fs.readFile(packageJsonPath, "utf8")));
11058
+ const cli = path.join(path.dirname(packageJsonPath), packageJson.bin?.playwright ?? "cli.js");
11059
+ return new Promise((resolve, reject) => {
11060
+ const child = spawn(process.execPath, [
11061
+ cli,
11062
+ "install",
11063
+ "chromium",
11064
+ "--no-shell"
11065
+ ], {
11066
+ env: {
11067
+ ...process.env,
11068
+ PLAYWRIGHT_BROWSERS_PATH: this.cachePath
11069
+ },
11070
+ stdio: [
11071
+ "ignore",
11072
+ "pipe",
11073
+ "pipe"
11074
+ ]
11075
+ });
11076
+ let output = "";
11077
+ child.stdout.on("data", (data) => output += String(data));
11078
+ child.stderr.on("data", (data) => output += String(data));
11079
+ child.once("error", reject);
11080
+ child.once("exit", (code) => {
11081
+ if (code === 0) resolve(output.trim() || "Chromium installed.");
11082
+ else reject(new Error(output.trim() || `Playwright exited with ${code}`));
11083
+ });
11084
+ });
11085
+ })();
11086
+ try {
11087
+ return await this.installing;
11088
+ } finally {
11089
+ this.installing = null;
8457
11090
  }
8458
- };
8459
- }
11091
+ }
11092
+ async remove() {
11093
+ if (this.installing) throw new Error("Wait for the Browser installation to finish.");
11094
+ if (this.browserHost.started || [...this.sessions.values()].some((session) => session.browser !== null || session.launch !== null)) throw new Error("Close daemon-owned Browser before you remove Chromium.");
11095
+ await fs.rm(this.cachePath, {
11096
+ recursive: true,
11097
+ force: true
11098
+ });
11099
+ }
11100
+ async dispose() {
11101
+ this.unsubscribe();
11102
+ this.tickets.clear();
11103
+ this.ownerTickets.clear();
11104
+ await Promise.all([...this.sessionCreations.values()].map((creation) => creation.catch(() => null)));
11105
+ await Promise.all([...this.sessions.keys()].map((panelId) => this.closePanel(panelId, "Treeport is shutting down.")));
11106
+ await this.browserHost.close();
11107
+ }
11108
+ };
8460
11109
  //#endregion
8461
11110
  //#region src/server/daemon-ownership.ts
8462
11111
  function processExists(pid) {
@@ -8517,167 +11166,6 @@ async function acquireDaemonOwnership(config) {
8517
11166
  };
8518
11167
  }
8519
11168
  //#endregion
8520
- //#region src/server/request-security.ts
8521
- const MAX_HOST_BYTES = 512;
8522
- const MAX_LOGIN_BYTES = 320;
8523
- const MAX_NAME_BYTES = 512;
8524
- const MAX_PROFILE_PICTURE_BYTES = 2048;
8525
- const UNSAFE_METHODS = /* @__PURE__ */ new Set([
8526
- "POST",
8527
- "PUT",
8528
- "PATCH",
8529
- "DELETE"
8530
- ]);
8531
- function denied(status, code, message) {
8532
- return {
8533
- allowed: false,
8534
- principal: null,
8535
- effectiveOrigin: null,
8536
- status,
8537
- code,
8538
- message
8539
- };
8540
- }
8541
- function headerValues(request, name) {
8542
- const values = [];
8543
- for (let index = 0; index < request.rawHeaders.length; index += 2) if (request.rawHeaders[index]?.toLowerCase() === name) values.push(request.rawHeaders[index + 1] ?? "");
8544
- if (values.length > 0) return values;
8545
- const value = request.headers[name];
8546
- if (value === void 0) return [];
8547
- return Array.isArray(value) ? value : [value];
8548
- }
8549
- function singleHeader(request, name) {
8550
- const values = headerValues(request, name);
8551
- return {
8552
- present: values.length > 0,
8553
- valid: values.length <= 1,
8554
- value: values.length === 1 ? values[0] ?? null : null
8555
- };
8556
- }
8557
- function hasControlCharacters(value) {
8558
- return [...value].some((character) => {
8559
- const code = character.codePointAt(0) ?? 0;
8560
- return code <= 31 || code === 127;
8561
- });
8562
- }
8563
- function parseHost(value) {
8564
- if (value.length === 0 || Buffer.byteLength(value) > MAX_HOST_BYTES || hasControlCharacters(value) || /[\s,/@?#\\]/u.test(value)) return null;
8565
- const candidate = `http://${value}`;
8566
- if (!URL.canParse(candidate)) return null;
8567
- const url = new URL(candidate);
8568
- if (!url.host || url.pathname !== "/") return null;
8569
- return {
8570
- host: url.host,
8571
- hostname: url.hostname.toLowerCase()
8572
- };
8573
- }
8574
- function isLoopbackHostname(hostname) {
8575
- return [
8576
- "127.0.0.1",
8577
- "localhost",
8578
- "[::1]",
8579
- "::1"
8580
- ].includes(hostname);
8581
- }
8582
- function isLoopbackAddress(address) {
8583
- if (!address) return false;
8584
- const normalized = address.toLowerCase();
8585
- if (normalized === "::1") return true;
8586
- const octets = (normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized).split(".");
8587
- return octets.length === 4 && octets[0] === "127" && octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255);
8588
- }
8589
- function validIdentityValue(value, maximumBytes) {
8590
- return value.length > 0 && value === value.trim() && Buffer.byteLength(value) <= maximumBytes && !hasControlCharacters(value);
8591
- }
8592
- function effectiveOriginFor(request, source, incomingHost) {
8593
- if (source === "local") return `http://${incomingHost.host}`;
8594
- const forwardedHostHeader = singleHeader(request, "x-forwarded-host");
8595
- const forwardedProtocolHeader = singleHeader(request, "x-forwarded-proto");
8596
- if (!forwardedHostHeader.valid || !forwardedHostHeader.present || !forwardedProtocolHeader.valid || !forwardedProtocolHeader.present) return null;
8597
- const forwardedHost = parseHost(forwardedHostHeader.value ?? "");
8598
- if (!forwardedHost || forwardedProtocolHeader.value?.toLowerCase() !== "https") return null;
8599
- return `https://${forwardedHost.host}`;
8600
- }
8601
- function allowsOpaqueWebPanelOrigin(request, socketUpgrade) {
8602
- if (!["GET", "HEAD"].includes(request.method?.toUpperCase() ?? "")) return false;
8603
- const pathname = new URL(request.url ?? "/", "http://treeport.local").pathname;
8604
- if (socketUpgrade) return /^\/api\/web-panel-dev\/[a-f0-9]{24}\/@vite-hmr$/u.test(pathname);
8605
- return /^\/api\/web-panels\/panel_[a-f0-9]{32}\/assets(?:\/|$)/u.test(pathname) || /^\/api\/web-panel-dev\/[a-f0-9]{24}\//u.test(pathname);
8606
- }
8607
- function originIsAllowed(request, effectiveOrigin, socketUpgrade) {
8608
- const originHeader = singleHeader(request, "origin");
8609
- if (!originHeader.valid) return false;
8610
- if (originHeader.present) {
8611
- const value = originHeader.value ?? "";
8612
- if (value === "null") {
8613
- if (!allowsOpaqueWebPanelOrigin(request, socketUpgrade)) return false;
8614
- } else {
8615
- if (!URL.canParse(value)) return false;
8616
- const parsed = new URL(value);
8617
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== value || parsed.origin !== effectiveOrigin) return false;
8618
- }
8619
- }
8620
- const fetchSiteHeader = singleHeader(request, "sec-fetch-site");
8621
- if (!fetchSiteHeader.valid) return false;
8622
- if ((UNSAFE_METHODS.has(request.method?.toUpperCase() ?? "") || socketUpgrade) && fetchSiteHeader.value?.toLowerCase() === "cross-site") return false;
8623
- return true;
8624
- }
8625
- function authorizeRequest(request, options = {}) {
8626
- if (!isLoopbackAddress(request.socket.remoteAddress)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
8627
- const hostHeader = singleHeader(request, "host");
8628
- const incomingHost = hostHeader.valid && hostHeader.value ? parseHost(hostHeader.value.toLowerCase()) : null;
8629
- if (!incomingHost) return denied(400, "INVALID_REQUEST_HOST", "The request host is invalid.");
8630
- const loginHeader = singleHeader(request, "tailscale-user-login");
8631
- const nameHeader = singleHeader(request, "tailscale-user-name");
8632
- const profilePictureHeader = singleHeader(request, "tailscale-user-profile-pic");
8633
- const hasTailscaleIdentity = loginHeader.present || nameHeader.present || profilePictureHeader.present;
8634
- const headersHaveSingleValues = loginHeader.valid && nameHeader.valid && profilePictureHeader.valid;
8635
- let principal;
8636
- if (hasTailscaleIdentity) {
8637
- const login = loginHeader.value ?? "";
8638
- const name = nameHeader.value === "" ? null : nameHeader.value;
8639
- const profilePicture = profilePictureHeader.value === "" ? null : profilePictureHeader.value;
8640
- const validProfilePicture = profilePicture === null || validIdentityValue(profilePicture, MAX_PROFILE_PICTURE_BYTES) && URL.canParse(profilePicture) && ["http:", "https:"].includes(new URL(profilePicture).protocol);
8641
- if (!headersHaveSingleValues || !loginHeader.present || !validIdentityValue(login, MAX_LOGIN_BYTES) || name !== null && !validIdentityValue(name, MAX_NAME_BYTES) || !validProfilePicture) return denied(401, "INVALID_TAILSCALE_IDENTITY", "The Tailscale user identity is invalid.");
8642
- principal = {
8643
- source: "tailscale",
8644
- login,
8645
- name,
8646
- profilePicture
8647
- };
8648
- } else {
8649
- if (!isLoopbackHostname(incomingHost.hostname)) return denied(401, "AUTHENTICATION_REQUIRED", "Treeport accepts remote requests only through Tailscale Serve.");
8650
- principal = {
8651
- source: "local",
8652
- login: null,
8653
- name: null,
8654
- profilePicture: null
8655
- };
8656
- }
8657
- const effectiveOrigin = effectiveOriginFor(request, principal.source, incomingHost);
8658
- if (!effectiveOrigin) return denied(400, "INVALID_FORWARDED_ORIGIN", "The forwarded request origin is invalid.");
8659
- if (!originIsAllowed(request, effectiveOrigin, options.socketUpgrade === true)) return denied(403, "INVALID_ORIGIN", "The request origin is not allowed.");
8660
- return {
8661
- allowed: true,
8662
- principal,
8663
- effectiveOrigin,
8664
- status: 200,
8665
- code: null,
8666
- message: null
8667
- };
8668
- }
8669
- function rejectHttpRequest(request, response, decision) {
8670
- const apiRequest = request.url?.startsWith("/api/") === true;
8671
- const body = apiRequest ? `${JSON.stringify({ error: {
8672
- code: decision.code,
8673
- message: decision.message
8674
- } })}\n` : `${decision.message ?? "The request was rejected."}\n`;
8675
- response.statusCode = decision.status;
8676
- response.setHeader("cache-control", "no-store");
8677
- response.setHeader("content-type", apiRequest ? "application/json; charset=utf-8" : "text/plain; charset=utf-8");
8678
- response.end(request.method === "HEAD" ? void 0 : body);
8679
- }
8680
- //#endregion
8681
11169
  //#region src/server/terminal-attachments.ts
8682
11170
  var AttachmentInitializationError = class {
8683
11171
  phase;
@@ -9290,7 +11778,7 @@ var TerminalAttachmentManager = class {
9290
11778
  };
9291
11779
  //#endregion
9292
11780
  //#region src/server/socket-server.ts
9293
- function createSocketServer(httpServer, { service, config, tmux, terminalMetadata, attachmentManager }) {
11781
+ function createSocketServer(httpServer, { service, config, tmux, terminalMetadata, attachmentManager, browserSessions }) {
9294
11782
  const io = new Server(httpServer, {
9295
11783
  path: SOCKET_IO_PATH,
9296
11784
  serveClient: false,
@@ -9301,6 +11789,7 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
9301
11789
  });
9302
11790
  const metadataReady = terminalMetadata.initialize();
9303
11791
  const attachments = attachmentManager ?? new TerminalAttachmentManager(service, tmux, config.tmuxPath, terminalMetadata);
11792
+ const hostedBrowsers = browserSessions ?? new BrowserSessionManager(service, config);
9304
11793
  io.of("/events").on("connection", (socket) => {
9305
11794
  const queuedEvents = [];
9306
11795
  let snapshotted = false;
@@ -9313,12 +11802,13 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
9313
11802
  if (!socket.connected) return;
9314
11803
  const metadataSnapshot = terminalMetadata.snapshot();
9315
11804
  const representedEventCount = queuedEvents.length;
9316
- service.listWebPanels().then((webPanels) => {
11805
+ Promise.all([service.listWebPanels(), service.listBrowserPanels()]).then(([webPanels, browserPanels]) => {
9317
11806
  if (!socket.connected) return;
9318
11807
  socket.emit("snapshot", {
9319
11808
  at: (/* @__PURE__ */ new Date()).toISOString(),
9320
11809
  terminalMetadata: metadataSnapshot,
9321
- webPanels
11810
+ webPanels,
11811
+ browserPanels
9322
11812
  });
9323
11813
  queuedEvents.splice(0, representedEventCount);
9324
11814
  while (queuedEvents.length && socket.connected) socket.emit("product_event", queuedEvents.shift());
@@ -9371,9 +11861,69 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
9371
11861
  socket.on("output_ack", (payload) => attachments.message(connectionId, "output_ack", payload));
9372
11862
  socket.once("disconnect", () => attachments.close(connectionId));
9373
11863
  });
11864
+ const browsers = io.of("/browsers");
11865
+ browsers.use((socket, next) => {
11866
+ const auth = parseBrowserAuth(socket.handshake.auth);
11867
+ if (!auth) {
11868
+ next(/* @__PURE__ */ new Error("INVALID_BROWSER_AUTH"));
11869
+ return;
11870
+ }
11871
+ socket.data.browserTicket = auth.ticket;
11872
+ next();
11873
+ });
11874
+ browsers.on("connection", (socket) => {
11875
+ const transport = {
11876
+ id: socket.id,
11877
+ isConnected: () => socket.connected,
11878
+ sendMessage(message) {
11879
+ if (!socket.connected) return false;
11880
+ socket.emit("message", message);
11881
+ return true;
11882
+ },
11883
+ sendFrame(frame) {
11884
+ if (!socket.connected) return false;
11885
+ socket.emit("frame", frame);
11886
+ return true;
11887
+ },
11888
+ disconnect() {
11889
+ socket.disconnect(true);
11890
+ }
11891
+ };
11892
+ socket.on("command", (message) => hostedBrowsers.message(socket.id, message));
11893
+ socket.once("disconnect", () => hostedBrowsers.close(socket.id));
11894
+ hostedBrowsers.accept(socket.data.browserTicket, transport).catch(() => socket.disconnect(true));
11895
+ });
11896
+ const browserOwners = io.of("/browser-owners");
11897
+ browserOwners.use((socket, next) => {
11898
+ const auth = parseBrowserOwnerAuth(socket.handshake.auth);
11899
+ if (!auth) {
11900
+ next(/* @__PURE__ */ new Error("INVALID_BROWSER_OWNER_AUTH"));
11901
+ return;
11902
+ }
11903
+ socket.data.browserOwnerAuth = auth;
11904
+ next();
11905
+ });
11906
+ browserOwners.on("connection", (socket) => {
11907
+ const transport = {
11908
+ id: socket.id,
11909
+ isConnected: () => socket.connected,
11910
+ send(message) {
11911
+ if (!socket.connected) return false;
11912
+ socket.emit("ownerMessage", message);
11913
+ return true;
11914
+ },
11915
+ disconnect() {
11916
+ socket.disconnect(true);
11917
+ }
11918
+ };
11919
+ socket.on("ownerMessage", (message) => hostedBrowsers.ownerMessage(socket.id, message));
11920
+ socket.once("disconnect", () => hostedBrowsers.closeOwner(socket.id));
11921
+ hostedBrowsers.acceptOwner(socket.data.browserOwnerAuth, transport).catch(() => socket.disconnect(true));
11922
+ });
9374
11923
  return {
9375
11924
  io,
9376
- attachments
11925
+ attachments,
11926
+ browserSessions: hostedBrowsers
9377
11927
  };
9378
11928
  }
9379
11929
  //#endregion
@@ -9406,12 +11956,14 @@ async function main() {
9406
11956
  const terminalMetadata = new TerminalMetadataManager(service, tmux, config.tmuxPath);
9407
11957
  await terminalMetadata.initialize();
9408
11958
  const applicationUpdate = createApplicationUpdateManager(config);
11959
+ const browserSessions = new BrowserSessionManager(service, config);
9409
11960
  const honoListener = getRequestListener(createApp({
9410
11961
  service,
9411
11962
  config,
9412
11963
  tmux,
9413
11964
  applicationUpdate,
9414
- terminalMetadata
11965
+ terminalMetadata,
11966
+ browserSessions
9415
11967
  }).fetch);
9416
11968
  let vite = null;
9417
11969
  const server = createServer((request, response) => {
@@ -9453,7 +12005,8 @@ async function main() {
9453
12005
  service,
9454
12006
  config,
9455
12007
  tmux,
9456
- terminalMetadata
12008
+ terminalMetadata,
12009
+ browserSessions
9457
12010
  });
9458
12011
  await new Promise((resolve, reject) => {
9459
12012
  server.once("error", reject);
@@ -9483,6 +12036,7 @@ async function main() {
9483
12036
  terminalMetadata.drain(),
9484
12037
  viteClosed
9485
12038
  ]).then(async () => {
12039
+ await browserSessions.dispose();
9486
12040
  await service.disposeWebPanelRuntime();
9487
12041
  database.close();
9488
12042
  await ownership.release();