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.
- package/README.md +1 -1
- package/dist/client/assets/{index-CCCDQbjU.js → index-BLUOUI0z.js} +206 -196
- package/dist/client/assets/index-FQ1dOuTJ.css +32 -0
- 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 +131 -24
- package/src/server/auth.ts +47 -147
- 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 +11 -4
- 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/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
|
}
|
|
@@ -26,13 +31,14 @@ function extractCookie(response: Response) {
|
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
describe("password auth", () => {
|
|
29
|
-
test("
|
|
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}
|
|
34
|
-
expect(response.status).toBe(
|
|
35
|
-
expect(response.headers.get("
|
|
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("
|
|
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(
|
|
58
|
-
expect(response.headers.get("
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
|
package/src/server/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.replaceAll("'", "'")
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
175
|
+
const { password: candidate, nextPath } = await readLoginForm(req)
|
|
262
176
|
if (!verifyPassword(candidate)) {
|
|
263
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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> {
|