kanna-code 0.32.4 → 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.
- package/dist/client/assets/index-BYzAMpzV.css +32 -0
- package/dist/client/assets/{index-CCCDQbjU.js → index-ab3JkqZ2.js} +206 -196
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +88 -0
- package/src/server/agent.ts +44 -3
- package/src/server/auth.test.ts +119 -2
- package/src/server/auth.ts +46 -11
- package/src/server/cli-runtime.test.ts +4 -0
- package/src/server/cli-runtime.ts +2 -0
- package/src/server/codex-app-server-protocol.ts +12 -0
- package/src/server/codex-app-server.test.ts +32 -0
- package/src/server/codex-app-server.ts +17 -3
- package/src/server/diff-store.ts +0 -9
- package/src/server/event-store.test.ts +26 -0
- package/src/server/event-store.ts +74 -0
- package/src/server/events.ts +8 -0
- package/src/server/read-models.test.ts +60 -0
- package/src/server/read-models.ts +15 -0
- package/src/server/server.ts +8 -1
- package/src/server/ws-router.test.ts +134 -1
- package/src/server/ws-router.ts +7 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.ts +1 -0
- package/dist/client/assets/index-DbTkHYDv.css +0 -32
package/dist/client/index.html
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
})()
|
|
31
31
|
</script>
|
|
32
32
|
<title>Kanna</title>
|
|
33
|
-
<script type="module" crossorigin src="/assets/index-
|
|
34
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
package/src/server/agent.test.ts
CHANGED
|
@@ -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(),
|
package/src/server/agent.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/auth.test.ts
CHANGED
|
@@ -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({
|
|
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
|
|
package/src/server/auth.ts
CHANGED
|
@@ -47,11 +47,30 @@ function sanitizeNextPath(nextPath: string | null | undefined) {
|
|
|
47
47
|
return nextPath
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function
|
|
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("'", "'")
|
|
102
121
|
}
|
|
103
122
|
|
|
104
|
-
export
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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" },
|
|
@@ -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") {
|