kanna-code 0.32.4 → 0.33.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.
@@ -11,10 +11,15 @@ afterEach(async () => {
11
11
  await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
12
12
  })
13
13
 
14
- async function startPasswordServer() {
14
+ async function startPasswordServer(options: { trustProxy?: boolean; port?: number } = {}) {
15
15
  const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-test-"))
16
16
  tempDirs.push(projectDir)
17
- const server = await startKannaServer({ port: 4320, strictPort: true, password: "secret" })
17
+ const server = await startKannaServer({
18
+ port: options.port ?? 4320,
19
+ strictPort: true,
20
+ password: "secret",
21
+ trustProxy: options.trustProxy ?? false,
22
+ })
18
23
  const project = await server.store.openProject(projectDir, "Project")
19
24
  return { server, projectDir, project }
20
25
  }
@@ -26,13 +31,14 @@ function extractCookie(response: Response) {
26
31
  }
27
32
 
28
33
  describe("password auth", () => {
29
- test("redirects unauthenticated html requests to the login page", async () => {
34
+ test("serves the app shell to unauthenticated browser requests", async () => {
30
35
  const { server } = await startPasswordServer()
31
36
 
32
37
  try {
33
- const response = await fetch(`http://localhost:${server.port}/`, { redirect: "manual", headers: { Accept: "text/html" } })
34
- expect(response.status).toBe(302)
35
- expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
38
+ const response = await fetch(`http://localhost:${server.port}/chat/demo`, { headers: { Accept: "text/html" } })
39
+ expect(response.status).toBe(200)
40
+ expect(response.headers.get("content-type")).toContain("text/html")
41
+ expect(await response.text()).toContain('id="root"')
36
42
  } finally {
37
43
  await server.stop()
38
44
  }
@@ -49,14 +55,13 @@ describe("password auth", () => {
49
55
  }
50
56
  })
51
57
 
52
- test("serves the login page without authentication", async () => {
58
+ test("redirects /auth/login back into the app", async () => {
53
59
  const { server } = await startPasswordServer()
54
60
 
55
61
  try {
56
- const response = await fetch(`http://localhost:${server.port}/auth/login`)
57
- expect(response.status).toBe(200)
58
- expect(response.headers.get("content-type")).toContain("text/html")
59
- expect(await response.text()).toContain("This server is password protected")
62
+ const response = await fetch(`http://localhost:${server.port}/auth/login?next=%2Fchat%2Fdemo`, { redirect: "manual" })
63
+ expect(response.status).toBe(302)
64
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/chat/demo`)
60
65
  } finally {
61
66
  await server.stop()
62
67
  }
@@ -66,20 +71,16 @@ describe("password auth", () => {
66
71
  const { server } = await startPasswordServer()
67
72
 
68
73
  try {
69
- const formData = new FormData()
70
- formData.append("password", "secret")
71
- formData.append("next", "/")
72
74
  const response = await fetch(`http://localhost:${server.port}/auth/login`, {
73
75
  method: "POST",
74
- body: formData,
75
- redirect: "manual",
76
+ body: JSON.stringify({ password: "secret", next: "/" }),
76
77
  headers: {
78
+ "Content-Type": "application/json",
77
79
  Origin: `http://localhost:${server.port}`,
78
80
  },
79
81
  })
80
82
 
81
- expect(response.status).toBe(302)
82
- expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
83
+ expect(response.status).toBe(200)
83
84
  expect(extractCookie(response)).toContain("kanna_session=")
84
85
  } finally {
85
86
  await server.stop()
@@ -90,19 +91,16 @@ describe("password auth", () => {
90
91
  const { server } = await startPasswordServer()
91
92
 
92
93
  try {
93
- const formData = new FormData()
94
- formData.append("password", "wrong")
95
94
  const response = await fetch(`http://localhost:${server.port}/auth/login`, {
96
95
  method: "POST",
97
- body: formData,
98
- redirect: "manual",
96
+ body: JSON.stringify({ password: "wrong", next: "/" }),
99
97
  headers: {
98
+ "Content-Type": "application/json",
100
99
  Origin: `http://localhost:${server.port}`,
101
100
  },
102
101
  })
103
102
 
104
- expect(response.status).toBe(302)
105
- expect(response.headers.get("location")).toContain("/auth/login?error=1")
103
+ expect(response.status).toBe(401)
106
104
  expect(response.headers.get("set-cookie")).toBeNull()
107
105
  } finally {
108
106
  await server.stop()
@@ -169,6 +167,115 @@ describe("password auth", () => {
169
167
  }
170
168
  })
171
169
 
170
+ test("ignores forwarded proto when trustProxy is off", async () => {
171
+ const { server } = await startPasswordServer({ port: 54321 })
172
+
173
+ try {
174
+ const response = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
175
+ redirect: "manual",
176
+ headers: {
177
+ "X-Forwarded-Proto": "https",
178
+ },
179
+ })
180
+ expect(response.status).toBe(302)
181
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
182
+
183
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
184
+ method: "POST",
185
+ body: JSON.stringify({ password: "secret", next: "/" }),
186
+ headers: {
187
+ "Content-Type": "application/json",
188
+ Origin: "https://evil.test",
189
+ "X-Forwarded-Proto": "https",
190
+ },
191
+ })
192
+ expect(loginResponse.status).toBe(403)
193
+
194
+ const goodLoginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
195
+ method: "POST",
196
+ body: JSON.stringify({ password: "secret", next: "/" }),
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ Origin: `http://localhost:${server.port}`,
200
+ "X-Forwarded-Proto": "https",
201
+ },
202
+ })
203
+ expect(goodLoginResponse.status).toBe(200)
204
+ const cookieHeader = goodLoginResponse.headers.get("set-cookie") ?? ""
205
+ expect(cookieHeader).not.toContain("Secure")
206
+ } finally {
207
+ await server.stop()
208
+ }
209
+ })
210
+
211
+ test("honors forwarded proto when trustProxy is on", async () => {
212
+ const { server } = await startPasswordServer({ port: 54322, trustProxy: true })
213
+
214
+ try {
215
+ const redirect = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
216
+ redirect: "manual",
217
+ headers: {
218
+ "X-Forwarded-Proto": "https",
219
+ },
220
+ })
221
+ expect(redirect.status).toBe(302)
222
+ expect(redirect.headers.get("location")).toBe(`https://localhost:${server.port}/`)
223
+
224
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
225
+ method: "POST",
226
+ body: JSON.stringify({ password: "secret", next: "/" }),
227
+ headers: {
228
+ "Content-Type": "application/json",
229
+ Origin: `https://localhost:${server.port}`,
230
+ "X-Forwarded-Proto": "https",
231
+ },
232
+ })
233
+ expect(loginResponse.status).toBe(200)
234
+ expect(loginResponse.headers.get("set-cookie") ?? "").toContain("Secure")
235
+
236
+ const evilResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
237
+ method: "POST",
238
+ body: JSON.stringify({ password: "secret", next: "/" }),
239
+ headers: {
240
+ "Content-Type": "application/json",
241
+ Origin: `http://localhost:${server.port}`,
242
+ },
243
+ })
244
+ expect(evilResponse.status).toBe(200)
245
+ } finally {
246
+ await server.stop()
247
+ }
248
+ })
249
+
250
+ test("ignores invalid forwarded proto values", async () => {
251
+ const { server } = await startPasswordServer({ port: 54323, trustProxy: true })
252
+
253
+ try {
254
+ const redirect = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
255
+ redirect: "manual",
256
+ headers: {
257
+ "X-Forwarded-Proto": "ftp",
258
+ },
259
+ })
260
+ expect(redirect.status).toBe(302)
261
+ expect(redirect.headers.get("location")).toBe(`http://localhost:${server.port}/`)
262
+
263
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
264
+ method: "POST",
265
+ body: JSON.stringify({ password: "secret", next: "/" }),
266
+ headers: {
267
+ "Content-Type": "application/json",
268
+ Origin: `http://localhost:${server.port}`,
269
+ "X-Forwarded-Proto": "ftp",
270
+ },
271
+ })
272
+ expect(loginResponse.status).toBe(200)
273
+ expect(loginResponse.headers.get("set-cookie") ?? "").not.toContain("Secure")
274
+ } finally {
275
+ await server.stop()
276
+ }
277
+ })
278
+
172
279
  test("clears the session cookie on logout", async () => {
173
280
  const { server } = await startPasswordServer()
174
281
 
@@ -1,5 +1,4 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto"
2
- import { APP_NAME } from "../shared/branding"
3
2
 
4
3
  const SESSION_COOKIE_NAME = "kanna_session"
5
4
 
@@ -9,17 +8,12 @@ export interface AuthStatusPayload {
9
8
  }
10
9
 
11
10
  export interface AuthManager {
12
- readonly enabled: true
13
11
  isAuthenticated(req: Request): boolean
14
12
  validateOrigin(req: Request): boolean
15
- createSessionCookie(req: Request): string
16
- clearSessionCookie(req: Request): string
17
- verifyPassword(candidate: string): boolean
13
+ redirectToApp(req: Request): Response
18
14
  handleLogin(req: Request, nextPath: string): Promise<Response>
19
15
  handleLogout(req: Request): Response
20
16
  handleStatus(req: Request): Response
21
- renderLoginPage(req: Request): Response
22
- unauthorizedResponse(req: Request): Response
23
17
  }
24
18
 
25
19
  function parseCookies(header: string | null) {
@@ -47,11 +41,30 @@ function sanitizeNextPath(nextPath: string | null | undefined) {
47
41
  return nextPath
48
42
  }
49
43
 
50
- function shouldUseSecureCookie(req: Request) {
44
+ function forwardedProto(req: Request): "http" | "https" | null {
45
+ const xfp = req.headers.get("x-forwarded-proto")
46
+ if (!xfp) return null
47
+ const value = xfp.split(",")[0]?.trim().toLowerCase()
48
+ return value === "http" || value === "https" ? value : null
49
+ }
50
+
51
+ function effectiveOrigin(req: Request, trustProxy: boolean): string {
52
+ const url = new URL(req.url)
53
+ if (!trustProxy) return url.origin
54
+ const proto = forwardedProto(req)
55
+ const scheme = proto ?? url.protocol.replace(":", "")
56
+ return `${scheme}://${url.host}`
57
+ }
58
+
59
+ function shouldUseSecureCookie(req: Request, trustProxy: boolean) {
60
+ if (trustProxy) {
61
+ const proto = forwardedProto(req)
62
+ if (proto) return proto === "https"
63
+ }
51
64
  return new URL(req.url).protocol === "https:"
52
65
  }
53
66
 
54
- function buildCookie(name: string, value: string, req: Request, extras: string[] = []) {
67
+ function buildCookie(name: string, value: string, req: Request, trustProxy: boolean, extras: string[] = []) {
55
68
  const parts = [
56
69
  `${name}=${encodeURIComponent(value)}`,
57
70
  "Path=/",
@@ -59,7 +72,7 @@ function buildCookie(name: string, value: string, req: Request, extras: string[]
59
72
  "SameSite=Strict",
60
73
  ]
61
74
 
62
- if (shouldUseSecureCookie(req)) {
75
+ if (shouldUseSecureCookie(req, trustProxy)) {
63
76
  parts.push("Secure")
64
77
  }
65
78
 
@@ -75,7 +88,6 @@ async function readLoginForm(req: Request) {
75
88
  return {
76
89
  password: typeof payload.password === "string" ? payload.password : "",
77
90
  nextPath: sanitizeNextPath(typeof payload.next === "string" ? payload.next : "/"),
78
- wantsJson: true,
79
91
  }
80
92
  }
81
93
 
@@ -83,27 +95,26 @@ async function readLoginForm(req: Request) {
83
95
  return {
84
96
  password: String(formData.get("password") ?? ""),
85
97
  nextPath: sanitizeNextPath(String(formData.get("next") ?? "/")),
86
- wantsJson: false,
87
98
  }
88
99
  }
89
100
 
90
- function requestWantsHtml(req: Request) {
91
- const accept = req.headers.get("accept") ?? ""
92
- return accept.includes("text/html")
93
- }
94
-
95
- function escapeHtml(value: string) {
96
- return value
97
- .replaceAll("&", "&amp;")
98
- .replaceAll("<", "&lt;")
99
- .replaceAll(">", "&gt;")
100
- .replaceAll("\"", "&quot;")
101
- .replaceAll("'", "&#39;")
101
+ export interface AuthManagerOptions {
102
+ /**
103
+ * When true, the auth layer trusts X-Forwarded-Proto to decide whether the
104
+ * public origin is http or https. The hostname always comes from the Host
105
+ * header (never X-Forwarded-Host) because X-Forwarded-Host is passed
106
+ * through by some tunnels unmodified and would otherwise allow open
107
+ * redirects.
108
+ * Enable only when the server is reachable solely through a trusted reverse
109
+ * proxy such as cloudflared.
110
+ */
111
+ trustProxy?: boolean
102
112
  }
103
113
 
104
- export function createAuthManager(password: string): AuthManager {
114
+ export function createAuthManager(password: string, options: AuthManagerOptions = {}): AuthManager {
105
115
  const sessions = new Set<string>()
106
116
  const expectedPassword = Buffer.from(password)
117
+ const trustProxy = options.trustProxy ?? false
107
118
 
108
119
  function getSessionToken(req: Request) {
109
120
  return parseCookies(req.headers.get("cookie")).get(SESSION_COOKIE_NAME) ?? null
@@ -117,13 +128,15 @@ export function createAuthManager(password: string): AuthManager {
117
128
  function validateOrigin(req: Request) {
118
129
  const origin = req.headers.get("origin")
119
130
  if (!origin) return true
120
- return origin === new URL(req.url).origin
131
+ if (origin === new URL(req.url).origin) return true
132
+ if (!trustProxy) return false
133
+ return origin === effectiveOrigin(req, trustProxy)
121
134
  }
122
135
 
123
136
  function createSessionCookie(req: Request) {
124
137
  const sessionToken = randomBytes(32).toString("base64url")
125
138
  sessions.add(sessionToken)
126
- return buildCookie(SESSION_COOKIE_NAME, sessionToken, req)
139
+ return buildCookie(SESSION_COOKIE_NAME, sessionToken, req, trustProxy)
127
140
  }
128
141
 
129
142
  function clearSessionCookie(req: Request) {
@@ -131,7 +144,7 @@ export function createAuthManager(password: string): AuthManager {
131
144
  if (sessionToken) {
132
145
  sessions.delete(sessionToken)
133
146
  }
134
- return buildCookie(SESSION_COOKIE_NAME, "", req, ["Max-Age=0"])
147
+ return buildCookie(SESSION_COOKIE_NAME, "", req, trustProxy, ["Max-Age=0"])
135
148
  }
136
149
 
137
150
  function verifyPassword(candidate: string) {
@@ -149,108 +162,9 @@ export function createAuthManager(password: string): AuthManager {
149
162
  } satisfies AuthStatusPayload)
150
163
  }
151
164
 
152
- function unauthorizedResponse(req: Request) {
153
- if (req.method === "GET" && requestWantsHtml(req)) {
154
- const url = new URL(req.url)
155
- const loginUrl = new URL("/auth/login", req.url)
156
- loginUrl.searchParams.set("next", sanitizeNextPath(`${url.pathname}${url.search}`))
157
- return Response.redirect(loginUrl, 302)
158
- }
159
-
160
- return Response.json({ error: "Unauthorized" }, { status: 401 })
161
- }
162
-
163
- function renderLoginPage(req: Request) {
164
- if (isAuthenticated(req)) {
165
- const currentUrl = new URL(req.url)
166
- return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), req.url), 302)
167
- }
168
-
165
+ function redirectToApp(req: Request) {
169
166
  const currentUrl = new URL(req.url)
170
- const nextPath = sanitizeNextPath(currentUrl.searchParams.get("next"))
171
- const showError = currentUrl.searchParams.get("error") === "1"
172
- const html = `<!doctype html>
173
- <html lang="en">
174
- <head>
175
- <meta charset="utf-8" />
176
- <meta name="viewport" content="width=device-width, initial-scale=1" />
177
- <title>${escapeHtml(APP_NAME)} Login</title>
178
- <style>
179
- :root { color-scheme: dark; }
180
- * { box-sizing: border-box; }
181
- body {
182
- margin: 0;
183
- min-height: 100vh;
184
- font-family: ui-sans-serif, system-ui, sans-serif;
185
- background:
186
- radial-gradient(circle at top, rgba(255,255,255,0.10), transparent 28%),
187
- linear-gradient(160deg, #16181d 0%, #0b0d10 55%, #050608 100%);
188
- color: #f4f7fb;
189
- display: grid;
190
- place-items: center;
191
- padding: 24px;
192
- }
193
- .card {
194
- width: min(420px, 100%);
195
- border: 1px solid rgba(255,255,255,0.12);
196
- background: rgba(12, 15, 20, 0.88);
197
- backdrop-filter: blur(10px);
198
- border-radius: 24px;
199
- padding: 28px;
200
- box-shadow: 0 24px 70px rgba(0,0,0,0.35);
201
- }
202
- h1 { margin: 0 0 8px; font-size: 28px; }
203
- p { margin: 0 0 20px; color: #a6b0bd; line-height: 1.5; }
204
- label { display: block; font-size: 13px; margin-bottom: 8px; color: #d7dce4; }
205
- input {
206
- width: 100%;
207
- border-radius: 14px;
208
- border: 1px solid rgba(255,255,255,0.16);
209
- background: rgba(255,255,255,0.04);
210
- color: inherit;
211
- padding: 14px 16px;
212
- font-size: 15px;
213
- margin-bottom: 16px;
214
- }
215
- button {
216
- width: 100%;
217
- border: 0;
218
- border-radius: 14px;
219
- padding: 14px 16px;
220
- background: linear-gradient(135deg, #f4ede0 0%, #d9c4a1 100%);
221
- color: #16181d;
222
- font-size: 15px;
223
- font-weight: 700;
224
- cursor: pointer;
225
- }
226
- .error {
227
- margin-bottom: 16px;
228
- border-radius: 14px;
229
- background: rgba(255, 106, 106, 0.12);
230
- border: 1px solid rgba(255, 106, 106, 0.24);
231
- color: #ffb8b8;
232
- padding: 12px 14px;
233
- font-size: 14px;
234
- }
235
- </style>
236
- </head>
237
- <body>
238
- <form class="card" method="post" action="/auth/login">
239
- <h1>${escapeHtml(APP_NAME)}</h1>
240
- <p>This server is password protected. Enter the launch password to continue.</p>
241
- ${showError ? '<div class="error">Incorrect password. Try again.</div>' : ""}
242
- <input type="hidden" name="next" value="${escapeHtml(nextPath)}" />
243
- <label for="password">Password</label>
244
- <input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
245
- <button type="submit">Unlock</button>
246
- </form>
247
- </body>
248
- </html>`
249
- return new Response(html, {
250
- headers: {
251
- "Content-Type": "text/html; charset=utf-8",
252
- },
253
- })
167
+ return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
254
168
  }
255
169
 
256
170
  async function handleLogin(req: Request, fallbackNextPath: string) {
@@ -258,21 +172,12 @@ export function createAuthManager(password: string): AuthManager {
258
172
  return Response.json({ error: "Forbidden" }, { status: 403 })
259
173
  }
260
174
 
261
- const { password: candidate, nextPath, wantsJson } = await readLoginForm(req)
175
+ const { password: candidate, nextPath } = await readLoginForm(req)
262
176
  if (!verifyPassword(candidate)) {
263
- if (wantsJson) {
264
- return Response.json({ error: "Invalid password" }, { status: 401 })
265
- }
266
-
267
- const redirectUrl = new URL("/auth/login", req.url)
268
- redirectUrl.searchParams.set("error", "1")
269
- redirectUrl.searchParams.set("next", sanitizeNextPath(nextPath || fallbackNextPath))
270
- return Response.redirect(redirectUrl, 302)
177
+ return Response.json({ error: "Invalid password" }, { status: 401 })
271
178
  }
272
179
 
273
- const response = wantsJson
274
- ? Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
275
- : Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), req.url), 302)
180
+ const response = Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
276
181
 
277
182
  response.headers.set("Set-Cookie", createSessionCookie(req))
278
183
  return response
@@ -289,16 +194,11 @@ export function createAuthManager(password: string): AuthManager {
289
194
  }
290
195
 
291
196
  return {
292
- enabled: true,
293
197
  isAuthenticated,
294
198
  validateOrigin,
295
- createSessionCookie,
296
- clearSessionCookie,
297
- verifyPassword,
199
+ redirectToApp,
298
200
  handleLogin,
299
201
  handleLogout,
300
202
  handleStatus,
301
- renderLoginPage,
302
- unauthorizedResponse,
303
203
  }
304
204
  }
@@ -27,6 +27,7 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
27
27
  share: false | "quick" | { kind: "token"; token: string }
28
28
  password: string | null
29
29
  strictPort: boolean
30
+ trustProxy?: boolean
30
31
  update: {
31
32
  version: string
32
33
  argv: string[]
@@ -290,6 +291,7 @@ describe("runCli", () => {
290
291
  share: false,
291
292
  password: null,
292
293
  strictPort: false,
294
+ trustProxy: false,
293
295
  update: {
294
296
  version: "0.3.0",
295
297
  argv: ["--port", "4000", "--no-open"],
@@ -356,6 +358,7 @@ describe("runCli", () => {
356
358
 
357
359
  expect(result.kind).toBe("started")
358
360
  expect(calls.openUrl).toEqual([])
361
+ expect(calls.startServer[0]?.trustProxy).toBe(true)
359
362
  expect(calls.shareTunnel).toEqual([{ localUrl: "http://localhost:4000", shareMode: "quick" }])
360
363
  expect(calls.renderShareQr).toEqual(["https://kanna.trycloudflare.com"])
361
364
  expect(calls.log).toContain("QR Code:")
@@ -452,6 +455,7 @@ describe("runCli", () => {
452
455
  const result = await runCli(["--cloudflared", "secret-token"], deps)
453
456
 
454
457
  expect(result.kind).toBe("started")
458
+ expect(calls.startServer[0]?.trustProxy).toBe(true)
455
459
  expect(calls.shareTunnel).toEqual([{
456
460
  localUrl: "http://localhost:3210",
457
461
  shareMode: { kind: "token", token: "secret-token" },
@@ -49,6 +49,7 @@ export interface CliRuntimeDeps {
49
49
  startServer: (options: CliOptions & {
50
50
  update: CliUpdateOptions
51
51
  onMigrationProgress?: (message: string) => void
52
+ trustProxy?: boolean
52
53
  }) => Promise<{ port: number; stop: () => Promise<void> }>
53
54
  fetchLatestVersion: (packageName: string) => Promise<string>
54
55
  installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
@@ -270,6 +271,7 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
270
271
 
271
272
  const { port, stop } = await deps.startServer({
272
273
  ...parsedArgs.options,
274
+ trustProxy: isShareEnabled(parsedArgs.options.share),
273
275
  onMigrationProgress: deps.log,
274
276
  update: {
275
277
  version: deps.version,
@@ -45,6 +45,17 @@ export interface ThreadResumeParams {
45
45
  persistExtendedHistory: boolean
46
46
  }
47
47
 
48
+ export interface ThreadForkParams {
49
+ threadId: string
50
+ model?: string | null
51
+ cwd?: string | null
52
+ serviceTier?: ServiceTier | null
53
+ approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted" | null
54
+ sandbox?: "read-only" | "workspace-write" | "danger-full-access" | null
55
+ ephemeral?: boolean
56
+ persistExtendedHistory: boolean
57
+ }
58
+
48
59
  export interface TextUserInput {
49
60
  type: "text"
50
61
  text: string
@@ -90,6 +101,7 @@ export interface ThreadStartResponse {
90
101
  }
91
102
 
92
103
  export type ThreadResumeResponse = ThreadStartResponse
104
+ export type ThreadForkResponse = ThreadStartResponse
93
105
 
94
106
  export interface TurnSummary {
95
107
  id: string
@@ -120,6 +120,38 @@ describe("CodexAppServerManager", () => {
120
120
  ])
121
121
  })
122
122
 
123
+ test("forks a thread when a pending fork session token is provided", async () => {
124
+ const process = new FakeCodexProcess((message, child) => {
125
+ if (message.method === "initialize") {
126
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
127
+ } else if (message.method === "thread/fork") {
128
+ child.writeServerMessage({
129
+ id: message.id,
130
+ result: { thread: { id: "thread-fork-1" }, model: "gpt-5.4", reasoningEffort: "high" },
131
+ })
132
+ }
133
+ })
134
+
135
+ const manager = new CodexAppServerManager({
136
+ spawnProcess: () => process as never,
137
+ })
138
+
139
+ const sessionToken = await manager.startSession({
140
+ chatId: "chat-1",
141
+ cwd: "/tmp/project",
142
+ model: "gpt-5.4",
143
+ sessionToken: null,
144
+ pendingForkSessionToken: "thread-source",
145
+ })
146
+
147
+ expect(sessionToken).toBe("thread-fork-1")
148
+ expect(process.messages.map((message: any) => message.method)).toEqual([
149
+ "initialize",
150
+ "initialized",
151
+ "thread/fork",
152
+ ])
153
+ })
154
+
123
155
  test("maps fast mode and reasoning into app-server params", async () => {
124
156
  const process = new FakeCodexProcess((message, child) => {
125
157
  if (message.method === "initialize") {
@@ -34,6 +34,8 @@ import {
34
34
  type ThreadItem,
35
35
  type ThreadResumeParams,
36
36
  type ThreadResumeResponse,
37
+ type ThreadForkParams,
38
+ type ThreadForkResponse,
37
39
  type ThreadStartParams,
38
40
  type ThreadStartResponse,
39
41
  type ThreadTokenUsageUpdatedNotification,
@@ -118,6 +120,7 @@ export interface StartCodexSessionArgs {
118
120
  model: string
119
121
  serviceTier?: ServiceTier
120
122
  sessionToken: string | null
123
+ pendingForkSessionToken?: string | null
121
124
  }
122
125
 
123
126
  export interface StartCodexTurnArgs {
@@ -745,7 +748,7 @@ export class CodexAppServerManager {
745
748
 
746
749
  async startSession(args: StartCodexSessionArgs) {
747
750
  const existing = this.sessions.get(args.chatId)
748
- if (existing && !existing.closed && existing.cwd === args.cwd) {
751
+ if (existing && !existing.closed && existing.cwd === args.cwd && !args.pendingForkSessionToken) {
749
752
  return
750
753
  }
751
754
 
@@ -791,8 +794,18 @@ export class CodexAppServerManager {
791
794
  persistExtendedHistory: false,
792
795
  } satisfies ThreadStartParams
793
796
 
794
- let response: ThreadStartResponse | ThreadResumeResponse
795
- if (args.sessionToken) {
797
+ let response: ThreadStartResponse | ThreadResumeResponse | ThreadForkResponse
798
+ if (args.pendingForkSessionToken) {
799
+ response = await this.sendRequest<ThreadForkResponse>(context, "thread/fork", {
800
+ threadId: args.pendingForkSessionToken,
801
+ model: args.model,
802
+ cwd: args.cwd,
803
+ serviceTier: args.serviceTier,
804
+ approvalPolicy: "never",
805
+ sandbox: "danger-full-access",
806
+ persistExtendedHistory: false,
807
+ } satisfies ThreadForkParams)
808
+ } else if (args.sessionToken) {
796
809
  try {
797
810
  response = await this.sendRequest<ThreadResumeResponse>(context, "thread/resume", {
798
811
  threadId: args.sessionToken,
@@ -815,6 +828,7 @@ export class CodexAppServerManager {
815
828
  }
816
829
 
817
830
  context.sessionToken = response.thread.id
831
+ return context.sessionToken
818
832
  }
819
833
 
820
834
  async startTurn(args: StartCodexTurnArgs): Promise<HarnessTurn> {