kanna-code 0.32.3 → 0.33.0

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.
@@ -30,8 +30,8 @@
30
30
  })()
31
31
  </script>
32
32
  <title>Kanna</title>
33
- <script type="module" crossorigin src="/assets/index-BVeR75Xr.js"></script>
34
- <link rel="stylesheet" crossorigin href="/assets/index-B3EUEMfY.css">
33
+ <script type="module" crossorigin src="/assets/index-ab3JkqZ2.js"></script>
34
+ <link rel="stylesheet" crossorigin href="/assets/index-BYzAMpzV.css">
35
35
  </head>
36
36
  <body>
37
37
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.32.3",
4
+ "version": "0.33.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -1452,6 +1452,77 @@ describe("AgentCoordinator claude integration", () => {
1452
1452
 
1453
1453
  events.close()
1454
1454
  })
1455
+
1456
+ test("uses Claude forkSession when starting a forked chat", async () => {
1457
+ const startSessionCalls: Array<{ sessionToken: string | null; forkSession: boolean }> = []
1458
+ const events = new AsyncEventQueue<any>()
1459
+ const store = createFakeStore()
1460
+ store.chat.provider = "claude"
1461
+ store.chat.pendingForkSessionToken = "claude-parent-1"
1462
+
1463
+ const coordinator = new AgentCoordinator({
1464
+ store: store as never,
1465
+ onStateChange: () => {},
1466
+ startClaudeSession: async (args) => {
1467
+ startSessionCalls.push({
1468
+ sessionToken: args.sessionToken,
1469
+ forkSession: args.forkSession,
1470
+ })
1471
+
1472
+ return {
1473
+ provider: "claude",
1474
+ stream: events,
1475
+ getAccountInfo: async () => null,
1476
+ interrupt: async () => {},
1477
+ close: () => {},
1478
+ setModel: async () => {},
1479
+ setPermissionMode: async () => {},
1480
+ sendPrompt: async () => {
1481
+ events.push({ type: "session_token" as const, sessionToken: "claude-fork-1" })
1482
+ events.push({
1483
+ type: "transcript" as const,
1484
+ entry: timestamped({
1485
+ kind: "system_init",
1486
+ provider: "claude",
1487
+ model: "claude-opus-4-1",
1488
+ tools: [],
1489
+ agents: [],
1490
+ slashCommands: [],
1491
+ mcpServers: [],
1492
+ }),
1493
+ })
1494
+ events.push({
1495
+ type: "transcript" as const,
1496
+ entry: timestamped({
1497
+ kind: "result",
1498
+ subtype: "success",
1499
+ isError: false,
1500
+ durationMs: 0,
1501
+ result: "done",
1502
+ }),
1503
+ })
1504
+ },
1505
+ }
1506
+ },
1507
+ })
1508
+
1509
+ await coordinator.send({
1510
+ type: "chat.send",
1511
+ chatId: "chat-1",
1512
+ provider: "claude",
1513
+ content: "branch this",
1514
+ model: "claude-opus-4-1",
1515
+ })
1516
+
1517
+ await waitFor(() => store.turnFinishedCount === 1)
1518
+
1519
+ expect(startSessionCalls).toEqual([{
1520
+ sessionToken: "claude-parent-1",
1521
+ forkSession: true,
1522
+ }])
1523
+ expect(store.chat.pendingForkSessionToken).toBeNull()
1524
+ events.close()
1525
+ })
1455
1526
  })
1456
1527
 
1457
1528
  function createFakeStore() {
@@ -1462,6 +1533,7 @@ function createFakeStore() {
1462
1533
  provider: null as "claude" | "codex" | null,
1463
1534
  planMode: false,
1464
1535
  sessionToken: null as string | null,
1536
+ pendingForkSessionToken: null as string | null,
1465
1537
  }
1466
1538
  const project = {
1467
1539
  id: "project-1",
@@ -1476,6 +1548,10 @@ function createFakeStore() {
1476
1548
  expect(chatId).toBe("chat-1")
1477
1549
  return chat
1478
1550
  },
1551
+ getChat(chatId: string) {
1552
+ expect(chatId).toBe("chat-1")
1553
+ return chat
1554
+ },
1479
1555
  getProject(projectId: string) {
1480
1556
  expect(projectId).toBe("project-1")
1481
1557
  return project
@@ -1506,9 +1582,21 @@ function createFakeStore() {
1506
1582
  async setSessionToken(_chatId: string, sessionToken: string | null) {
1507
1583
  chat.sessionToken = sessionToken
1508
1584
  },
1585
+ async setPendingForkSessionToken(_chatId: string, pendingForkSessionToken: string | null) {
1586
+ chat.pendingForkSessionToken = pendingForkSessionToken
1587
+ },
1509
1588
  async createChat() {
1510
1589
  return chat
1511
1590
  },
1591
+ async forkChat() {
1592
+ return {
1593
+ ...chat,
1594
+ id: "chat-fork-1",
1595
+ title: "Fork: New Chat",
1596
+ sessionToken: null,
1597
+ pendingForkSessionToken: chat.sessionToken ?? chat.pendingForkSessionToken,
1598
+ }
1599
+ },
1512
1600
  async enqueueMessage(_chatId: string, message: any) {
1513
1601
  const queuedMessage = {
1514
1602
  id: message.id ?? crypto.randomUUID(),
@@ -106,6 +106,7 @@ interface AgentCoordinatorArgs {
106
106
  effort?: string
107
107
  planMode: boolean
108
108
  sessionToken: string | null
109
+ forkSession: boolean
109
110
  onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
110
111
  }) => Promise<ClaudeSessionHandle>
111
112
  }
@@ -552,6 +553,7 @@ async function startClaudeSession(args: {
552
553
  effort?: string
553
554
  planMode: boolean
554
555
  sessionToken: string | null
556
+ forkSession: boolean
555
557
  onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
556
558
  }): Promise<ClaudeSessionHandle> {
557
559
  const canUseTool: CanUseTool = async (toolName, input, options) => {
@@ -618,6 +620,7 @@ async function startClaudeSession(args: {
618
620
  model: args.model,
619
621
  effort: args.effort as "low" | "medium" | "high" | "max" | undefined,
620
622
  resume: args.sessionToken ?? undefined,
623
+ forkSession: args.forkSession,
621
624
  permissionMode: args.planMode ? "plan" : "acceptEdits",
622
625
  canUseTool,
623
626
  tools: [...CLAUDE_TOOLSET],
@@ -920,7 +923,8 @@ export class AgentCoordinator {
920
923
  model: args.model,
921
924
  effort: args.effort,
922
925
  planMode: args.planMode,
923
- sessionToken: chat.sessionToken,
926
+ sessionToken: chat.pendingForkSessionToken ?? chat.sessionToken,
927
+ forkSession: Boolean(chat.pendingForkSessionToken),
924
928
  onToolRequest,
925
929
  })
926
930
  logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
@@ -934,13 +938,17 @@ export class AgentCoordinator {
934
938
  provider: args.provider,
935
939
  model: args.model,
936
940
  })
937
- await this.codexManager.startSession({
941
+ const sessionToken = await this.codexManager.startSession({
938
942
  chatId: args.chatId,
939
943
  cwd: project.localPath,
940
944
  model: args.model,
941
945
  serviceTier: args.serviceTier,
942
946
  sessionToken: chat.sessionToken,
947
+ pendingForkSessionToken: chat.pendingForkSessionToken,
943
948
  })
949
+ if (chat.pendingForkSessionToken && sessionToken) {
950
+ await this.store.setPendingForkSessionToken(args.chatId, null)
951
+ }
944
952
  logSendToStartingProfile(args.profile, "start_turn.session_ready", {
945
953
  chatId: args.chatId,
946
954
  provider: args.provider,
@@ -1043,11 +1051,12 @@ export class AgentCoordinator {
1043
1051
  effort?: string
1044
1052
  planMode: boolean
1045
1053
  sessionToken: string | null
1054
+ forkSession: boolean
1046
1055
  onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
1047
1056
  }): Promise<HarnessTurn> {
1048
1057
  let session = this.claudeSessions.get(args.chatId)
1049
1058
 
1050
- if (!session || session.localPath !== args.localPath || session.effort !== args.effort) {
1059
+ if (!session || session.localPath !== args.localPath || session.effort !== args.effort || args.forkSession) {
1051
1060
  if (session) {
1052
1061
  session.session.close()
1053
1062
  this.claudeSessions.delete(args.chatId)
@@ -1059,6 +1068,7 @@ export class AgentCoordinator {
1059
1068
  effort: args.effort,
1060
1069
  planMode: args.planMode,
1061
1070
  sessionToken: args.sessionToken,
1071
+ forkSession: args.forkSession,
1062
1072
  onToolRequest: args.onToolRequest,
1063
1073
  })
1064
1074
 
@@ -1206,6 +1216,22 @@ export class AgentCoordinator {
1206
1216
  await this.store.removeQueuedMessage(command.chatId, command.queuedMessageId)
1207
1217
  }
1208
1218
 
1219
+ async forkChat(chatId: string) {
1220
+ const chat = this.store.requireChat(chatId)
1221
+ if (this.activeTurns.has(chatId) || this.drainingStreams.has(chatId)) {
1222
+ throw new Error("Chat must be idle before forking")
1223
+ }
1224
+ if (!chat.provider) {
1225
+ throw new Error("Chat must have a provider before forking")
1226
+ }
1227
+ if (!chat.sessionToken && !chat.pendingForkSessionToken) {
1228
+ throw new Error("Chat has no session to fork")
1229
+ }
1230
+
1231
+ const forked = await this.store.forkChat(chatId)
1232
+ return { chatId: forked.id }
1233
+ }
1234
+
1209
1235
  private async runClaudeSession(session: ClaudeSessionState) {
1210
1236
  try {
1211
1237
  for await (const event of session.session.stream) {
@@ -1221,6 +1247,14 @@ export class AgentCoordinator {
1221
1247
  const active = this.activeTurns.get(session.chatId)
1222
1248
  if (event.entry.kind === "system_init" && active) {
1223
1249
  active.status = "running"
1250
+ const chat = this.store.getChat(session.chatId)
1251
+ if (
1252
+ chat?.pendingForkSessionToken
1253
+ && session.sessionToken
1254
+ && session.sessionToken !== chat.pendingForkSessionToken
1255
+ ) {
1256
+ await this.store.setPendingForkSessionToken(session.chatId, null)
1257
+ }
1224
1258
  logClaudeSteer("claude_event_system_init", {
1225
1259
  chatId: session.chatId,
1226
1260
  sessionId: session.id,
@@ -1320,6 +1354,13 @@ export class AgentCoordinator {
1320
1354
 
1321
1355
  if (event.type === "session_token" && event.sessionToken) {
1322
1356
  await this.store.setSessionToken(active.chatId, event.sessionToken)
1357
+ const chat = this.store.getChat(active.chatId)
1358
+ if (
1359
+ chat?.pendingForkSessionToken
1360
+ && event.sessionToken !== chat.pendingForkSessionToken
1361
+ ) {
1362
+ await this.store.setPendingForkSessionToken(active.chatId, null)
1363
+ }
1323
1364
  this.emitStateChange(active.chatId)
1324
1365
  continue
1325
1366
  }
@@ -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
  }
@@ -169,6 +174,118 @@ describe("password auth", () => {
169
174
  }
170
175
  })
171
176
 
177
+ test("ignores forwarded proto when trustProxy is off", async () => {
178
+ const { server } = await startPasswordServer({ port: 54321 })
179
+
180
+ try {
181
+ const response = await fetch(`http://localhost:${server.port}/`, {
182
+ redirect: "manual",
183
+ headers: {
184
+ Accept: "text/html",
185
+ "X-Forwarded-Proto": "https",
186
+ },
187
+ })
188
+ expect(response.status).toBe(302)
189
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
190
+
191
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
192
+ method: "POST",
193
+ body: JSON.stringify({ password: "secret", next: "/" }),
194
+ headers: {
195
+ "Content-Type": "application/json",
196
+ Origin: "https://evil.test",
197
+ "X-Forwarded-Proto": "https",
198
+ },
199
+ })
200
+ expect(loginResponse.status).toBe(403)
201
+
202
+ const goodLoginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
203
+ method: "POST",
204
+ body: JSON.stringify({ password: "secret", next: "/" }),
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ Origin: `http://localhost:${server.port}`,
208
+ "X-Forwarded-Proto": "https",
209
+ },
210
+ })
211
+ expect(goodLoginResponse.status).toBe(200)
212
+ const cookieHeader = goodLoginResponse.headers.get("set-cookie") ?? ""
213
+ expect(cookieHeader).not.toContain("Secure")
214
+ } finally {
215
+ await server.stop()
216
+ }
217
+ })
218
+
219
+ test("honors forwarded proto when trustProxy is on", async () => {
220
+ const { server } = await startPasswordServer({ port: 54322, trustProxy: true })
221
+
222
+ try {
223
+ const redirect = await fetch(`http://localhost:${server.port}/`, {
224
+ redirect: "manual",
225
+ headers: {
226
+ Accept: "text/html",
227
+ "X-Forwarded-Proto": "https",
228
+ },
229
+ })
230
+ expect(redirect.status).toBe(302)
231
+ expect(redirect.headers.get("location")).toBe(`https://localhost:${server.port}/auth/login?next=%2F`)
232
+
233
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
234
+ method: "POST",
235
+ body: JSON.stringify({ password: "secret", next: "/" }),
236
+ headers: {
237
+ "Content-Type": "application/json",
238
+ Origin: `https://localhost:${server.port}`,
239
+ "X-Forwarded-Proto": "https",
240
+ },
241
+ })
242
+ expect(loginResponse.status).toBe(200)
243
+ expect(loginResponse.headers.get("set-cookie") ?? "").toContain("Secure")
244
+
245
+ const evilResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
246
+ method: "POST",
247
+ body: JSON.stringify({ password: "secret", next: "/" }),
248
+ headers: {
249
+ "Content-Type": "application/json",
250
+ Origin: `http://localhost:${server.port}`,
251
+ },
252
+ })
253
+ expect(evilResponse.status).toBe(200)
254
+ } finally {
255
+ await server.stop()
256
+ }
257
+ })
258
+
259
+ test("ignores invalid forwarded proto values", async () => {
260
+ const { server } = await startPasswordServer({ port: 54323, trustProxy: true })
261
+
262
+ try {
263
+ const redirect = await fetch(`http://localhost:${server.port}/`, {
264
+ redirect: "manual",
265
+ headers: {
266
+ Accept: "text/html",
267
+ "X-Forwarded-Proto": "ftp",
268
+ },
269
+ })
270
+ expect(redirect.status).toBe(302)
271
+ expect(redirect.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
272
+
273
+ const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
274
+ method: "POST",
275
+ body: JSON.stringify({ password: "secret", next: "/" }),
276
+ headers: {
277
+ "Content-Type": "application/json",
278
+ Origin: `http://localhost:${server.port}`,
279
+ "X-Forwarded-Proto": "ftp",
280
+ },
281
+ })
282
+ expect(loginResponse.status).toBe(200)
283
+ expect(loginResponse.headers.get("set-cookie") ?? "").not.toContain("Secure")
284
+ } finally {
285
+ await server.stop()
286
+ }
287
+ })
288
+
172
289
  test("clears the session cookie on logout", async () => {
173
290
  const { server } = await startPasswordServer()
174
291
 
@@ -47,11 +47,30 @@ function sanitizeNextPath(nextPath: string | null | undefined) {
47
47
  return nextPath
48
48
  }
49
49
 
50
- function shouldUseSecureCookie(req: Request) {
50
+ function forwardedProto(req: Request): "http" | "https" | null {
51
+ const xfp = req.headers.get("x-forwarded-proto")
52
+ if (!xfp) return null
53
+ const value = xfp.split(",")[0]?.trim().toLowerCase()
54
+ return value === "http" || value === "https" ? value : null
55
+ }
56
+
57
+ function effectiveOrigin(req: Request, trustProxy: boolean): string {
58
+ const url = new URL(req.url)
59
+ if (!trustProxy) return url.origin
60
+ const proto = forwardedProto(req)
61
+ const scheme = proto ?? url.protocol.replace(":", "")
62
+ return `${scheme}://${url.host}`
63
+ }
64
+
65
+ function shouldUseSecureCookie(req: Request, trustProxy: boolean) {
66
+ if (trustProxy) {
67
+ const proto = forwardedProto(req)
68
+ if (proto) return proto === "https"
69
+ }
51
70
  return new URL(req.url).protocol === "https:"
52
71
  }
53
72
 
54
- function buildCookie(name: string, value: string, req: Request, extras: string[] = []) {
73
+ function buildCookie(name: string, value: string, req: Request, trustProxy: boolean, extras: string[] = []) {
55
74
  const parts = [
56
75
  `${name}=${encodeURIComponent(value)}`,
57
76
  "Path=/",
@@ -59,7 +78,7 @@ function buildCookie(name: string, value: string, req: Request, extras: string[]
59
78
  "SameSite=Strict",
60
79
  ]
61
80
 
62
- if (shouldUseSecureCookie(req)) {
81
+ if (shouldUseSecureCookie(req, trustProxy)) {
63
82
  parts.push("Secure")
64
83
  }
65
84
 
@@ -101,9 +120,23 @@ function escapeHtml(value: string) {
101
120
  .replaceAll("'", "&#39;")
102
121
  }
103
122
 
104
- export function createAuthManager(password: string): AuthManager {
123
+ export interface AuthManagerOptions {
124
+ /**
125
+ * When true, the auth layer trusts X-Forwarded-Proto to decide whether the
126
+ * public origin is http or https. The hostname always comes from the Host
127
+ * header (never X-Forwarded-Host) because X-Forwarded-Host is passed
128
+ * through by some tunnels unmodified and would otherwise allow open
129
+ * redirects.
130
+ * Enable only when the server is reachable solely through a trusted reverse
131
+ * proxy such as cloudflared.
132
+ */
133
+ trustProxy?: boolean
134
+ }
135
+
136
+ export function createAuthManager(password: string, options: AuthManagerOptions = {}): AuthManager {
105
137
  const sessions = new Set<string>()
106
138
  const expectedPassword = Buffer.from(password)
139
+ const trustProxy = options.trustProxy ?? false
107
140
 
108
141
  function getSessionToken(req: Request) {
109
142
  return parseCookies(req.headers.get("cookie")).get(SESSION_COOKIE_NAME) ?? null
@@ -117,13 +150,15 @@ export function createAuthManager(password: string): AuthManager {
117
150
  function validateOrigin(req: Request) {
118
151
  const origin = req.headers.get("origin")
119
152
  if (!origin) return true
120
- return origin === new URL(req.url).origin
153
+ if (origin === new URL(req.url).origin) return true
154
+ if (!trustProxy) return false
155
+ return origin === effectiveOrigin(req, trustProxy)
121
156
  }
122
157
 
123
158
  function createSessionCookie(req: Request) {
124
159
  const sessionToken = randomBytes(32).toString("base64url")
125
160
  sessions.add(sessionToken)
126
- return buildCookie(SESSION_COOKIE_NAME, sessionToken, req)
161
+ return buildCookie(SESSION_COOKIE_NAME, sessionToken, req, trustProxy)
127
162
  }
128
163
 
129
164
  function clearSessionCookie(req: Request) {
@@ -131,7 +166,7 @@ export function createAuthManager(password: string): AuthManager {
131
166
  if (sessionToken) {
132
167
  sessions.delete(sessionToken)
133
168
  }
134
- return buildCookie(SESSION_COOKIE_NAME, "", req, ["Max-Age=0"])
169
+ return buildCookie(SESSION_COOKIE_NAME, "", req, trustProxy, ["Max-Age=0"])
135
170
  }
136
171
 
137
172
  function verifyPassword(candidate: string) {
@@ -152,7 +187,7 @@ export function createAuthManager(password: string): AuthManager {
152
187
  function unauthorizedResponse(req: Request) {
153
188
  if (req.method === "GET" && requestWantsHtml(req)) {
154
189
  const url = new URL(req.url)
155
- const loginUrl = new URL("/auth/login", req.url)
190
+ const loginUrl = new URL("/auth/login", effectiveOrigin(req, trustProxy))
156
191
  loginUrl.searchParams.set("next", sanitizeNextPath(`${url.pathname}${url.search}`))
157
192
  return Response.redirect(loginUrl, 302)
158
193
  }
@@ -163,7 +198,7 @@ export function createAuthManager(password: string): AuthManager {
163
198
  function renderLoginPage(req: Request) {
164
199
  if (isAuthenticated(req)) {
165
200
  const currentUrl = new URL(req.url)
166
- return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), req.url), 302)
201
+ return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
167
202
  }
168
203
 
169
204
  const currentUrl = new URL(req.url)
@@ -264,7 +299,7 @@ export function createAuthManager(password: string): AuthManager {
264
299
  return Response.json({ error: "Invalid password" }, { status: 401 })
265
300
  }
266
301
 
267
- const redirectUrl = new URL("/auth/login", req.url)
302
+ const redirectUrl = new URL("/auth/login", effectiveOrigin(req, trustProxy))
268
303
  redirectUrl.searchParams.set("error", "1")
269
304
  redirectUrl.searchParams.set("next", sanitizeNextPath(nextPath || fallbackNextPath))
270
305
  return Response.redirect(redirectUrl, 302)
@@ -272,7 +307,7 @@ export function createAuthManager(password: string): AuthManager {
272
307
 
273
308
  const response = wantsJson
274
309
  ? Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
275
- : Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), req.url), 302)
310
+ : Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), effectiveOrigin(req, trustProxy)), 302)
276
311
 
277
312
  response.headers.set("Set-Cookie", createSessionCookie(req))
278
313
  return response
@@ -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" },
@@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"
3
3
  import { hasCommand, spawnDetached } from "./process-utils"
4
4
  import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } from "../shared/branding"
5
5
  import type { ShareMode } from "../shared/share"
6
- import { isShareEnabled, isTokenShareMode } from "../shared/share"
6
+ import { assertNoHostOverride, getShareCliFlag, isShareEnabled, isTokenShareMode } from "../shared/share"
7
7
  import type { UpdateInstallErrorCode } from "../shared/types"
8
8
  import { PROD_SERVER_PORT } from "../shared/ports"
9
9
  import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
@@ -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
@@ -73,6 +74,10 @@ type ParsedArgs =
73
74
 
74
75
  const MINIMUM_BUN_VERSION = "1.3.5"
75
76
 
77
+ function throwShareConflict(share: Exclude<ShareMode, false>, hostFlag: "--host" | "--remote"): never {
78
+ throw new Error(`${getShareCliFlag(share)} cannot be used with ${hostFlag}`)
79
+ }
80
+
76
81
  function printHelp() {
77
82
  console.log(`${APP_NAME} — local-only project chat UI
78
83
 
@@ -122,7 +127,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
122
127
  const next = argv[index + 1]
123
128
  if (!next || next.startsWith("-")) throw new Error("Missing value for --host")
124
129
  if (isShareEnabled(share)) {
125
- throw new Error(typeof share === "string" ? "--share cannot be used with --host" : "--cloudflared cannot be used with --host")
130
+ throwShareConflict(share, "--host")
126
131
  }
127
132
  host = next
128
133
  sawHost = true
@@ -131,21 +136,19 @@ export function parseArgs(argv: string[]): ParsedArgs {
131
136
  }
132
137
  if (arg === "--remote") {
133
138
  if (isShareEnabled(share)) {
134
- throw new Error(typeof share === "string" ? "--share cannot be used with --remote" : "--cloudflared cannot be used with --remote")
139
+ throwShareConflict(share, "--remote")
135
140
  }
136
141
  host = "0.0.0.0"
137
142
  sawRemote = true
138
143
  continue
139
144
  }
140
145
  if (arg === "--share") {
141
- if (sawHost) throw new Error("--share cannot be used with --host")
142
- if (sawRemote) throw new Error("--share cannot be used with --remote")
146
+ assertNoHostOverride("--share", sawHost, sawRemote)
143
147
  share = "quick"
144
148
  continue
145
149
  }
146
150
  if (arg === "--cloudflared") {
147
- if (sawHost) throw new Error("--cloudflared cannot be used with --host")
148
- if (sawRemote) throw new Error("--cloudflared cannot be used with --remote")
151
+ assertNoHostOverride("--cloudflared", sawHost, sawRemote)
149
152
  const next = argv[index + 1]
150
153
  if (!next || next.startsWith("-")) throw new Error("Missing value for --cloudflared")
151
154
  share = { kind: "token", token: next }
@@ -268,6 +271,7 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
268
271
 
269
272
  const { port, stop } = await deps.startServer({
270
273
  ...parsedArgs.options,
274
+ trustProxy: isShareEnabled(parsedArgs.options.share),
271
275
  onMigrationProgress: deps.log,
272
276
  update: {
273
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