@cuongtran001/kanna 0.43.2 → 0.45.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-DLVKyl74.js +1 -0
- package/dist/client/assets/index-DVqFbcyL.css +32 -0
- package/dist/client/assets/index-DrdIVK7H.js +2655 -0
- package/dist/client/index.html +2 -2
- package/dist/export-viewer/assets/{index-D5AqC1n_.js → index-DE2jvYMB.js} +95 -95
- package/dist/export-viewer/assets/index-V85HqTWK.css +1 -0
- package/dist/export-viewer/index.html +2 -2
- package/package.json +4 -3
- package/src/server/agent.test.ts +643 -0
- package/src/server/agent.ts +143 -16
- package/src/server/app-settings.test.ts +54 -1
- package/src/server/app-settings.ts +49 -0
- package/src/server/auth.test.ts +23 -1
- package/src/server/background-tasks.test.ts +293 -0
- package/src/server/background-tasks.ts +219 -0
- package/src/server/cli-runtime.test.ts +15 -1
- package/src/server/cloudflare-tunnel/e2e.test.ts +4 -1
- package/src/server/codex-app-server.test.ts +69 -0
- package/src/server/codex-app-server.ts +13 -1
- package/src/server/diff-store.ts +2 -2
- package/src/server/kanna-mcp.test.ts +90 -0
- package/src/server/kanna-mcp.ts +116 -0
- package/src/server/orphan-persistence.test.ts +148 -0
- package/src/server/orphan-persistence.ts +147 -0
- package/src/server/server.ts +32 -7
- package/src/server/terminal-manager.test.ts +100 -0
- package/src/server/terminal-manager.ts +16 -2
- package/src/server/test-helpers/worktree-repo.ts +37 -0
- package/src/server/uploads.test.ts +36 -0
- package/src/server/worktree-store.test.ts +209 -0
- package/src/server/worktree-store.ts +120 -0
- package/src/server/ws-router.test.ts +360 -2
- package/src/server/ws-router.ts +86 -2
- package/src/shared/protocol.ts +18 -1
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +56 -0
- package/src/shared/types.ts +75 -0
- package/dist/client/assets/index-CC4TiTzA.css +0 -32
- package/dist/client/assets/index-UQWb6QtR.js +0 -2620
- package/dist/export-viewer/assets/index-BWQYh3zz.css +0 -1
package/src/server/agent.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
|
|
2
|
+
import { createKannaMcpServer } from "./kanna-mcp"
|
|
3
|
+
import { KANNA_MCP_SERVER_NAME } from "../shared/tools"
|
|
2
4
|
import { homedir } from "node:os"
|
|
3
5
|
import type {
|
|
4
6
|
AgentProvider,
|
|
@@ -34,6 +36,8 @@ import { ClaudeLimitDetector, CodexLimitDetector, type LimitDetection, type Limi
|
|
|
34
36
|
import type { ScheduleManager } from "./auto-continue/schedule-manager"
|
|
35
37
|
import { deriveChatSchedules } from "./auto-continue/read-model"
|
|
36
38
|
import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
|
|
39
|
+
import type { BackgroundTaskRegistry } from "./background-tasks"
|
|
40
|
+
import type { TerminalManager } from "./terminal-manager"
|
|
37
41
|
|
|
38
42
|
const CLAUDE_TOOLSET = [
|
|
39
43
|
"Skill",
|
|
@@ -111,9 +115,11 @@ interface AgentCoordinatorArgs {
|
|
|
111
115
|
onStateChange: (chatId?: string, options?: { immediate?: boolean }) => void
|
|
112
116
|
analytics?: AnalyticsReporter
|
|
113
117
|
codexManager?: CodexAppServerManager
|
|
118
|
+
terminalManager?: TerminalManager
|
|
114
119
|
generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
115
120
|
tunnelGateway?: TunnelGateway
|
|
116
121
|
startClaudeSession?: (args: {
|
|
122
|
+
projectId: string
|
|
117
123
|
localPath: string
|
|
118
124
|
model: string
|
|
119
125
|
effort?: string
|
|
@@ -127,6 +133,7 @@ interface AgentCoordinatorArgs {
|
|
|
127
133
|
scheduleManager?: ScheduleManager
|
|
128
134
|
getAutoResumePreference?: () => boolean
|
|
129
135
|
throwOnClaudeSessionStart?: boolean
|
|
136
|
+
backgroundTasks?: BackgroundTaskRegistry
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
interface SendToStartingProfile {
|
|
@@ -583,6 +590,7 @@ class AsyncMessageQueue<T> implements AsyncIterable<T> {
|
|
|
583
590
|
}
|
|
584
591
|
|
|
585
592
|
async function startClaudeSession(args: {
|
|
593
|
+
projectId: string
|
|
586
594
|
localPath: string
|
|
587
595
|
model: string
|
|
588
596
|
effort?: string
|
|
@@ -659,6 +667,12 @@ async function startClaudeSession(args: {
|
|
|
659
667
|
permissionMode: args.planMode ? "plan" : "acceptEdits",
|
|
660
668
|
canUseTool,
|
|
661
669
|
tools: [...CLAUDE_TOOLSET],
|
|
670
|
+
mcpServers: {
|
|
671
|
+
[KANNA_MCP_SERVER_NAME]: createKannaMcpServer({
|
|
672
|
+
projectId: args.projectId,
|
|
673
|
+
localPath: args.localPath,
|
|
674
|
+
}),
|
|
675
|
+
},
|
|
662
676
|
systemPrompt: {
|
|
663
677
|
type: "preset",
|
|
664
678
|
preset: "claude_code",
|
|
@@ -715,11 +729,50 @@ async function startClaudeSession(args: {
|
|
|
715
729
|
}
|
|
716
730
|
}
|
|
717
731
|
|
|
732
|
+
function parseBackgroundPid(output: string): number | null {
|
|
733
|
+
const match = output.match(/\bpid[\s:=]+(\d+)\b/i)
|
|
734
|
+
return match ? Number(match[1]) : null
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function parseBackgroundShellId(output: string): string | null {
|
|
738
|
+
const match = output.match(/shell[_\s-]?id[:\s]+([\w-]+)/i)
|
|
739
|
+
return match ? match[1] : null
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Extracts the canonical background task ID from a tool_result content value.
|
|
744
|
+
*
|
|
745
|
+
* The SDK may surface `backgroundTaskId` either:
|
|
746
|
+
* - as a top-level field on a BashOutput object (the direct-object form), or
|
|
747
|
+
* - as a field on one of the items in a content-block array.
|
|
748
|
+
*
|
|
749
|
+
* Returns the first non-empty string found, or null if absent.
|
|
750
|
+
*/
|
|
751
|
+
function extractBackgroundTaskId(content: unknown): string | null {
|
|
752
|
+
if (content === null || typeof content !== "object") return null
|
|
753
|
+
if (Array.isArray(content)) {
|
|
754
|
+
for (const item of content) {
|
|
755
|
+
if (typeof item === "object" && item !== null && "backgroundTaskId" in item) {
|
|
756
|
+
const id = (item as { backgroundTaskId?: unknown }).backgroundTaskId
|
|
757
|
+
if (typeof id === "string" && id.length > 0) return id
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return null
|
|
761
|
+
}
|
|
762
|
+
// Direct BashOutput-like object
|
|
763
|
+
if ("backgroundTaskId" in content) {
|
|
764
|
+
const id = (content as { backgroundTaskId?: unknown }).backgroundTaskId
|
|
765
|
+
if (typeof id === "string" && id.length > 0) return id
|
|
766
|
+
}
|
|
767
|
+
return null
|
|
768
|
+
}
|
|
769
|
+
|
|
718
770
|
export class AgentCoordinator {
|
|
719
771
|
private readonly store: EventStore
|
|
720
772
|
private readonly onStateChange: (chatId?: string, options?: { immediate?: boolean }) => void
|
|
721
773
|
private readonly analytics: AnalyticsReporter
|
|
722
774
|
private readonly codexManager: CodexAppServerManager
|
|
775
|
+
private readonly terminalManager: TerminalManager | null
|
|
723
776
|
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
724
777
|
private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
|
|
725
778
|
private reportBackgroundError: ((message: string) => void) | null = null
|
|
@@ -734,13 +787,17 @@ export class AgentCoordinator {
|
|
|
734
787
|
private readonly throwOnClaudeSessionStart: boolean
|
|
735
788
|
private readonly autoResumeByChat = new Map<string, boolean>()
|
|
736
789
|
private readonly tunnelGateway: TunnelGateway | null
|
|
737
|
-
private readonly
|
|
790
|
+
private readonly backgroundTasks: BackgroundTaskRegistry | null
|
|
791
|
+
private readonly pendingBashCalls = new Map<string, { command: string; chatId: string; isBg: boolean }>()
|
|
738
792
|
|
|
739
793
|
constructor(args: AgentCoordinatorArgs) {
|
|
740
794
|
this.store = args.store
|
|
741
795
|
this.onStateChange = args.onStateChange
|
|
742
796
|
this.analytics = args.analytics ?? NoopAnalyticsReporter
|
|
743
|
-
this.codexManager = args.codexManager ?? new CodexAppServerManager(
|
|
797
|
+
this.codexManager = args.codexManager ?? new CodexAppServerManager({
|
|
798
|
+
backgroundTasks: args.backgroundTasks,
|
|
799
|
+
})
|
|
800
|
+
this.terminalManager = args.terminalManager ?? null
|
|
744
801
|
this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
|
|
745
802
|
this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
|
|
746
803
|
this.claudeLimitDetector = args.claudeLimitDetector ?? new ClaudeLimitDetector()
|
|
@@ -749,6 +806,18 @@ export class AgentCoordinator {
|
|
|
749
806
|
this.getAutoResumePreference = args.getAutoResumePreference ?? (() => false)
|
|
750
807
|
this.throwOnClaudeSessionStart = args.throwOnClaudeSessionStart ?? false
|
|
751
808
|
this.tunnelGateway = args.tunnelGateway ?? null
|
|
809
|
+
this.backgroundTasks = args.backgroundTasks ?? null
|
|
810
|
+
this.backgroundTasks?.setStrategies({
|
|
811
|
+
closeStream: async (task) => {
|
|
812
|
+
await this.stopDraining(task.chatId)
|
|
813
|
+
},
|
|
814
|
+
killPty: async (task) => {
|
|
815
|
+
this.terminalManager?.close(task.ptyId)
|
|
816
|
+
},
|
|
817
|
+
shutdownCodex: async (task) => {
|
|
818
|
+
this.codexManager.stopSession(task.chatId)
|
|
819
|
+
},
|
|
820
|
+
})
|
|
752
821
|
}
|
|
753
822
|
|
|
754
823
|
setBackgroundErrorReporter(report: ((message: string) => void) | null) {
|
|
@@ -790,11 +859,10 @@ export class AgentCoordinator {
|
|
|
790
859
|
}
|
|
791
860
|
|
|
792
861
|
private trackBashToolEntry(chatId: string, entry: TranscriptEntry): void {
|
|
793
|
-
if (!this.tunnelGateway) return
|
|
794
|
-
|
|
795
862
|
if (entry.kind === "tool_call" && entry.tool.toolKind === "bash") {
|
|
796
863
|
const command = entry.tool.input.command ?? ""
|
|
797
|
-
|
|
864
|
+
const isBg = entry.tool.input.runInBackground === true
|
|
865
|
+
this.pendingBashCalls.set(entry.tool.toolId, { command, chatId, isBg })
|
|
798
866
|
return
|
|
799
867
|
}
|
|
800
868
|
|
|
@@ -803,12 +871,35 @@ export class AgentCoordinator {
|
|
|
803
871
|
if (!pending) return
|
|
804
872
|
this.pendingBashCalls.delete(entry.toolId)
|
|
805
873
|
const stdout = stringifyToolResultContent(entry.content)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
874
|
+
|
|
875
|
+
if (this.tunnelGateway) {
|
|
876
|
+
void this.tunnelGateway.handleBashResult({
|
|
877
|
+
command: pending.command,
|
|
878
|
+
stdout,
|
|
879
|
+
chatId: pending.chatId,
|
|
880
|
+
sourcePid: null,
|
|
881
|
+
})
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (pending.isBg && this.backgroundTasks) {
|
|
885
|
+
const registryId = `bash:${entry.toolId}`
|
|
886
|
+
const shellId =
|
|
887
|
+
extractBackgroundTaskId(entry.content) ??
|
|
888
|
+
parseBackgroundShellId(stdout) ??
|
|
889
|
+
entry.toolId
|
|
890
|
+
const pid = parseBackgroundPid(stdout)
|
|
891
|
+
this.backgroundTasks.register({
|
|
892
|
+
kind: "bash_shell",
|
|
893
|
+
id: registryId,
|
|
894
|
+
chatId: pending.chatId,
|
|
895
|
+
command: pending.command,
|
|
896
|
+
shellId,
|
|
897
|
+
pid,
|
|
898
|
+
startedAt: Date.now(),
|
|
899
|
+
lastOutput: stdout.slice(-1024),
|
|
900
|
+
status: "running",
|
|
901
|
+
})
|
|
902
|
+
}
|
|
812
903
|
}
|
|
813
904
|
}
|
|
814
905
|
|
|
@@ -824,11 +915,16 @@ export class AgentCoordinator {
|
|
|
824
915
|
}
|
|
825
916
|
}
|
|
826
917
|
|
|
918
|
+
private clearDrainingStream(chatId: string): void {
|
|
919
|
+
this.drainingStreams.delete(chatId)
|
|
920
|
+
this.backgroundTasks?.unregister(`drain:${chatId}`)
|
|
921
|
+
}
|
|
922
|
+
|
|
827
923
|
async stopDraining(chatId: string) {
|
|
828
924
|
const draining = this.drainingStreams.get(chatId)
|
|
829
925
|
if (!draining) return
|
|
830
926
|
draining.turn.close()
|
|
831
|
-
this.
|
|
927
|
+
this.clearDrainingStream(chatId)
|
|
832
928
|
this.emitStateChange(chatId)
|
|
833
929
|
}
|
|
834
930
|
|
|
@@ -853,6 +949,7 @@ export class AgentCoordinator {
|
|
|
853
949
|
const defaultModel = normalizeServerModel("claude")
|
|
854
950
|
const defaultOptions = normalizeClaudeModelOptions(defaultModel)
|
|
855
951
|
const ephemeral = await this.startClaudeSessionFn({
|
|
952
|
+
projectId: project.id,
|
|
856
953
|
localPath: project.localPath,
|
|
857
954
|
model: resolveClaudeApiModelId(defaultModel, defaultOptions.contextWindow),
|
|
858
955
|
effort: defaultOptions.reasoningEffort,
|
|
@@ -984,7 +1081,7 @@ export class AgentCoordinator {
|
|
|
984
1081
|
const draining = this.drainingStreams.get(args.chatId)
|
|
985
1082
|
if (draining) {
|
|
986
1083
|
draining.turn.close()
|
|
987
|
-
this.
|
|
1084
|
+
this.clearDrainingStream(args.chatId)
|
|
988
1085
|
}
|
|
989
1086
|
|
|
990
1087
|
const chat = this.store.requireChat(args.chatId)
|
|
@@ -1070,6 +1167,7 @@ export class AgentCoordinator {
|
|
|
1070
1167
|
})
|
|
1071
1168
|
turn = await this.startClaudeTurn({
|
|
1072
1169
|
chatId: args.chatId,
|
|
1170
|
+
projectId: project.id,
|
|
1073
1171
|
localPath: project.localPath,
|
|
1074
1172
|
model: args.model,
|
|
1075
1173
|
effort: args.effort,
|
|
@@ -1198,6 +1296,7 @@ export class AgentCoordinator {
|
|
|
1198
1296
|
|
|
1199
1297
|
private async startClaudeTurn(args: {
|
|
1200
1298
|
chatId: string
|
|
1299
|
+
projectId: string
|
|
1201
1300
|
localPath: string
|
|
1202
1301
|
model: string
|
|
1203
1302
|
effort?: string
|
|
@@ -1215,6 +1314,7 @@ export class AgentCoordinator {
|
|
|
1215
1314
|
}
|
|
1216
1315
|
|
|
1217
1316
|
const started = await this.startClaudeSessionFn({
|
|
1317
|
+
projectId: args.projectId,
|
|
1218
1318
|
localPath: args.localPath,
|
|
1219
1319
|
model: args.model,
|
|
1220
1320
|
effort: args.effort,
|
|
@@ -1503,7 +1603,13 @@ export class AgentCoordinator {
|
|
|
1503
1603
|
}
|
|
1504
1604
|
}
|
|
1505
1605
|
} finally {
|
|
1506
|
-
|
|
1606
|
+
// Only clear the chat's session slot if it still points at us. A cancel
|
|
1607
|
+
// followed by an immediate steer can install a fresh session under the
|
|
1608
|
+
// same chatId before this finally runs; deleting unconditionally would
|
|
1609
|
+
// wipe the new session.
|
|
1610
|
+
if (this.claudeSessions.get(session.chatId) === session) {
|
|
1611
|
+
this.claudeSessions.delete(session.chatId)
|
|
1612
|
+
}
|
|
1507
1613
|
const active = this.activeTurns.get(session.chatId)
|
|
1508
1614
|
if (active?.provider === "claude") {
|
|
1509
1615
|
if (active.cancelRequested && !active.cancelRecorded) {
|
|
@@ -1582,6 +1688,13 @@ export class AgentCoordinator {
|
|
|
1582
1688
|
// Track the still-open stream so the UI can show a draining
|
|
1583
1689
|
// indicator and the user can stop background tasks.
|
|
1584
1690
|
this.drainingStreams.set(active.chatId, { turn: active.turn })
|
|
1691
|
+
this.backgroundTasks?.register({
|
|
1692
|
+
kind: "draining_stream",
|
|
1693
|
+
id: `drain:${active.chatId}`,
|
|
1694
|
+
chatId: active.chatId,
|
|
1695
|
+
startedAt: Date.now(),
|
|
1696
|
+
lastOutput: "",
|
|
1697
|
+
})
|
|
1585
1698
|
}
|
|
1586
1699
|
|
|
1587
1700
|
this.emitStateChange(active.chatId)
|
|
@@ -1618,7 +1731,7 @@ export class AgentCoordinator {
|
|
|
1618
1731
|
this.activeTurns.delete(active.chatId)
|
|
1619
1732
|
}
|
|
1620
1733
|
// Stream has fully ended — no longer draining.
|
|
1621
|
-
this.
|
|
1734
|
+
this.clearDrainingStream(active.chatId)
|
|
1622
1735
|
this.emitStateChange(active.chatId)
|
|
1623
1736
|
|
|
1624
1737
|
if (active.postToolFollowUp && !active.cancelRequested) {
|
|
@@ -1824,7 +1937,7 @@ export class AgentCoordinator {
|
|
|
1824
1937
|
const draining = this.drainingStreams.get(chatId)
|
|
1825
1938
|
if (draining) {
|
|
1826
1939
|
draining.turn.close()
|
|
1827
|
-
this.
|
|
1940
|
+
this.clearDrainingStream(chatId)
|
|
1828
1941
|
}
|
|
1829
1942
|
|
|
1830
1943
|
const active = this.activeTurns.get(chatId)
|
|
@@ -1866,6 +1979,20 @@ export class AgentCoordinator {
|
|
|
1866
1979
|
// Remove from activeTurns immediately so the UI reflects the cancellation
|
|
1867
1980
|
// right away, rather than waiting for interrupt() which may hang.
|
|
1868
1981
|
this.activeTurns.delete(chatId)
|
|
1982
|
+
|
|
1983
|
+
// Drain the cancelled prompt's seq from the Claude session's pending
|
|
1984
|
+
// queue. The SDK does not always echo a `result.subtype=cancelled` for
|
|
1985
|
+
// an interrupted prompt — when the stream just ends, the seq would
|
|
1986
|
+
// otherwise linger and cause a FIFO mismatch when the next turn's
|
|
1987
|
+
// result arrives, leaving the chat stuck in "running".
|
|
1988
|
+
if (active.provider === "claude" && active.claudePromptSeq != null) {
|
|
1989
|
+
const session = this.claudeSessions.get(chatId)
|
|
1990
|
+
if (session) {
|
|
1991
|
+
const idx = session.pendingPromptSeqs.indexOf(active.claudePromptSeq)
|
|
1992
|
+
if (idx >= 0) session.pendingPromptSeqs.splice(idx, 1)
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1869
1996
|
this.emitStateChange(chatId)
|
|
1870
1997
|
logClaudeSteer("cancel_active_turn_deleted", {
|
|
1871
1998
|
chatId,
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
5
|
-
import { AUTH_DEFAULTS, CLOUDFLARE_TUNNEL_DEFAULTS } from "../shared/types"
|
|
5
|
+
import { AUTH_DEFAULTS, CLOUDFLARE_TUNNEL_DEFAULTS, UPLOAD_DEFAULTS } from "../shared/types"
|
|
6
6
|
import { AppSettingsManager, readAppSettingsSnapshot } from "./app-settings"
|
|
7
7
|
import type { AppSettingsSnapshot } from "../shared/types"
|
|
8
8
|
|
|
@@ -63,6 +63,7 @@ function expectedSettingsSnapshot(filePath: string, overrides: Partial<AppSettin
|
|
|
63
63
|
filePathDisplay: filePath,
|
|
64
64
|
cloudflareTunnel: CLOUDFLARE_TUNNEL_DEFAULTS,
|
|
65
65
|
auth: AUTH_DEFAULTS,
|
|
66
|
+
uploads: UPLOAD_DEFAULTS,
|
|
66
67
|
...overrides,
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -232,3 +233,55 @@ describe("cloudflareTunnel normalization", () => {
|
|
|
232
233
|
})
|
|
233
234
|
})
|
|
234
235
|
})
|
|
236
|
+
|
|
237
|
+
describe("uploads normalization", () => {
|
|
238
|
+
test("returns defaults when uploads block missing", async () => {
|
|
239
|
+
const filePath = await writeSettingsFile({ analyticsEnabled: true })
|
|
240
|
+
const snapshot = await readAppSettingsSnapshot(filePath)
|
|
241
|
+
expect(snapshot.uploads).toEqual({ maxFileSizeMb: 100 })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("preserves valid maxFileSizeMb", async () => {
|
|
245
|
+
const filePath = await writeSettingsFile({ uploads: { maxFileSizeMb: 250 } })
|
|
246
|
+
const snapshot = await readAppSettingsSnapshot(filePath)
|
|
247
|
+
expect(snapshot.uploads.maxFileSizeMb).toBe(250)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test("clamps out-of-range values and emits warning", async () => {
|
|
251
|
+
const filePath = await writeSettingsFile({ uploads: { maxFileSizeMb: 99999 } })
|
|
252
|
+
const snapshot = await readAppSettingsSnapshot(filePath)
|
|
253
|
+
expect(snapshot.uploads.maxFileSizeMb).toBe(2048)
|
|
254
|
+
expect(snapshot.warning).toContain("uploads.maxFileSizeMb")
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test("rejects non-number maxFileSizeMb and falls back to default", async () => {
|
|
258
|
+
const filePath = await writeSettingsFile({ uploads: { maxFileSizeMb: "big" } })
|
|
259
|
+
const snapshot = await readAppSettingsSnapshot(filePath)
|
|
260
|
+
expect(snapshot.uploads.maxFileSizeMb).toBe(100)
|
|
261
|
+
expect(snapshot.warning).toContain("uploads.maxFileSizeMb must be a number")
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test("setUploads persists patch and round-trips through readAppSettingsSnapshot", async () => {
|
|
265
|
+
const filePath = await writeSettingsFile({ analyticsEnabled: true })
|
|
266
|
+
const manager = new AppSettingsManager(filePath)
|
|
267
|
+
await manager.initialize()
|
|
268
|
+
await manager.setUploads({ maxFileSizeMb: 500 })
|
|
269
|
+
const reloaded = await readAppSettingsSnapshot(filePath)
|
|
270
|
+
expect(reloaded.uploads.maxFileSizeMb).toBe(500)
|
|
271
|
+
manager.dispose()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test("setUploads throws on invalid value", async () => {
|
|
275
|
+
const filePath = await createTempFilePath()
|
|
276
|
+
const manager = new AppSettingsManager(filePath)
|
|
277
|
+
await manager.initialize()
|
|
278
|
+
let lowError: unknown
|
|
279
|
+
try { await manager.setUploads({ maxFileSizeMb: 0 }) } catch (error) { lowError = error }
|
|
280
|
+
expect((lowError as Error)?.message).toMatch(/between/)
|
|
281
|
+
let highError: unknown
|
|
282
|
+
try { await manager.setUploads({ maxFileSizeMb: 99999 }) } catch (error) { highError = error }
|
|
283
|
+
expect((highError as Error)?.message).toMatch(/between/)
|
|
284
|
+
manager.dispose()
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
normalizeClaudeModelId,
|
|
18
18
|
normalizeCodexModelId,
|
|
19
19
|
supportsClaudeMaxReasoningEffort,
|
|
20
|
+
UPLOAD_DEFAULTS,
|
|
21
|
+
UPLOAD_MAX_FILE_SIZE_MB_MAX,
|
|
22
|
+
UPLOAD_MAX_FILE_SIZE_MB_MIN,
|
|
20
23
|
type AppSettingsPatch,
|
|
21
24
|
type AppSettingsSnapshot,
|
|
22
25
|
type AppThemePreference,
|
|
@@ -30,6 +33,7 @@ import {
|
|
|
30
33
|
type DefaultProviderPreference,
|
|
31
34
|
type EditorPreset,
|
|
32
35
|
type ProviderPreference,
|
|
36
|
+
type UploadSettings,
|
|
33
37
|
} from "../shared/types"
|
|
34
38
|
|
|
35
39
|
interface AppSettingsFile {
|
|
@@ -54,6 +58,7 @@ interface AppSettingsFile {
|
|
|
54
58
|
}
|
|
55
59
|
cloudflareTunnel?: unknown
|
|
56
60
|
auth?: unknown
|
|
61
|
+
uploads?: unknown
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
interface AppSettingsState extends AppSettingsSnapshot {
|
|
@@ -278,6 +283,30 @@ function normalizeAuthSettings(value: unknown, warnings: string[]): AuthSettings
|
|
|
278
283
|
return { sessionMaxAgeDays }
|
|
279
284
|
}
|
|
280
285
|
|
|
286
|
+
function normalizeUploadSettings(value: unknown, warnings: string[]): UploadSettings {
|
|
287
|
+
const source = value && typeof value === "object" && !Array.isArray(value)
|
|
288
|
+
? value as Record<string, unknown>
|
|
289
|
+
: null
|
|
290
|
+
if (value !== undefined && !source) {
|
|
291
|
+
warnings.push("uploads must be an object")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rawSize = source?.maxFileSizeMb
|
|
295
|
+
let maxFileSizeMb = UPLOAD_DEFAULTS.maxFileSizeMb
|
|
296
|
+
if (rawSize !== undefined) {
|
|
297
|
+
if (typeof rawSize !== "number" || !Number.isFinite(rawSize)) {
|
|
298
|
+
warnings.push("uploads.maxFileSizeMb must be a number")
|
|
299
|
+
} else if (rawSize < UPLOAD_MAX_FILE_SIZE_MB_MIN || rawSize > UPLOAD_MAX_FILE_SIZE_MB_MAX) {
|
|
300
|
+
warnings.push(`uploads.maxFileSizeMb must be between ${UPLOAD_MAX_FILE_SIZE_MB_MIN} and ${UPLOAD_MAX_FILE_SIZE_MB_MAX}`)
|
|
301
|
+
maxFileSizeMb = clampNumber(rawSize, UPLOAD_DEFAULTS.maxFileSizeMb, UPLOAD_MAX_FILE_SIZE_MB_MIN, UPLOAD_MAX_FILE_SIZE_MB_MAX)
|
|
302
|
+
} else {
|
|
303
|
+
maxFileSizeMb = Math.round(rawSize)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { maxFileSizeMb }
|
|
308
|
+
}
|
|
309
|
+
|
|
281
310
|
function toFilePayload(state: AppSettingsState) {
|
|
282
311
|
return {
|
|
283
312
|
analyticsEnabled: state.analyticsEnabled,
|
|
@@ -292,6 +321,7 @@ function toFilePayload(state: AppSettingsState) {
|
|
|
292
321
|
providerDefaults: state.providerDefaults,
|
|
293
322
|
cloudflareTunnel: state.cloudflareTunnel,
|
|
294
323
|
auth: state.auth,
|
|
324
|
+
uploads: state.uploads,
|
|
295
325
|
}
|
|
296
326
|
}
|
|
297
327
|
|
|
@@ -310,6 +340,7 @@ function toSnapshot(state: AppSettingsState): AppSettingsSnapshot {
|
|
|
310
340
|
filePathDisplay: state.filePathDisplay,
|
|
311
341
|
cloudflareTunnel: state.cloudflareTunnel,
|
|
312
342
|
auth: state.auth,
|
|
343
|
+
uploads: state.uploads,
|
|
313
344
|
}
|
|
314
345
|
}
|
|
315
346
|
|
|
@@ -342,6 +373,7 @@ function normalizeAppSettings(
|
|
|
342
373
|
|
|
343
374
|
const cloudflareTunnel = normalizeCloudflareTunnel(source?.cloudflareTunnel, warnings)
|
|
344
375
|
const auth = normalizeAuthSettings(source?.auth, warnings)
|
|
376
|
+
const uploads = normalizeUploadSettings(source?.uploads, warnings)
|
|
345
377
|
|
|
346
378
|
const editorPreset = normalizeEditorPreset(source?.editor?.preset)
|
|
347
379
|
const state: AppSettingsState = {
|
|
@@ -365,6 +397,7 @@ function normalizeAppSettings(
|
|
|
365
397
|
filePathDisplay: formatDisplayPath(filePath),
|
|
366
398
|
cloudflareTunnel,
|
|
367
399
|
auth,
|
|
400
|
+
uploads,
|
|
368
401
|
}
|
|
369
402
|
|
|
370
403
|
const shouldWrite = JSON.stringify(source ? toComparablePayload(source) : null) !== JSON.stringify(toFilePayload(state))
|
|
@@ -393,6 +426,7 @@ function toComparablePayload(source: AppSettingsFile) {
|
|
|
393
426
|
providerDefaults: source.providerDefaults,
|
|
394
427
|
cloudflareTunnel: source.cloudflareTunnel,
|
|
395
428
|
auth: source.auth,
|
|
429
|
+
uploads: source.uploads,
|
|
396
430
|
}
|
|
397
431
|
}
|
|
398
432
|
|
|
@@ -434,6 +468,10 @@ function applyPatch(state: AppSettingsState, patch: AppSettingsPatch): AppSettin
|
|
|
434
468
|
...state.auth,
|
|
435
469
|
...patch.auth,
|
|
436
470
|
},
|
|
471
|
+
uploads: {
|
|
472
|
+
...state.uploads,
|
|
473
|
+
...patch.uploads,
|
|
474
|
+
},
|
|
437
475
|
}, state.filePathDisplay).payload
|
|
438
476
|
}
|
|
439
477
|
|
|
@@ -528,6 +566,17 @@ export class AppSettingsManager {
|
|
|
528
566
|
return this.writePatch({ auth: patch })
|
|
529
567
|
}
|
|
530
568
|
|
|
569
|
+
async setUploads(patch: Partial<UploadSettings>) {
|
|
570
|
+
if (patch.maxFileSizeMb !== undefined) {
|
|
571
|
+
const value = patch.maxFileSizeMb
|
|
572
|
+
if (typeof value !== "number" || !Number.isFinite(value)
|
|
573
|
+
|| value < UPLOAD_MAX_FILE_SIZE_MB_MIN || value > UPLOAD_MAX_FILE_SIZE_MB_MAX) {
|
|
574
|
+
throw new Error(`uploads.maxFileSizeMb must be between ${UPLOAD_MAX_FILE_SIZE_MB_MIN} and ${UPLOAD_MAX_FILE_SIZE_MB_MAX}`)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return this.writePatch({ uploads: patch })
|
|
578
|
+
}
|
|
579
|
+
|
|
531
580
|
async writePatch(patch: AppSettingsPatch) {
|
|
532
581
|
const nextState = {
|
|
533
582
|
...applyPatch(this.state, patch),
|
package/src/server/auth.test.ts
CHANGED
|
@@ -39,7 +39,29 @@ function extractCookie(response: Response) {
|
|
|
39
39
|
|
|
40
40
|
describe("password auth", () => {
|
|
41
41
|
test("serves the app shell to unauthenticated browser requests", async () => {
|
|
42
|
-
|
|
42
|
+
// Create a minimal client bundle fixture so the static file handler can serve it.
|
|
43
|
+
// In CI, `bun run build` produces dist/client/index.html before tests run.
|
|
44
|
+
// Locally (no prior build) we inject a temp distDir via the test-only option.
|
|
45
|
+
const distDir = await mkdtemp(path.join(tmpdir(), "kanna-dist-"))
|
|
46
|
+
tempDirs.push(distDir)
|
|
47
|
+
await writeFile(
|
|
48
|
+
path.join(distDir, "index.html"),
|
|
49
|
+
'<!DOCTYPE html><html><body><div id="root"></div></body></html>',
|
|
50
|
+
"utf8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-test-"))
|
|
54
|
+
const dataDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-data-"))
|
|
55
|
+
tempDirs.push(projectDir, dataDir)
|
|
56
|
+
const server = await startKannaServer({
|
|
57
|
+
dataDir,
|
|
58
|
+
distDir,
|
|
59
|
+
port: 4320,
|
|
60
|
+
strictPort: true,
|
|
61
|
+
password: "secret",
|
|
62
|
+
trustProxy: false,
|
|
63
|
+
})
|
|
64
|
+
await server.store.openProject(projectDir, "Project")
|
|
43
65
|
|
|
44
66
|
try {
|
|
45
67
|
const response = await fetch(`http://localhost:${server.port}/chat/demo`, { headers: { Accept: "text/html" } })
|