kcg-code 0.0.0-stage → 0.1.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/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace (Sandbox_Root) — validasi path, penulisan config, dan rute HTTP.
|
|
3
|
+
*/
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { openSessionStore } from "../../../db";
|
|
17
|
+
import type { Project } from "../../../types";
|
|
18
|
+
import type { ApiRouteContext } from "../../routes/types";
|
|
19
|
+
import { workspaceRoutes } from "../../routes/workspace.routes";
|
|
20
|
+
import { createAuthService } from "../auth";
|
|
21
|
+
import { createProjectManager } from "../project-manager";
|
|
22
|
+
import {
|
|
23
|
+
displayPath,
|
|
24
|
+
expandUserPath,
|
|
25
|
+
isTooBroad,
|
|
26
|
+
projectsOutside,
|
|
27
|
+
validateWorkspaceRoot,
|
|
28
|
+
writeSandboxRoot,
|
|
29
|
+
} from "../workspace";
|
|
30
|
+
|
|
31
|
+
let base: string;
|
|
32
|
+
let home: string;
|
|
33
|
+
let data: string;
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
base = mkdtempSync(path.join(tmpdir(), "kcg-ws-"));
|
|
37
|
+
home = path.join(base, "home");
|
|
38
|
+
data = path.join(home, ".kcgcode", "data");
|
|
39
|
+
mkdirSync(path.join(home, "code", "a"), { recursive: true });
|
|
40
|
+
mkdirSync(path.join(home, "work"), { recursive: true });
|
|
41
|
+
mkdirSync(data, { recursive: true });
|
|
42
|
+
writeFileSync(path.join(home, "file.txt"), "x");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(() => {
|
|
46
|
+
rmSync(base, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const opts = (over: Partial<Parameters<typeof validateWorkspaceRoot>[1]> = {}) => ({
|
|
50
|
+
create: false,
|
|
51
|
+
protectedDirs: [data],
|
|
52
|
+
home,
|
|
53
|
+
...over,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("util path", () => {
|
|
57
|
+
test("displayPath & expandUserPath memakai ~", () => {
|
|
58
|
+
expect(displayPath(path.join(home, "code"), home)).toBe("~/code");
|
|
59
|
+
expect(displayPath(home, home)).toBe("~");
|
|
60
|
+
expect(displayPath("/srv/x", home)).toBe("/srv/x");
|
|
61
|
+
expect(expandUserPath("~/code", home)).toBe(path.join(home, "code"));
|
|
62
|
+
expect(expandUserPath("relative/x", home)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("isTooBroad: root fs, home, leluhur home, direktori sistem", () => {
|
|
66
|
+
expect(isTooBroad("/", home)).toBe(true);
|
|
67
|
+
expect(isTooBroad(home, home)).toBe(true);
|
|
68
|
+
expect(isTooBroad(path.dirname(home), home)).toBe(true);
|
|
69
|
+
expect(isTooBroad(path.join(home, "code"), home)).toBe(false);
|
|
70
|
+
if (process.platform !== "win32") {
|
|
71
|
+
expect(isTooBroad("/etc", home)).toBe(true);
|
|
72
|
+
expect(isTooBroad("/usr", home)).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("validateWorkspaceRoot", () => {
|
|
78
|
+
test("folder valid -> realpath", () => {
|
|
79
|
+
const r = validateWorkspaceRoot("~/code", opts());
|
|
80
|
+
expect(r).toEqual({ ok: true, data: { root: path.join(home, "code"), willCreate: false } });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("ditolak: kosong, relatif, tidak ada, bukan folder", () => {
|
|
84
|
+
expect(validateWorkspaceRoot("", opts()).ok).toBe(false);
|
|
85
|
+
expect(validateWorkspaceRoot("code", opts())).toMatchObject({
|
|
86
|
+
error: "WORKSPACE_PATH_NOT_ABSOLUTE",
|
|
87
|
+
});
|
|
88
|
+
expect(validateWorkspaceRoot("~/nope", opts())).toMatchObject({ error: "WORKSPACE_NOT_FOUND" });
|
|
89
|
+
expect(validateWorkspaceRoot("~/file.txt", opts())).toMatchObject({
|
|
90
|
+
error: "WORKSPACE_NOT_DIRECTORY",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("terlalu luas: home & root fs", () => {
|
|
95
|
+
expect(validateWorkspaceRoot("~", opts())).toMatchObject({ error: "WORKSPACE_TOO_BROAD" });
|
|
96
|
+
expect(validateWorkspaceRoot("/", opts())).toMatchObject({ error: "WORKSPACE_TOO_BROAD" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("tumpang tindih dengan data KCG Code ditolak (dua arah)", () => {
|
|
100
|
+
// Workspace MEMUAT folder data.
|
|
101
|
+
expect(validateWorkspaceRoot("~/.kcgcode", opts())).toMatchObject({
|
|
102
|
+
error: "WORKSPACE_CONTAINS_APP_DATA",
|
|
103
|
+
});
|
|
104
|
+
// Workspace DI DALAM folder data.
|
|
105
|
+
mkdirSync(path.join(data, "inner"), { recursive: true });
|
|
106
|
+
expect(validateWorkspaceRoot(path.join(data, "inner"), opts())).toMatchObject({
|
|
107
|
+
error: "WORKSPACE_CONTAINS_APP_DATA",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("symlink dinilai dari lokasi aslinya", () => {
|
|
112
|
+
const link = path.join(home, "link-to-home");
|
|
113
|
+
symlinkSync(home, link);
|
|
114
|
+
expect(validateWorkspaceRoot(link, opts())).toMatchObject({ error: "WORKSPACE_TOO_BROAD" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("create: hanya satu tingkat terakhir yang boleh dibuat", () => {
|
|
118
|
+
expect(validateWorkspaceRoot("~/projects", opts({ create: true }))).toEqual({
|
|
119
|
+
ok: true,
|
|
120
|
+
data: { root: path.join(home, "projects"), willCreate: true },
|
|
121
|
+
});
|
|
122
|
+
expect(validateWorkspaceRoot("~/a/b/c", opts({ create: true }))).toMatchObject({
|
|
123
|
+
error: "WORKSPACE_PARENT_NOT_FOUND",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
128
|
+
"folder tanpa izin tulis ditolak",
|
|
129
|
+
() => {
|
|
130
|
+
const ro = path.join(home, "readonly");
|
|
131
|
+
mkdirSync(ro);
|
|
132
|
+
chmodSync(ro, 0o555);
|
|
133
|
+
try {
|
|
134
|
+
expect(validateWorkspaceRoot(ro, opts())).toMatchObject({
|
|
135
|
+
error: "WORKSPACE_NOT_WRITABLE",
|
|
136
|
+
});
|
|
137
|
+
} finally {
|
|
138
|
+
chmodSync(ro, 0o755);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("projectsOutside & writeSandboxRoot", () => {
|
|
145
|
+
const proj = (p: string): Project => ({
|
|
146
|
+
id: p,
|
|
147
|
+
name: p,
|
|
148
|
+
path: p,
|
|
149
|
+
createdAt: 0,
|
|
150
|
+
instructions: null,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("Project di luar root baru terdeteksi", () => {
|
|
154
|
+
const root = path.join(home, "code");
|
|
155
|
+
const inside = proj(path.join(root, "a"));
|
|
156
|
+
const outside = proj(path.join(home, "work"));
|
|
157
|
+
expect(projectsOutside(root, [inside, outside]).map((p) => p.id)).toEqual([outside.id]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("menulis sandboxRoot & mempertahankan kunci lain", () => {
|
|
161
|
+
const cfg = path.join(base, "config.json");
|
|
162
|
+
writeFileSync(cfg, JSON.stringify({ sandboxRoot: "/old", extra: 1 }));
|
|
163
|
+
expect(writeSandboxRoot(cfg, "/new").ok).toBe(true);
|
|
164
|
+
expect(JSON.parse(readFileSync(cfg, "utf8"))).toEqual({ sandboxRoot: "/new", extra: 1 });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("rute /api/workspace", () => {
|
|
169
|
+
async function setup(locked: boolean) {
|
|
170
|
+
const store = openSessionStore(":memory:");
|
|
171
|
+
const auth = createAuthService({
|
|
172
|
+
store,
|
|
173
|
+
hash: async (s) => `h:${s}`,
|
|
174
|
+
verify: async (s, h) => h === `h:${s}`,
|
|
175
|
+
});
|
|
176
|
+
if (locked) {
|
|
177
|
+
await auth.setLock({ kind: "pin", secret: "482915" }, { ip: "1.1.1.1", userAgent: null });
|
|
178
|
+
}
|
|
179
|
+
const root = path.join(home, "code");
|
|
180
|
+
const pm = createProjectManager(root, store);
|
|
181
|
+
const cfg = path.join(base, `cfg-${locked ? "l" : "u"}-${Math.random()}.json`);
|
|
182
|
+
writeFileSync(cfg, JSON.stringify({ sandboxRoot: root }));
|
|
183
|
+
let changed = 0;
|
|
184
|
+
const ctx = {
|
|
185
|
+
projectManager: pm,
|
|
186
|
+
notifyDataChanged: () => {
|
|
187
|
+
changed++;
|
|
188
|
+
},
|
|
189
|
+
} as unknown as ApiRouteContext;
|
|
190
|
+
// `home` diinjeksi: "~" mengarah ke folder sementara, BUKAN home asli.
|
|
191
|
+
const routes = workspaceRoutes(ctx, { auth, configPath: cfg, protectedDirs: [data], home });
|
|
192
|
+
const server = { requestIP: () => ({ address: "5.5.5.5" }) } as never;
|
|
193
|
+
const put = (body: unknown) =>
|
|
194
|
+
routes["/api/workspace"].PUT(
|
|
195
|
+
new Request("http://x/api/workspace", {
|
|
196
|
+
method: "PUT",
|
|
197
|
+
headers: { "content-type": "application/json" },
|
|
198
|
+
body: JSON.stringify(body),
|
|
199
|
+
}),
|
|
200
|
+
server,
|
|
201
|
+
);
|
|
202
|
+
return { pm, cfg, put, routes, changed: () => changed };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
test("GET: root aktif, tampilan ~, jumlah project", async () => {
|
|
206
|
+
const h = await setup(false);
|
|
207
|
+
h.pm.createProject("a", "a");
|
|
208
|
+
const res = h.routes["/api/workspace"].GET();
|
|
209
|
+
const body = (await res.json()) as { workspace: { display: string; projectCount: number } };
|
|
210
|
+
expect(body.workspace.display).toBe("~/code");
|
|
211
|
+
expect(body.workspace.projectCount).toBe(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("terkunci: sandi salah ditolak SEBELUM path divalidasi", async () => {
|
|
215
|
+
const h = await setup(true);
|
|
216
|
+
const res = await h.put({ path: "/", current: "000000" });
|
|
217
|
+
expect(res.status).toBe(401);
|
|
218
|
+
expect(((await res.json()) as { error: string }).error).toBe("CURRENT_SECRET_INVALID");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("sukses: config ditulis, root berlaku seketika, klien diberi tahu", async () => {
|
|
222
|
+
const h = await setup(true);
|
|
223
|
+
const res = await h.put({ path: "~/work", current: "482915" });
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
expect(h.pm.getSandboxRoot()).toBe(path.join(home, "work"));
|
|
226
|
+
expect(JSON.parse(readFileSync(h.cfg, "utf8")).sandboxRoot).toBe(path.join(home, "work"));
|
|
227
|
+
expect(h.changed()).toBe(1);
|
|
228
|
+
// Folder browser kini memakai root baru.
|
|
229
|
+
expect(h.pm.listDirectory("").ok).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("Project di luar root baru -> 409 berisi daftarnya, root tidak berubah", async () => {
|
|
233
|
+
const h = await setup(false);
|
|
234
|
+
h.pm.createProject("a", "a");
|
|
235
|
+
const res = await h.put({ path: "~/work" });
|
|
236
|
+
expect(res.status).toBe(409);
|
|
237
|
+
const body = (await res.json()) as { error: string; projects: { name: string }[] };
|
|
238
|
+
expect(body.error).toBe("WORKSPACE_PROJECTS_OUTSIDE");
|
|
239
|
+
expect(body.projects.map((p) => p.name)).toEqual(["a"]);
|
|
240
|
+
expect(h.pm.getSandboxRoot()).toBe(path.join(home, "code"));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("create: folder baru dibuat lalu dipakai", async () => {
|
|
244
|
+
const h = await setup(false);
|
|
245
|
+
const res = await h.put({ path: "~/fresh-ws", create: true });
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
expect(h.pm.getSandboxRoot()).toBe(path.join(home, "fresh-ws"));
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Info aplikasi untuk halaman Settings -> About.
|
|
3
|
+
*
|
|
4
|
+
* Versi diambil dari `package.json` paket (root repo / paket global),
|
|
5
|
+
* kecuali saat development (`bun dev` = mode runtime `dev` tanpa
|
|
6
|
+
* `NODE_ENV=production`): versinya ditampilkan sebagai `"dev"` agar build
|
|
7
|
+
* lokal tidak tertukar dengan rilis.
|
|
8
|
+
*/
|
|
9
|
+
import { readPackageVersion } from "../../cli/theme";
|
|
10
|
+
import { getRunMode, type RunMode } from "../../runtime";
|
|
11
|
+
|
|
12
|
+
export interface AppInfo {
|
|
13
|
+
/** Versi semver dari package.json, atau `"dev"` saat development. */
|
|
14
|
+
version: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Fungsi murni: tentukan label versi dari mode runtime & NODE_ENV. */
|
|
18
|
+
export function resolveVersionLabel(
|
|
19
|
+
packageVersion: string,
|
|
20
|
+
mode: RunMode,
|
|
21
|
+
nodeEnv: string | undefined,
|
|
22
|
+
): string {
|
|
23
|
+
return mode === "dev" && nodeEnv !== "production" ? "dev" : packageVersion;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readAppInfo(): Promise<AppInfo> {
|
|
27
|
+
const pkg = await readPackageVersion();
|
|
28
|
+
return { version: resolveVersionLabel(pkg, getRunMode(), process.env.NODE_ENV) };
|
|
29
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment_Store — penyimpanan file yang di-upload Client untuk Session
|
|
3
|
+
* (gambar, dokumen, kode, arsip, …).
|
|
4
|
+
*
|
|
5
|
+
* File dikirim dari browser ke KCG Code (HTTP), disimpan di disk sebagai
|
|
6
|
+
* file tak ternama (`<uuid>` di `<uploadsRoot>/<sessionId>/`), lalu dirujuk
|
|
7
|
+
* oleh Session sebagai part `file` + `url: file:///…` saat prompt dikirim ke
|
|
8
|
+
* opencode (lihat `session-manager.ts`; mime yang dikirim ke model dipilih
|
|
9
|
+
* `modelFacingMime` di `lib/attachments.ts`).
|
|
10
|
+
*
|
|
11
|
+
* - Semua jenis file diterima; mime dinormalisasi (`normalizeMime`) agar
|
|
12
|
+
* header Content-Type saat serve tidak bisa disuntik.
|
|
13
|
+
* - Batas 20 MiB per file (mengikuti batas attachment opencode).
|
|
14
|
+
* - File disimpan per-Session agar mudah dibersihkan saat Session dihapus
|
|
15
|
+
* (`removeSession`) dan tidak tercampur antar Session/Project.
|
|
16
|
+
* - `id` adalah UUID acak yang dibangkitkan server — path upload tidak pernah
|
|
17
|
+
* menerima masukan pengguna, sehingga aman dari path traversal.
|
|
18
|
+
* - Metadata (nama asli + mime) disimpan sebagai sidecar JSON per file agar
|
|
19
|
+
* echo pesan & part file tetap membawa nama tampilan asli pengguna.
|
|
20
|
+
*
|
|
21
|
+
* Dipakai oleh:
|
|
22
|
+
* - `app.ts` (route HTTP upload + serve),
|
|
23
|
+
* - `session-manager.ts` (resolve upload id -> file URL absolut saat kirim
|
|
24
|
+
* prompt & membersihkan file saat Session dihapus).
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { MAX_ATTACHMENT_BYTES, normalizeMime } from "../../lib/attachments";
|
|
30
|
+
import type { Result } from "../result";
|
|
31
|
+
|
|
32
|
+
/** Batas ukuran upload (byte) — mengikuti limit attachment opencode (20 MiB). */
|
|
33
|
+
export const MAX_UPLOAD_BYTES = MAX_ATTACHMENT_BYTES;
|
|
34
|
+
|
|
35
|
+
export interface AttachmentMeta {
|
|
36
|
+
/** Id lampiran (UUID acak, dibangkitkan server). */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Nama file asli dari perangkat pengguna (untuk tampilan). */
|
|
39
|
+
filename: string;
|
|
40
|
+
/** MIME ternormalisasi (`application/octet-stream` bila tidak dikenal). */
|
|
41
|
+
mime: string;
|
|
42
|
+
size: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AttachmentManager {
|
|
46
|
+
/**
|
|
47
|
+
* Simpan bytes file ke penyimpanan Session. Memvalidasi ukuran;
|
|
48
|
+
* mengembalikan metadata lampiran (termasuk `id` untuk rujukan berikutnya).
|
|
49
|
+
*/
|
|
50
|
+
save(
|
|
51
|
+
sessionId: string,
|
|
52
|
+
filename: string,
|
|
53
|
+
mime: string,
|
|
54
|
+
bytes: Uint8Array,
|
|
55
|
+
): Result<AttachmentMeta>;
|
|
56
|
+
/** Baca metadata + path absolut file lampiran (untuk part `file` opencode). */
|
|
57
|
+
info(sessionId: string, id: string): Result<AttachmentMeta & { absPath: string }>;
|
|
58
|
+
/** Baca bytes file lampiran (untuk route serve ke browser). */
|
|
59
|
+
read(
|
|
60
|
+
sessionId: string,
|
|
61
|
+
id: string,
|
|
62
|
+
): Result<{ bytes: Uint8Array; mime: string; filename: string }>;
|
|
63
|
+
/** Hapus satu lampiran (mis. pengguna membatalkan sebelum mengirim). */
|
|
64
|
+
remove(sessionId: string, id: string): void;
|
|
65
|
+
/** Hapus seluruh lampiran milik Session (saat Session dihapus permanen). */
|
|
66
|
+
removeSession(sessionId: string): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Id lampiran valid: UUID tanpa karakter path (`-` di dalamnya aman). */
|
|
70
|
+
function isValidId(id: string): boolean {
|
|
71
|
+
return /^[0-9a-fA-F-]{36}$/.test(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Path aman untuk file bytes di bawah direktori Session; mengembalikan null
|
|
76
|
+
* bila `id` bukan UUID (mencegah traversal).
|
|
77
|
+
*/
|
|
78
|
+
function safeIdPath(dir: string, id: string): string | null {
|
|
79
|
+
if (!isValidId(id)) return null;
|
|
80
|
+
return path.join(dir, id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Path aman sidecar metadata `<id>.meta.json` (id UUID tervalidasi). */
|
|
84
|
+
function safeMetaPath(dir: string, id: string): string | null {
|
|
85
|
+
if (!isValidId(id)) return null;
|
|
86
|
+
return path.join(dir, `${id}.meta.json`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function errResult(msg: string): { ok: false; error: string } {
|
|
90
|
+
return { ok: false, error: msg };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Buat direktori Session (bila belum ada). */
|
|
94
|
+
function ensureDir(dir: string): void {
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readMeta(
|
|
99
|
+
dir: string,
|
|
100
|
+
id: string,
|
|
101
|
+
): { filename: string; mime: string; size: number } | null {
|
|
102
|
+
const metaPath = safeMetaPath(dir, id);
|
|
103
|
+
if (!metaPath) return null;
|
|
104
|
+
try {
|
|
105
|
+
const raw = JSON.parse(readFileSync(metaPath, "utf8")) as {
|
|
106
|
+
filename?: unknown;
|
|
107
|
+
mime?: unknown;
|
|
108
|
+
size?: unknown;
|
|
109
|
+
};
|
|
110
|
+
if (typeof raw.filename !== "string" || typeof raw.mime !== "string") return null;
|
|
111
|
+
return {
|
|
112
|
+
filename: raw.filename,
|
|
113
|
+
mime: raw.mime,
|
|
114
|
+
size: typeof raw.size === "number" ? raw.size : 0,
|
|
115
|
+
};
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createAttachmentManager(uploadsRoot: string): AttachmentManager {
|
|
122
|
+
return {
|
|
123
|
+
save(sessionId, rawFilename, rawMime, bytes) {
|
|
124
|
+
if (bytes.byteLength === 0) return errResult("EMPTY_UPLOAD");
|
|
125
|
+
if (bytes.byteLength > MAX_UPLOAD_BYTES) return errResult("FILE_TOO_LARGE");
|
|
126
|
+
const mime = normalizeMime(rawMime);
|
|
127
|
+
// Nama tampilan saja (bukan path): buang direktori & karakter kontrol.
|
|
128
|
+
const base = rawFilename.split(/[\\/]/).pop() ?? "";
|
|
129
|
+
const filename =
|
|
130
|
+
[...base]
|
|
131
|
+
.filter((ch) => {
|
|
132
|
+
const code = ch.charCodeAt(0);
|
|
133
|
+
return code > 0x1f && code !== 0x7f;
|
|
134
|
+
})
|
|
135
|
+
.join("")
|
|
136
|
+
.slice(0, 255) || "file";
|
|
137
|
+
|
|
138
|
+
const dir = path.join(uploadsRoot, sessionId);
|
|
139
|
+
ensureDir(dir);
|
|
140
|
+
const id = randomUUID();
|
|
141
|
+
try {
|
|
142
|
+
writeFileSync(safeIdPath(dir, id) as string, bytes);
|
|
143
|
+
writeFileSync(
|
|
144
|
+
safeMetaPath(dir, id) as string,
|
|
145
|
+
JSON.stringify({ filename, mime, size: bytes.byteLength }),
|
|
146
|
+
);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return errResult(`UPLOAD_WRITE_FAILED: ${(e as Error).message}`);
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, data: { id, filename, mime, size: bytes.byteLength } };
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
info(sessionId, id) {
|
|
154
|
+
const dir = path.join(uploadsRoot, sessionId);
|
|
155
|
+
const filePath = safeIdPath(dir, id);
|
|
156
|
+
if (!filePath) return errResult("ATTACHMENT_NOT_FOUND");
|
|
157
|
+
const meta = readMeta(dir, id);
|
|
158
|
+
if (!meta) return errResult("ATTACHMENT_NOT_FOUND");
|
|
159
|
+
// `absPath` wajib absolut: dipakai untuk URL `file:///…` yang dikirim
|
|
160
|
+
// ke opencode — `file://rel/path` (host=rel) ditolak opencode ("File
|
|
161
|
+
// URL host must be localhost or empty") dan prompt gagal diam-diam.
|
|
162
|
+
return { ok: true, data: { ...meta, id, absPath: path.resolve(filePath) } };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
read(sessionId, id) {
|
|
166
|
+
const dir = path.join(uploadsRoot, sessionId);
|
|
167
|
+
const filePath = safeIdPath(dir, id);
|
|
168
|
+
if (!filePath) return errResult("ATTACHMENT_NOT_FOUND");
|
|
169
|
+
const meta = readMeta(dir, id);
|
|
170
|
+
if (!meta) return errResult("ATTACHMENT_NOT_FOUND");
|
|
171
|
+
try {
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
data: { bytes: readFileSync(filePath), mime: meta.mime, filename: meta.filename },
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
return errResult("ATTACHMENT_NOT_FOUND");
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
remove(sessionId, id) {
|
|
182
|
+
const dir = path.join(uploadsRoot, sessionId);
|
|
183
|
+
const filePath = safeIdPath(dir, id);
|
|
184
|
+
if (filePath) rmSync(filePath, { force: true });
|
|
185
|
+
const metaPath = safeMetaPath(dir, id);
|
|
186
|
+
if (metaPath) rmSync(metaPath, { force: true });
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
removeSession(sessionId) {
|
|
190
|
+
rmSync(path.join(uploadsRoot, sessionId), { recursive: true, force: true });
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|