codex-webstrapper 0.2.0 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-webstrapper",
3
- "version": "0.2.0",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Web wrapper for Codex desktop assets with bridge + token auth",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@electron/asar": "^4.0.1",
26
+ "bun-pty": "^0.4.8",
26
27
  "ws": "^8.18.0"
27
28
  }
28
29
  }
@@ -0,0 +1,161 @@
1
+ import { spawn } from "bun-pty";
2
+
3
+ function toErrorMessage(error) {
4
+ if (!error) {
5
+ return "unknown_error";
6
+ }
7
+ if (typeof error === "string") {
8
+ return error;
9
+ }
10
+ if (typeof error.message === "string" && error.message.length > 0) {
11
+ return error.message;
12
+ }
13
+ return String(error);
14
+ }
15
+
16
+ function send(payload) {
17
+ try {
18
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
19
+ } catch {
20
+ // Ignore JSON serialization/output failures.
21
+ }
22
+ }
23
+
24
+ function parseConfig(raw) {
25
+ try {
26
+ const parsed = JSON.parse(raw || "{}");
27
+ if (!parsed || typeof parsed !== "object") {
28
+ return null;
29
+ }
30
+ return parsed;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function toDimension(value, fallback) {
37
+ const parsed = Number(value);
38
+ if (!Number.isFinite(parsed) || parsed <= 0) {
39
+ return fallback;
40
+ }
41
+ return Math.max(1, Math.floor(parsed));
42
+ }
43
+
44
+ const config = parseConfig(process.env.CODEX_WEBSTRAP_BUN_PTY_CONFIG || "");
45
+ if (!config || typeof config.file !== "string" || config.file.length === 0) {
46
+ send({ type: "error", message: "Invalid bun-pty config." });
47
+ process.exit(2);
48
+ }
49
+
50
+ let terminal;
51
+ try {
52
+ terminal = spawn(
53
+ config.file,
54
+ Array.isArray(config.args) ? config.args.filter((entry) => typeof entry === "string") : [],
55
+ {
56
+ name: typeof config.term === "string" && config.term.length > 0 ? config.term : "xterm-256color",
57
+ cols: toDimension(config.cols, 120),
58
+ rows: toDimension(config.rows, 30),
59
+ cwd: typeof config.cwd === "string" && config.cwd.length > 0 ? config.cwd : process.cwd(),
60
+ env: config.env && typeof config.env === "object" ? config.env : process.env
61
+ }
62
+ );
63
+ } catch (error) {
64
+ send({
65
+ type: "error",
66
+ message: `Failed to start bun-pty terminal: ${toErrorMessage(error)}`
67
+ });
68
+ process.exit(1);
69
+ }
70
+
71
+ let exiting = false;
72
+ const requestExit = (code = 0) => {
73
+ if (exiting) {
74
+ return;
75
+ }
76
+ exiting = true;
77
+ process.exit(code);
78
+ };
79
+
80
+ terminal.onData((data) => {
81
+ send({
82
+ type: "data",
83
+ data
84
+ });
85
+ });
86
+
87
+ terminal.onExit((exit) => {
88
+ send({
89
+ type: "exit",
90
+ exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null,
91
+ signal: exit?.signal ?? null
92
+ });
93
+ requestExit(0);
94
+ });
95
+
96
+ let inputBuffer = "";
97
+ process.stdin.setEncoding("utf8");
98
+ process.stdin.on("data", (chunk) => {
99
+ inputBuffer += chunk;
100
+
101
+ for (;;) {
102
+ const newlineAt = inputBuffer.indexOf("\n");
103
+ if (newlineAt < 0) {
104
+ break;
105
+ }
106
+
107
+ const line = inputBuffer.slice(0, newlineAt);
108
+ inputBuffer = inputBuffer.slice(newlineAt + 1);
109
+
110
+ if (line.trim().length === 0) {
111
+ continue;
112
+ }
113
+
114
+ let command;
115
+ try {
116
+ command = JSON.parse(line);
117
+ } catch {
118
+ continue;
119
+ }
120
+
121
+ try {
122
+ if (command?.type === "write") {
123
+ terminal.write(typeof command.data === "string" ? command.data : "");
124
+ continue;
125
+ }
126
+
127
+ if (command?.type === "resize") {
128
+ terminal.resize(toDimension(command.cols, 120), toDimension(command.rows, 30));
129
+ continue;
130
+ }
131
+
132
+ if (command?.type === "close") {
133
+ terminal.kill("SIGTERM");
134
+ requestExit(0);
135
+ }
136
+ } catch (error) {
137
+ send({
138
+ type: "error",
139
+ message: toErrorMessage(error)
140
+ });
141
+ }
142
+ }
143
+ });
144
+
145
+ process.on("SIGTERM", () => {
146
+ try {
147
+ terminal.kill("SIGTERM");
148
+ } catch {
149
+ // Ignore.
150
+ }
151
+ requestExit(0);
152
+ });
153
+
154
+ process.on("SIGINT", () => {
155
+ try {
156
+ terminal.kill("SIGTERM");
157
+ } catch {
158
+ // Ignore.
159
+ }
160
+ requestExit(0);
161
+ });
@@ -1,9 +1,10 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn as childSpawn, spawnSync as childSpawnSync } from "node:child_process";
2
2
  import { Worker } from "node:worker_threads";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, statSync } from "node:fs";
6
6
  import fs from "node:fs/promises";
7
+ import { fileURLToPath } from "node:url";
7
8
 
8
9
  import { createLogger, randomId, safeJsonParse, toErrorMessage } from "./util.mjs";
9
10
 
@@ -76,6 +77,11 @@ class TerminalRegistry {
76
77
  this.sendToWs = sendToWs;
77
78
  this.logger = logger;
78
79
  this.sessions = new Map();
80
+ this.bunPtyAvailable = this._detectBunPtyAvailability();
81
+ this.loggedBunPtyFailure = false;
82
+ this.pythonPtyAvailable = this._detectPythonPtyAvailability();
83
+ this.loggedPythonPtyFailure = false;
84
+ this.hasPtyLikeRuntime = this.bunPtyAvailable || this.pythonPtyAvailable;
79
85
  }
80
86
 
81
87
  createOrAttach(ws, message) {
@@ -83,41 +89,80 @@ class TerminalRegistry {
83
89
  const existing = this.sessions.get(sessionId);
84
90
  if (existing) {
85
91
  existing.listeners.add(ws);
86
- this.sendToWs(ws, { type: "terminal-attached", sessionId });
92
+ this.sendToWs(ws, {
93
+ type: "terminal-attached",
94
+ sessionId,
95
+ cwd: existing.cwd,
96
+ shell: existing.shell
97
+ });
98
+ this.sendToWs(ws, {
99
+ type: "terminal-init-log",
100
+ sessionId,
101
+ log: existing.attachLog
102
+ });
103
+ if (this._hasExplicitDimensions(message)) {
104
+ this.resize(sessionId, message.cols, message.rows);
105
+ }
87
106
  return;
88
107
  }
89
108
 
90
- const shell = process.env.SHELL || "/bin/zsh";
91
- const command = Array.isArray(message.command) && message.command.length > 0
92
- ? message.command
93
- : [shell];
109
+ const launchConfig = this._resolveLaunchConfig(message);
110
+ let runtime;
111
+ try {
112
+ runtime = this._spawnRuntime(launchConfig, message);
113
+ } catch (error) {
114
+ this.sendToWs(ws, {
115
+ type: "terminal-error",
116
+ sessionId,
117
+ message: toErrorMessage(error)
118
+ });
119
+ this.logger.warn("Terminal spawn failed", {
120
+ sessionId,
121
+ error: toErrorMessage(error)
122
+ });
123
+ return;
124
+ }
94
125
 
95
- const [bin, ...args] = command;
96
- const proc = spawn(bin, args, {
97
- cwd: message.cwd || process.cwd(),
98
- env: {
99
- ...process.env,
100
- ...(message.env || {})
101
- },
102
- stdio: ["pipe", "pipe", "pipe"]
103
- });
126
+ const attachLogLines = [
127
+ `Terminal attached via codex-webstrapper (${runtime.mode})`
128
+ ];
129
+ if (launchConfig.cwdWasFallback && launchConfig.requestedCwd) {
130
+ attachLogLines.push(`[webstrap] Requested cwd unavailable: ${launchConfig.requestedCwd}`);
131
+ attachLogLines.push(`[webstrap] Using cwd: ${launchConfig.cwd}`);
132
+ }
133
+ const attachLog = `${attachLogLines.join("\r\n")}\r\n`;
104
134
 
105
135
  const session = {
106
136
  sessionId,
107
- proc,
108
- listeners: new Set([ws])
137
+ listeners: new Set([ws]),
138
+ cwd: launchConfig.cwd,
139
+ shell: launchConfig.shell,
140
+ attachLog,
141
+ cols: runtime.cols ?? this._coerceDimension(message?.cols, 120),
142
+ rows: runtime.rows ?? this._coerceDimension(message?.rows, 30),
143
+ ...runtime
109
144
  };
110
145
 
111
146
  this.sessions.set(sessionId, session);
112
147
 
113
- this.sendToWs(ws, { type: "terminal-attached", sessionId });
148
+ this.sendToWs(ws, {
149
+ type: "terminal-attached",
150
+ sessionId,
151
+ cwd: session.cwd,
152
+ shell: session.shell
153
+ });
114
154
  this.sendToWs(ws, {
115
155
  type: "terminal-init-log",
116
156
  sessionId,
117
- log: "Terminal attached via codex-webstrapper\r\n"
157
+ log: session.attachLog
118
158
  });
119
159
 
120
- proc.stdout?.on("data", (chunk) => {
160
+ if (session.mode === "bun-pty") {
161
+ this._attachBunPtyProcess(sessionId, session);
162
+ return;
163
+ }
164
+
165
+ session.proc.stdout?.on("data", (chunk) => {
121
166
  this._broadcast(sessionId, {
122
167
  type: "terminal-data",
123
168
  sessionId,
@@ -125,7 +170,7 @@ class TerminalRegistry {
125
170
  });
126
171
  });
127
172
 
128
- proc.stderr?.on("data", (chunk) => {
173
+ session.proc.stderr?.on("data", (chunk) => {
129
174
  this._broadcast(sessionId, {
130
175
  type: "terminal-data",
131
176
  sessionId,
@@ -133,7 +178,7 @@ class TerminalRegistry {
133
178
  });
134
179
  });
135
180
 
136
- proc.on("error", (error) => {
181
+ session.proc.on("error", (error) => {
137
182
  this._broadcast(sessionId, {
138
183
  type: "terminal-error",
139
184
  sessionId,
@@ -141,7 +186,7 @@ class TerminalRegistry {
141
186
  });
142
187
  });
143
188
 
144
- proc.on("exit", (code, signal) => {
189
+ session.proc.on("exit", (code, signal) => {
145
190
  this._broadcast(sessionId, {
146
191
  type: "terminal-exit",
147
192
  sessionId,
@@ -154,17 +199,72 @@ class TerminalRegistry {
154
199
 
155
200
  write(sessionId, data) {
156
201
  const session = this.sessions.get(sessionId);
157
- if (!session || !session.proc.stdin || session.proc.stdin.destroyed) {
202
+ if (!session) {
203
+ return;
204
+ }
205
+
206
+ if (session.mode === "bun-pty") {
207
+ try {
208
+ this._writeToBunPty(session, {
209
+ type: "write",
210
+ data
211
+ });
212
+ } catch (error) {
213
+ this._broadcast(sessionId, {
214
+ type: "terminal-error",
215
+ sessionId,
216
+ message: toErrorMessage(error)
217
+ });
218
+ }
219
+ return;
220
+ }
221
+
222
+ if (!session.proc.stdin || session.proc.stdin.destroyed) {
158
223
  return;
159
224
  }
160
- session.proc.stdin.write(data);
225
+
226
+ try {
227
+ session.proc.stdin.write(data);
228
+ } catch (error) {
229
+ this._broadcast(sessionId, {
230
+ type: "terminal-error",
231
+ sessionId,
232
+ message: toErrorMessage(error)
233
+ });
234
+ }
161
235
  }
162
236
 
163
- resize(sessionId) {
164
- if (!this.sessions.has(sessionId)) {
237
+ resize(sessionId, cols, rows) {
238
+ const session = this.sessions.get(sessionId);
239
+ if (!session) {
240
+ return;
241
+ }
242
+
243
+ if (session.mode === "bun-pty") {
244
+ try {
245
+ const nextCols = this._coerceDimension(cols, session.cols || 120);
246
+ const nextRows = this._coerceDimension(rows, session.rows || 30);
247
+ this._writeToBunPty(session, {
248
+ type: "resize",
249
+ cols: nextCols,
250
+ rows: nextRows
251
+ });
252
+ session.cols = nextCols;
253
+ session.rows = nextRows;
254
+ } catch (error) {
255
+ this._broadcast(sessionId, {
256
+ type: "terminal-error",
257
+ sessionId,
258
+ message: toErrorMessage(error)
259
+ });
260
+ }
261
+ return;
262
+ }
263
+
264
+ if (session.mode !== "python-pty") {
265
+ this.logger.debug("Terminal resize ignored (non-PTY mode)", { sessionId });
165
266
  return;
166
267
  }
167
- this.logger.debug("Terminal resize ignored (non-PTY mode)", { sessionId });
168
268
  }
169
269
 
170
270
  close(sessionId) {
@@ -173,6 +273,24 @@ class TerminalRegistry {
173
273
  return;
174
274
  }
175
275
 
276
+ if (session.mode === "bun-pty") {
277
+ try {
278
+ this._writeToBunPty(session, {
279
+ type: "close"
280
+ });
281
+ } catch (error) {
282
+ this.logger.debug("Bun PTY close command failed", {
283
+ sessionId,
284
+ error: toErrorMessage(error)
285
+ });
286
+ }
287
+ if (!session.proc.killed) {
288
+ session.proc.kill();
289
+ }
290
+ this.sessions.delete(sessionId);
291
+ return;
292
+ }
293
+
176
294
  if (!session.proc.killed) {
177
295
  session.proc.kill();
178
296
  }
@@ -204,6 +322,353 @@ class TerminalRegistry {
204
322
  this.sendToWs(listener, message);
205
323
  }
206
324
  }
325
+
326
+ _resolveLaunchConfig(message) {
327
+ const requestedCwd = typeof message?.cwd === "string" ? message.cwd.trim() : "";
328
+ const cwdResult = this._resolveCwd(requestedCwd);
329
+
330
+ const commandFromMessage = Array.isArray(message?.command)
331
+ ? message.command.filter((entry) => typeof entry === "string" && entry.length > 0)
332
+ : [];
333
+
334
+ let command = commandFromMessage;
335
+ let shell = null;
336
+
337
+ if (command.length === 0) {
338
+ shell = this._resolveShellPath(message?.shell);
339
+ command = this._defaultShellCommand(shell);
340
+ } else {
341
+ shell = command[0];
342
+ }
343
+
344
+ return {
345
+ command,
346
+ shell,
347
+ cwd: cwdResult.cwd,
348
+ requestedCwd: cwdResult.requestedCwd,
349
+ cwdWasFallback: cwdResult.cwdWasFallback,
350
+ env: this._buildEnv(message?.env)
351
+ };
352
+ }
353
+
354
+ _spawnRuntime(launchConfig, message) {
355
+ const bunPtyRuntime = this._spawnBunPtyRuntime(launchConfig, message);
356
+ if (bunPtyRuntime) {
357
+ return bunPtyRuntime;
358
+ }
359
+
360
+ const pythonPtyRuntime = this._spawnPythonPtyRuntime(launchConfig);
361
+ if (pythonPtyRuntime) {
362
+ return pythonPtyRuntime;
363
+ }
364
+
365
+ const [bin, ...args] = launchConfig.command;
366
+ const proc = childSpawn(bin, args, {
367
+ cwd: launchConfig.cwd,
368
+ env: launchConfig.env,
369
+ stdio: ["pipe", "pipe", "pipe"]
370
+ });
371
+
372
+ return {
373
+ mode: "pipe",
374
+ proc,
375
+ cols: this._coerceDimension(message?.cols, 120),
376
+ rows: this._coerceDimension(message?.rows, 30)
377
+ };
378
+ }
379
+
380
+ _spawnBunPtyRuntime(launchConfig, message) {
381
+ if (!this.bunPtyAvailable) {
382
+ return null;
383
+ }
384
+
385
+ const [bin, ...args] = launchConfig.command;
386
+ try {
387
+ const bridgePath = fileURLToPath(new URL("./bun-pty-bridge.mjs", import.meta.url));
388
+ const initialCols = this._coerceDimension(message?.cols, 120);
389
+ const initialRows = this._coerceDimension(message?.rows, 30);
390
+ const config = JSON.stringify({
391
+ file: bin,
392
+ args,
393
+ cwd: launchConfig.cwd,
394
+ env: launchConfig.env,
395
+ cols: initialCols,
396
+ rows: initialRows,
397
+ term: launchConfig.env.TERM || "xterm-256color"
398
+ });
399
+
400
+ const proc = childSpawn("bun", [bridgePath], {
401
+ cwd: launchConfig.cwd,
402
+ env: {
403
+ ...launchConfig.env,
404
+ CODEX_WEBSTRAP_BUN_PTY_CONFIG: config
405
+ },
406
+ stdio: ["pipe", "pipe", "pipe"]
407
+ });
408
+
409
+ return {
410
+ mode: "bun-pty",
411
+ proc,
412
+ bunStdoutBuffer: "",
413
+ cols: initialCols,
414
+ rows: initialRows
415
+ };
416
+ } catch (error) {
417
+ this.bunPtyAvailable = false;
418
+ if (!this.loggedBunPtyFailure) {
419
+ this.loggedBunPtyFailure = true;
420
+ this.logger.warn("Bun PTY unavailable; falling back", {
421
+ error: toErrorMessage(error)
422
+ });
423
+ }
424
+ return null;
425
+ }
426
+ }
427
+
428
+ _spawnPythonPtyRuntime(launchConfig) {
429
+ if (!this.pythonPtyAvailable) {
430
+ return null;
431
+ }
432
+
433
+ const [bin, ...args] = launchConfig.command;
434
+ try {
435
+ const proc = childSpawn(
436
+ "python3",
437
+ [
438
+ "-c",
439
+ "import pty, sys; pty.spawn(sys.argv[1:])",
440
+ bin,
441
+ ...args
442
+ ],
443
+ {
444
+ cwd: launchConfig.cwd,
445
+ env: launchConfig.env,
446
+ stdio: ["pipe", "pipe", "pipe"]
447
+ }
448
+ );
449
+ return {
450
+ mode: "python-pty",
451
+ proc,
452
+ cols: 120,
453
+ rows: 30
454
+ };
455
+ } catch (error) {
456
+ this.pythonPtyAvailable = false;
457
+ if (!this.loggedPythonPtyFailure) {
458
+ this.loggedPythonPtyFailure = true;
459
+ this.logger.warn("python3 PTY fallback unavailable; using pipe terminal", {
460
+ error: toErrorMessage(error)
461
+ });
462
+ }
463
+ return null;
464
+ }
465
+ }
466
+
467
+ _attachBunPtyProcess(sessionId, session) {
468
+ const handleBridgeLine = (line) => {
469
+ const parsed = safeJsonParse(line);
470
+ if (!parsed || typeof parsed !== "object") {
471
+ return;
472
+ }
473
+
474
+ if (parsed.type === "data") {
475
+ this._broadcast(sessionId, {
476
+ type: "terminal-data",
477
+ sessionId,
478
+ data: typeof parsed.data === "string" ? parsed.data : ""
479
+ });
480
+ return;
481
+ }
482
+
483
+ if (parsed.type === "error") {
484
+ this._broadcast(sessionId, {
485
+ type: "terminal-error",
486
+ sessionId,
487
+ message: typeof parsed.message === "string" ? parsed.message : "bun-pty bridge error"
488
+ });
489
+ return;
490
+ }
491
+
492
+ if (parsed.type === "exit") {
493
+ this._broadcast(sessionId, {
494
+ type: "terminal-exit",
495
+ sessionId,
496
+ code: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
497
+ signal: parsed.signal ?? null
498
+ });
499
+ this.sessions.delete(sessionId);
500
+ }
501
+ };
502
+
503
+ session.proc.stdout?.on("data", (chunk) => {
504
+ session.bunStdoutBuffer += chunk.toString("utf8");
505
+ for (;;) {
506
+ const newlineAt = session.bunStdoutBuffer.indexOf("\n");
507
+ if (newlineAt < 0) {
508
+ break;
509
+ }
510
+ const line = session.bunStdoutBuffer.slice(0, newlineAt);
511
+ session.bunStdoutBuffer = session.bunStdoutBuffer.slice(newlineAt + 1);
512
+ if (line.trim().length === 0) {
513
+ continue;
514
+ }
515
+ handleBridgeLine(line);
516
+ }
517
+ });
518
+
519
+ session.proc.stderr?.on("data", (chunk) => {
520
+ this._broadcast(sessionId, {
521
+ type: "terminal-error",
522
+ sessionId,
523
+ message: chunk.toString("utf8")
524
+ });
525
+ });
526
+
527
+ session.proc.on("error", (error) => {
528
+ this._broadcast(sessionId, {
529
+ type: "terminal-error",
530
+ sessionId,
531
+ message: toErrorMessage(error)
532
+ });
533
+ });
534
+
535
+ session.proc.on("exit", (code, signal) => {
536
+ if (!this.sessions.has(sessionId)) {
537
+ return;
538
+ }
539
+ this._broadcast(sessionId, {
540
+ type: "terminal-exit",
541
+ sessionId,
542
+ code,
543
+ signal
544
+ });
545
+ this.sessions.delete(sessionId);
546
+ });
547
+ }
548
+
549
+ _writeToBunPty(session, payload) {
550
+ if (!session?.proc?.stdin || session.proc.stdin.destroyed) {
551
+ return;
552
+ }
553
+ session.proc.stdin.write(`${JSON.stringify(payload)}\n`);
554
+ }
555
+
556
+ _detectBunPtyAvailability() {
557
+ const hasBun = childSpawnSync("bun", ["--version"], { stdio: "ignore" });
558
+ if (hasBun.status !== 0 || hasBun.error) {
559
+ return false;
560
+ }
561
+
562
+ const probe = childSpawnSync(
563
+ "bun",
564
+ ["-e", "import 'bun-pty';"],
565
+ { stdio: "ignore" }
566
+ );
567
+ return probe.status === 0 && !probe.error;
568
+ }
569
+
570
+ _detectPythonPtyAvailability() {
571
+ const probe = childSpawnSync(
572
+ "python3",
573
+ ["-c", "import pty"],
574
+ { stdio: "ignore" }
575
+ );
576
+ return probe.status === 0 && !probe.error;
577
+ }
578
+
579
+ _buildEnv(messageEnv) {
580
+ const env = {
581
+ ...process.env,
582
+ ...(messageEnv && typeof messageEnv === "object" ? messageEnv : {})
583
+ };
584
+
585
+ if (!env.TERM || env.TERM === "dumb") {
586
+ env.TERM = "xterm-256color";
587
+ }
588
+ if (!env.COLORTERM) {
589
+ env.COLORTERM = "truecolor";
590
+ }
591
+ if (!env.TERM_PROGRAM) {
592
+ env.TERM_PROGRAM = "codex-webstrapper";
593
+ }
594
+ return env;
595
+ }
596
+
597
+ _resolveShellPath(messageShell) {
598
+ if (typeof messageShell === "string" && messageShell.trim().length > 0) {
599
+ return messageShell.trim();
600
+ }
601
+ return process.env.SHELL || "/bin/zsh";
602
+ }
603
+
604
+ _defaultShellCommand(shellPath) {
605
+ const shellName = path.basename(shellPath).toLowerCase();
606
+ const disableProfileLoad = process.env.CODEX_WEBSTRAP_TERMINAL_NO_PROFILE === "1";
607
+ const preferLoginProfile = this.hasPtyLikeRuntime && !disableProfileLoad;
608
+
609
+ if (shellName === "zsh") {
610
+ return preferLoginProfile
611
+ ? [shellPath, "-il"]
612
+ : [shellPath, "-fi"];
613
+ }
614
+ if (shellName === "bash") {
615
+ return preferLoginProfile
616
+ ? [shellPath, "-il"]
617
+ : [shellPath, "--noprofile", "--norc", "-i"];
618
+ }
619
+ if (shellName === "fish") {
620
+ return preferLoginProfile
621
+ ? [shellPath, "-il"]
622
+ : [shellPath, "-i"];
623
+ }
624
+ return [shellPath, "-i"];
625
+ }
626
+
627
+ _resolveCwd(requestedCwd) {
628
+ const requested = requestedCwd && requestedCwd.length > 0
629
+ ? path.resolve(requestedCwd)
630
+ : null;
631
+ if (requested && this._isDirectory(requested)) {
632
+ return {
633
+ cwd: requested,
634
+ requestedCwd: requested,
635
+ cwdWasFallback: false
636
+ };
637
+ }
638
+
639
+ const fallbackCandidates = [process.cwd(), os.homedir(), "/"];
640
+ const fallback = fallbackCandidates.find((candidate) => this._isDirectory(candidate)) || process.cwd();
641
+ return {
642
+ cwd: fallback,
643
+ requestedCwd: requested,
644
+ cwdWasFallback: Boolean(requested)
645
+ };
646
+ }
647
+
648
+ _isDirectory(candidatePath) {
649
+ if (!candidatePath || typeof candidatePath !== "string") {
650
+ return false;
651
+ }
652
+ try {
653
+ return statSync(candidatePath).isDirectory();
654
+ } catch {
655
+ return false;
656
+ }
657
+ }
658
+
659
+ _coerceDimension(value, fallback) {
660
+ const parsed = Number(value);
661
+ if (!Number.isFinite(parsed) || parsed <= 0) {
662
+ return fallback;
663
+ }
664
+ return Math.max(1, Math.floor(parsed));
665
+ }
666
+
667
+ _hasExplicitDimensions(message) {
668
+ const cols = Number(message?.cols);
669
+ const rows = Number(message?.rows);
670
+ return Number.isFinite(cols) && cols > 0 && Number.isFinite(rows) && rows > 0;
671
+ }
207
672
  }
208
673
 
209
674
  class GitWorkerBridge {
@@ -543,7 +1008,7 @@ export class MessageRouter {
543
1008
  this.terminals.write(message.sessionId, message.data || "");
544
1009
  return;
545
1010
  case "terminal-resize":
546
- this.terminals.resize(message.sessionId);
1011
+ this.terminals.resize(message.sessionId, message.cols, message.rows);
547
1012
  return;
548
1013
  case "terminal-close":
549
1014
  this.terminals.close(message.sessionId);
@@ -753,14 +1218,25 @@ export class MessageRouter {
753
1218
  this.logger.debug("renderer-fetch", {
754
1219
  requestId,
755
1220
  method: message.method || "GET",
756
- url: message.url || null,
757
- body: typeof message.body === "string" ? message.body.slice(0, 400) : null
1221
+ url: this._sanitizeUrlForLogs(message.url),
1222
+ hasBody: message.body != null
758
1223
  });
759
1224
 
760
1225
  if (await this._handleVirtualFetch(ws, requestId, message)) {
761
1226
  return;
762
1227
  }
763
1228
 
1229
+ if (typeof message.url === "string" && message.url === "/transcribe") {
1230
+ const controller = new AbortController();
1231
+ this.fetchControllers.set(requestId, controller);
1232
+ try {
1233
+ await this._handleTranscribeFetch(ws, requestId, message, controller.signal);
1234
+ } finally {
1235
+ this.fetchControllers.delete(requestId);
1236
+ }
1237
+ return;
1238
+ }
1239
+
764
1240
  const resolvedUrl = this._resolveFetchUrl(message.url);
765
1241
  if (!resolvedUrl) {
766
1242
  this.sendMainMessage(ws, {
@@ -772,7 +1248,7 @@ export class MessageRouter {
772
1248
  });
773
1249
  this.logger.warn("renderer-fetch-failed", {
774
1250
  requestId,
775
- url: message.url || null,
1251
+ url: this._sanitizeUrlForLogs(message.url),
776
1252
  error: "unsupported_fetch_url"
777
1253
  });
778
1254
  return;
@@ -782,10 +1258,11 @@ export class MessageRouter {
782
1258
  this.fetchControllers.set(requestId, controller);
783
1259
 
784
1260
  try {
1261
+ const outbound = this._prepareOutgoingFetchRequest(message);
785
1262
  const response = await fetch(resolvedUrl, {
786
- method: message.method || "GET",
787
- headers: message.headers || {},
788
- body: message.body,
1263
+ method: outbound.method,
1264
+ headers: outbound.headers,
1265
+ body: outbound.body,
789
1266
  signal: controller.signal
790
1267
  });
791
1268
 
@@ -814,7 +1291,7 @@ export class MessageRouter {
814
1291
  requestId,
815
1292
  status: response.status,
816
1293
  ok: response.ok,
817
- url: response.url || resolvedUrl
1294
+ url: this._sanitizeUrlForLogs(response.url || resolvedUrl)
818
1295
  });
819
1296
  } catch (error) {
820
1297
  this.sendMainMessage(ws, {
@@ -826,7 +1303,7 @@ export class MessageRouter {
826
1303
  });
827
1304
  this.logger.warn("renderer-fetch-failed", {
828
1305
  requestId,
829
- url: resolvedUrl,
1306
+ url: this._sanitizeUrlForLogs(resolvedUrl),
830
1307
  error: toErrorMessage(error)
831
1308
  });
832
1309
  } finally {
@@ -834,6 +1311,271 @@ export class MessageRouter {
834
1311
  }
835
1312
  }
836
1313
 
1314
+ async _handleTranscribeFetch(ws, requestId, message, signal) {
1315
+ try {
1316
+ const outbound = this._prepareOutgoingFetchRequest(message);
1317
+ const bodyBuffer = this._asBuffer(outbound.body);
1318
+ const contentType = this._readHeader(outbound.headers, "content-type");
1319
+ const boundary = this._extractMultipartBoundary(contentType);
1320
+ if (!boundary) {
1321
+ throw new Error("Missing multipart boundary for /transcribe request.");
1322
+ }
1323
+
1324
+ const { fields, files } = this._parseMultipartBody(bodyBuffer, boundary);
1325
+ const file = files.find((part) => part.name === "file") || files[0];
1326
+ if (!file || !file.data || file.data.length === 0) {
1327
+ throw new Error("Missing audio file in /transcribe request.");
1328
+ }
1329
+
1330
+ const authToken = await this._resolveTranscriptionAuthToken();
1331
+ if (!authToken) {
1332
+ throw new Error("Dictation requires ChatGPT authentication in Codex.");
1333
+ }
1334
+
1335
+ const model = process.env.CODEX_WEBSTRAP_TRANSCRIBE_MODEL || "gpt-4o-mini-transcribe";
1336
+ const form = new FormData();
1337
+ form.append("model", model);
1338
+ if (typeof fields.language === "string" && fields.language.trim().length > 0) {
1339
+ form.append("language", fields.language.trim());
1340
+ }
1341
+ form.append(
1342
+ "file",
1343
+ new Blob([file.data], { type: file.contentType || "audio/webm" }),
1344
+ file.filename || "codex.webm"
1345
+ );
1346
+
1347
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
1348
+ method: "POST",
1349
+ headers: {
1350
+ Authorization: `Bearer ${authToken}`
1351
+ },
1352
+ body: form,
1353
+ signal
1354
+ });
1355
+ const responseText = await response.text();
1356
+
1357
+ let bodyJsonString = responseText;
1358
+ if (response.ok) {
1359
+ const parsed = safeJsonParse(responseText);
1360
+ if (parsed && typeof parsed === "object" && typeof parsed.text === "string") {
1361
+ bodyJsonString = JSON.stringify({ text: parsed.text });
1362
+ } else {
1363
+ bodyJsonString = JSON.stringify({ text: "" });
1364
+ }
1365
+ }
1366
+
1367
+ this.sendMainMessage(ws, {
1368
+ type: "fetch-response",
1369
+ requestId,
1370
+ responseType: "success",
1371
+ status: response.status,
1372
+ headers: {
1373
+ "content-type": response.headers.get("content-type") || "application/json"
1374
+ },
1375
+ bodyJsonString
1376
+ });
1377
+ } catch (error) {
1378
+ this.sendMainMessage(ws, {
1379
+ type: "fetch-response",
1380
+ requestId,
1381
+ responseType: "error",
1382
+ status: 0,
1383
+ error: toErrorMessage(error)
1384
+ });
1385
+ this.logger.warn("transcribe-fetch-failed", {
1386
+ requestId,
1387
+ error: toErrorMessage(error)
1388
+ });
1389
+ }
1390
+ }
1391
+
1392
+ _prepareOutgoingFetchRequest(message) {
1393
+ const method = message?.method || "GET";
1394
+ const headers = message?.headers && typeof message.headers === "object"
1395
+ ? { ...message.headers }
1396
+ : {};
1397
+ let body = message?.body;
1398
+
1399
+ const base64Marker = this._readHeader(headers, "x-codex-base64");
1400
+ if (base64Marker === "1") {
1401
+ this._deleteHeader(headers, "x-codex-base64");
1402
+ if (typeof body !== "string") {
1403
+ throw new Error("X-Codex-Base64 fetch body must be a base64 string.");
1404
+ }
1405
+ body = Buffer.from(body, "base64");
1406
+ }
1407
+
1408
+ return {
1409
+ method,
1410
+ headers,
1411
+ body
1412
+ };
1413
+ }
1414
+
1415
+ _asBuffer(body) {
1416
+ if (Buffer.isBuffer(body)) {
1417
+ return body;
1418
+ }
1419
+ if (body instanceof Uint8Array) {
1420
+ return Buffer.from(body);
1421
+ }
1422
+ if (typeof body === "string") {
1423
+ return Buffer.from(body, "utf8");
1424
+ }
1425
+ if (body == null) {
1426
+ return Buffer.alloc(0);
1427
+ }
1428
+ return Buffer.from(String(body), "utf8");
1429
+ }
1430
+
1431
+ _extractMultipartBoundary(contentType) {
1432
+ if (typeof contentType !== "string") {
1433
+ return null;
1434
+ }
1435
+ const match = contentType.match(/boundary=([^;]+)/i);
1436
+ if (!match || !match[1]) {
1437
+ return null;
1438
+ }
1439
+ return match[1].trim().replace(/^"|"$/g, "");
1440
+ }
1441
+
1442
+ _parseMultipartBody(body, boundary) {
1443
+ const files = [];
1444
+ const fields = {};
1445
+ const delimiter = Buffer.from(`--${boundary}`);
1446
+ const partSeparator = Buffer.from("\r\n\r\n");
1447
+
1448
+ let cursor = 0;
1449
+ for (;;) {
1450
+ const start = body.indexOf(delimiter, cursor);
1451
+ if (start < 0) {
1452
+ break;
1453
+ }
1454
+
1455
+ cursor = start + delimiter.length;
1456
+ if (body[cursor] === 45 && body[cursor + 1] === 45) {
1457
+ break;
1458
+ }
1459
+ if (body[cursor] === 13 && body[cursor + 1] === 10) {
1460
+ cursor += 2;
1461
+ }
1462
+
1463
+ const headerEnd = body.indexOf(partSeparator, cursor);
1464
+ if (headerEnd < 0) {
1465
+ break;
1466
+ }
1467
+
1468
+ const headersText = body.slice(cursor, headerEnd).toString("utf8");
1469
+ const contentStart = headerEnd + partSeparator.length;
1470
+ const nextBoundary = body.indexOf(Buffer.from(`\r\n--${boundary}`), contentStart);
1471
+ const contentEnd = nextBoundary >= 0 ? nextBoundary : body.length;
1472
+ const content = body.slice(contentStart, contentEnd);
1473
+
1474
+ const headers = {};
1475
+ for (const line of headersText.split("\r\n")) {
1476
+ const splitAt = line.indexOf(":");
1477
+ if (splitAt <= 0) {
1478
+ continue;
1479
+ }
1480
+ const key = line.slice(0, splitAt).trim().toLowerCase();
1481
+ const value = line.slice(splitAt + 1).trim();
1482
+ headers[key] = value;
1483
+ }
1484
+
1485
+ const disposition = headers["content-disposition"] || "";
1486
+ const nameMatch = disposition.match(/\bname="([^"]+)"/i);
1487
+ const filenameMatch = disposition.match(/\bfilename="([^"]+)"/i);
1488
+ const name = nameMatch ? nameMatch[1] : null;
1489
+ const filename = filenameMatch ? filenameMatch[1] : null;
1490
+ const part = {
1491
+ name,
1492
+ filename,
1493
+ contentType: headers["content-type"] || null,
1494
+ data: content
1495
+ };
1496
+
1497
+ if (filename) {
1498
+ files.push(part);
1499
+ } else if (name) {
1500
+ fields[name] = content.toString("utf8");
1501
+ }
1502
+
1503
+ cursor = contentEnd + 2;
1504
+ }
1505
+
1506
+ return { fields, files };
1507
+ }
1508
+
1509
+ async _resolveTranscriptionAuthToken() {
1510
+ if (!this.appServer) {
1511
+ return null;
1512
+ }
1513
+ try {
1514
+ const response = await this.appServer.sendRequest("getAuthStatus", {
1515
+ includeToken: true,
1516
+ refreshToken: true
1517
+ }, {
1518
+ timeoutMs: 10_000
1519
+ });
1520
+ const token = response?.result?.authToken;
1521
+ return typeof token === "string" && token.trim().length > 0 ? token.trim() : null;
1522
+ } catch (error) {
1523
+ this.logger.warn("Failed to resolve transcription auth token", {
1524
+ error: toErrorMessage(error)
1525
+ });
1526
+ return null;
1527
+ }
1528
+ }
1529
+
1530
+ _readHeader(headers, name) {
1531
+ if (!headers || typeof headers !== "object") {
1532
+ return null;
1533
+ }
1534
+
1535
+ const target = String(name).toLowerCase();
1536
+ for (const [key, value] of Object.entries(headers)) {
1537
+ if (String(key).toLowerCase() !== target) {
1538
+ continue;
1539
+ }
1540
+ if (Array.isArray(value)) {
1541
+ return value.length > 0 ? String(value[0]) : "";
1542
+ }
1543
+ return value == null ? null : String(value);
1544
+ }
1545
+ return null;
1546
+ }
1547
+
1548
+ _deleteHeader(headers, name) {
1549
+ if (!headers || typeof headers !== "object") {
1550
+ return;
1551
+ }
1552
+
1553
+ const target = String(name).toLowerCase();
1554
+ for (const key of Object.keys(headers)) {
1555
+ if (String(key).toLowerCase() === target) {
1556
+ delete headers[key];
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ _sanitizeUrlForLogs(url) {
1562
+ if (typeof url !== "string" || url.length === 0) {
1563
+ return null;
1564
+ }
1565
+
1566
+ // Drop query params and fragments from logs to avoid leaking tokens/user data.
1567
+ const withoutQuery = url.split("?")[0]?.split("#")[0] ?? "";
1568
+ if (withoutQuery.startsWith("http://") || withoutQuery.startsWith("https://")) {
1569
+ try {
1570
+ const parsed = new URL(withoutQuery);
1571
+ return `${parsed.origin}${parsed.pathname}`;
1572
+ } catch {
1573
+ return withoutQuery;
1574
+ }
1575
+ }
1576
+ return withoutQuery;
1577
+ }
1578
+
837
1579
  _resolveFetchUrl(url) {
838
1580
  if (typeof url !== "string" || url.length === 0) {
839
1581
  return null;
@@ -1883,7 +2625,7 @@ export class MessageRouter {
1883
2625
 
1884
2626
  async _runCommand(command, args, { timeoutMs = 5_000, allowNonZero = false, cwd = process.cwd() } = {}) {
1885
2627
  return new Promise((resolve) => {
1886
- const child = spawn(command, args, {
2628
+ const child = childSpawn(command, args, {
1887
2629
  cwd,
1888
2630
  env: process.env,
1889
2631
  stdio: ["ignore", "pipe", "pipe"]
@@ -1958,7 +2700,7 @@ export class MessageRouter {
1958
2700
  requestId,
1959
2701
  status,
1960
2702
  ok: status >= 200 && status < 300,
1961
- url
2703
+ url: this._sanitizeUrlForLogs(url)
1962
2704
  });
1963
2705
  }
1964
2706
 
@@ -2471,7 +3213,7 @@ export class MessageRouter {
2471
3213
  return;
2472
3214
  }
2473
3215
 
2474
- const child = spawn("open", [url], {
3216
+ const child = childSpawn("open", [url], {
2475
3217
  stdio: ["ignore", "ignore", "ignore"],
2476
3218
  detached: true
2477
3219
  });