From 2ec18964b7bca5606476d0203722222aee484287 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Fri, 19 Jun 2026 01:07:16 +0200 Subject: [PATCH] Initial Coworker app scaffold --- .codex/project.md | 75 ++++ .gitea/workflows/build.yml | 47 +++ .gitea/workflows/dependency-check.yml | 22 ++ .gitea/workflows/release-dry-run.yml | 31 ++ .gitea/workflows/release.yml | 72 ++++ .gitea/workflows/repo-cleanup.yml | 27 ++ .gitea/workflows/security-scan.yml | 30 ++ .gitea/workflows/template-compliance.yml | 35 ++ .gitignore | 17 + AGENTS.md | 59 +++ CHANGELOG.md | 6 + CONTRIBUTING.md | 30 ++ LICENSE | 21 ++ README.md | 72 ++++ SECURITY.md | 29 ++ apps/desktop/electron/main.ts | 416 ++++++++++++++++++++ apps/desktop/electron/preload.ts | 31 ++ apps/desktop/index.html | 17 + apps/desktop/package.json | 6 + apps/desktop/public/icon.svg | 5 + apps/desktop/public/manifest.webmanifest | 17 + apps/desktop/public/sw.js | 13 + apps/desktop/src/api.ts | 86 +++++ apps/desktop/src/global.d.ts | 7 + apps/desktop/src/main.tsx | 310 +++++++++++++++ apps/desktop/src/styles.css | 461 +++++++++++++++++++++++ apps/desktop/tsconfig.electron.json | 13 + apps/desktop/tsconfig.json | 16 + apps/desktop/vite.config.ts | 21 ++ docs/agent-handoff.md | 17 + docs/release-checklist.md | 10 + docs/release-notes.md | 17 + docs/security-review.md | 19 + package.json | 77 ++++ packages/core/package.json | 10 + packages/core/src/agent.ts | 60 +++ packages/core/src/brain.test.ts | 26 ++ packages/core/src/brain.ts | 76 ++++ packages/core/src/ids.ts | 9 + packages/core/src/index.ts | 6 + packages/core/src/pairing.ts | 30 ++ packages/core/src/providers.ts | 65 ++++ packages/core/src/types.ts | 129 +++++++ packages/core/tsconfig.json | 13 + tsconfig.base.json | 19 + tsconfig.json | 7 + 46 files changed, 2582 insertions(+) create mode 100644 .codex/project.md create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/dependency-check.yml create mode 100644 .gitea/workflows/release-dry-run.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/repo-cleanup.yml create mode 100644 .gitea/workflows/security-scan.yml create mode 100644 .gitea/workflows/template-compliance.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/desktop/electron/main.ts create mode 100644 apps/desktop/electron/preload.ts create mode 100644 apps/desktop/index.html create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/public/icon.svg create mode 100644 apps/desktop/public/manifest.webmanifest create mode 100644 apps/desktop/public/sw.js create mode 100644 apps/desktop/src/api.ts create mode 100644 apps/desktop/src/global.d.ts create mode 100644 apps/desktop/src/main.tsx create mode 100644 apps/desktop/src/styles.css create mode 100644 apps/desktop/tsconfig.electron.json create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/desktop/vite.config.ts create mode 100644 docs/agent-handoff.md create mode 100644 docs/release-checklist.md create mode 100644 docs/release-notes.md create mode 100644 docs/security-review.md create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/agent.ts create mode 100644 packages/core/src/brain.test.ts create mode 100644 packages/core/src/brain.ts create mode 100644 packages/core/src/ids.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/pairing.ts create mode 100644 packages/core/src/providers.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json diff --git a/.codex/project.md b/.codex/project.md new file mode 100644 index 0000000..7ba5f14 --- /dev/null +++ b/.codex/project.md @@ -0,0 +1,75 @@ +# Codex Project Notes + +## Project + +`Coworker` is a local-first Windows AI coding coworker. It combines a Codex-style desktop app, local model provider configuration, a persistent Brain for memory and skills, and optional iPhone/iPad PWA access. + +Repository: + +```text +Code-Inc/Coworker +``` + +## Stack + +```text +Electron, React, TypeScript, Vite +``` + +Package manager: + +```text +npm +``` + +## Commands + +Run only on Gitea Ubuntu runners: + +```text +INSTALL_COMMAND = npm install +LINT_COMMAND = npm run lint +TEST_COMMAND = npm run test +BUILD_COMMAND = npm run build +AUDIT_COMMAND = npm run audit +PACKAGE_COMMAND = npm run package:win +``` + +Local checks are limited to lightweight validation such as `git diff --check`. + +## Build Artifacts + +Release artifacts are produced in: + +```text +release/ +``` + +Expected files: + +```text +Coworker-Setup-.exe +SHA256SUMS.txt +``` + +## Security Rules + +- Web access is disabled by default. +- LAN/PWA access requires pairing-code authentication and scoped device tokens. +- Workspace tools may access only explicitly selected project directories. +- Agent writes and command execution require visible user approval. +- Local provider endpoints and tokens are stored in user app data, not tracked files. +- Unsigned installers are acceptable until a code-signing certificate is available. + +## Release Rules + +Before a release: + +1. verify Gitea CI is green, +2. run release dry-run workflow, +3. confirm `Coworker-Setup-.exe` and `SHA256SUMS.txt` exist, +4. update `CHANGELOG.md` and release notes, +5. create a `v*` tag, +6. let Gitea Actions publish the release artifact. + +Do not create a release unless explicitly requested. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..e6cfef9 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,47 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test + + - name: Build + run: npm run build + + - name: Build Windows installer + run: | + docker run --rm \ + -v "$PWD":/project \ + -w /project \ + electronuserland/builder:wine \ + /bin/bash -lc "npm run package:win" + + - name: Checksums + run: | + cd release + sha256sum Coworker-Setup-*.exe > SHA256SUMS.txt + + - name: Upload installer artifact + uses: actions/upload-artifact@v3 + with: + name: coworker-windows-installer + path: | + release/Coworker-Setup-*.exe + release/SHA256SUMS.txt diff --git a/.gitea/workflows/dependency-check.yml b/.gitea/workflows/dependency-check.yml new file mode 100644 index 0000000..c44db0a --- /dev/null +++ b/.gitea/workflows/dependency-check.yml @@ -0,0 +1,22 @@ +name: Scheduled Dependency Check + +on: + schedule: + - cron: "29 3 * * 2" + workflow_dispatch: + +jobs: + dependency-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Audit + run: npm run audit + + - name: Outdated report + run: npm outdated || true diff --git a/.gitea/workflows/release-dry-run.yml b/.gitea/workflows/release-dry-run.yml new file mode 100644 index 0000000..a794e9d --- /dev/null +++ b/.gitea/workflows/release-dry-run.yml @@ -0,0 +1,31 @@ +name: Release Dry Run + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + release-dry-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate release metadata + run: | + test -f README.md + test -f CHANGELOG.md + test -f docs/release-checklist.md + test -f docs/release-notes.md + grep -q "Coworker-Setup" README.md + if rg "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND" .; then + echo "Unresolved template placeholder found" + exit 1 + fi + + - name: Install dependencies + run: npm install + + - name: Release check + run: npm run release:check diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ddb21a1 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Validate + run: npm run release:check + + - name: Build Windows installer + run: | + docker run --rm \ + -v "$PWD":/project \ + -w /project \ + electronuserland/builder:wine \ + /bin/bash -lc "npm run package:win" + + - name: Checksums + run: | + cd release + sha256sum Coworker-Setup-*.exe > SHA256SUMS.txt + + - name: Create Gitea release and upload assets + run: | + test -n "$GITEA_TOKEN" + server="${GITHUB_SERVER_URL:-https://git.wilkensxl.de}" + repo="${GITHUB_REPOSITORY:-Code-Inc/Coworker}" + tag="${GITHUB_REF_NAME:-manual}" + body="$(python3 - <<'PY' +from pathlib import Path +print(Path("docs/release-notes.md").read_text(encoding="utf-8")) +PY +)" + release_json="$(python3 - <.exe +release/SHA256SUMS.txt +``` + +The installer is unsigned until a Windows code-signing certificate is configured. The app can check Gitea releases for updates, but silent auto-update is intentionally deferred until signing exists. + +## Web/PWA Access + +Web access is disabled by default. Enable it in Settings, choose localhost or LAN/Tailscale binding, then create a pairing code. Open the displayed URL on iPhone or iPad and add Coworker to the Home Screen. + +Security defaults: + +- pairing is required, +- device tokens are stored hashed, +- paired devices can be revoked in a future settings panel, +- workspace access stays inside selected project folders. + +## Architecture + +```text +apps/desktop Electron main process, preload bridge, React renderer +packages/core Agent, Brain, provider, pairing, and shared types +docs release, security, and project handoff documents +.gitea/workflows Gitea CI, installer, security, and release automation +``` + +## Release + +1. Ensure the Gitea build workflow succeeds. +2. Update `CHANGELOG.md`. +3. Create a `v*` tag. +4. Let the release workflow upload the installer and checksum to Gitea. + +Do not create a release from a local build. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8da0fa9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security + +Coworker is a local-first coding app with powerful project and agent capabilities. Treat it like a developer tool with access to your files and local model endpoints. + +## Reporting + +Open a private issue or contact the repository owner if a vulnerability exposes files, tokens, local services, paired devices, or agent permissions. + +## Defaults + +- Web access is disabled by default. +- LAN/Tailscale access requires an explicit settings change. +- Mobile access requires a pairing code. +- Device tokens are stored as hashes. +- Workspace file access is confined to selected project roots. +- Agent file writes and command execution require visible approval. + +## Secrets + +Never commit: + +- `.env` files, +- API keys, +- provider tokens, +- pairing tokens, +- certificates, +- private keys, +- app data, +- generated installers outside release artifacts. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts new file mode 100644 index 0000000..44a95ed --- /dev/null +++ b/apps/desktop/electron/main.ts @@ -0,0 +1,416 @@ +import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; +import { createHash, randomBytes } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { createServer, IncomingMessage, Server, ServerResponse } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + AppState, + BrainInput, + ModelProvider, + WebAccessSettings, + createBrainItem, + createPairedDevice, + createPairingChallenge, + createProvider, + isPairingChallengeValid, + nowIso, + probeOpenAiCompatibleProvider, + runAgent, + updateBrainItem +} from "@coworker/core"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rendererDir = path.resolve(__dirname, "../dist"); +const stateFile = () => path.join(app.getPath("userData"), "state.json"); + +let mainWindow: BrowserWindow | undefined; +let webServer: Server | undefined; +let activePairing = createPairingChallenge(0); + +const defaultState: AppState = { + providers: [ + createProvider("ollama", "Ollama", "http://localhost:11434/v1"), + createProvider("lmstudio", "LM Studio", "http://localhost:1234/v1") + ], + brain: [], + workspaces: [], + webAccess: { + enabled: false, + bindHost: "127.0.0.1", + port: 4789, + requirePairing: true + }, + pairedDevices: [] +}; + +async function readState(): Promise { + try { + return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) }; + } catch { + return defaultState; + } +} + +async function writeState(state: AppState): Promise { + await mkdir(path.dirname(stateFile()), { recursive: true }); + await writeFile(stateFile(), JSON.stringify(state, null, 2), "utf8"); + return state; +} + +async function mutateState(mutator: (state: AppState) => AppState | Promise): Promise { + const current = await readState(); + const next = await mutator(current); + return writeState(next); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1320, + height: 860, + minWidth: 980, + minHeight: 680, + title: "Coworker", + backgroundColor: "#111317", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false + } + }); + + const devUrl = process.env.VITE_DEV_SERVER_URL; + if (devUrl) { + void mainWindow.loadURL(devUrl); + } else { + void mainWindow.loadFile(path.join(rendererDir, "index.html")); + } + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); +} + +function registerIpc() { + ipcMain.handle("state:get", () => readState()); + + ipcMain.handle("providers:save", async (_event, provider: ModelProvider) => mutateState((state) => { + const next = { ...provider, baseUrl: provider.baseUrl.replace(/\/+$/, ""), updatedAt: nowIso() }; + return { + ...state, + providers: state.providers.some((item) => item.id === next.id) + ? state.providers.map((item) => item.id === next.id ? next : item) + : [...state.providers, next] + }; + })); + + ipcMain.handle("providers:remove", async (_event, id: string) => mutateState((state) => ({ + ...state, + providers: state.providers.filter((provider) => provider.id !== id) + }))); + + ipcMain.handle("providers:probe", async (_event, id: string) => { + const state = await readState(); + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new Error("Provider not found."); + return probeOpenAiCompatibleProvider(provider); + }); + + ipcMain.handle("brain:add", async (_event, input: BrainInput) => mutateState((state) => ({ + ...state, + brain: [createBrainItem(input), ...state.brain] + }))); + + ipcMain.handle("brain:update", async (_event, id: string, patch: Partial) => mutateState((state) => ({ + ...state, + brain: state.brain.map((item) => item.id === id ? updateBrainItem(item, patch) : item) + }))); + + ipcMain.handle("brain:remove", async (_event, id: string) => mutateState((state) => ({ + ...state, + brain: state.brain.filter((item) => item.id !== id) + }))); + + ipcMain.handle("workspace:open", async () => { + const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); + if (result.canceled || !result.filePaths[0]) return undefined; + const rootPath = result.filePaths[0]; + const workspace = { + id: createHash("sha256").update(rootPath).digest("hex").slice(0, 16), + name: path.basename(rootPath), + rootPath, + openedAt: nowIso() + }; + await mutateState((state) => ({ + ...state, + workspaces: [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)] + })); + return workspace; + }); + + ipcMain.handle("workspace:list", async (_event, id: string) => { + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) throw new Error("Workspace not found."); + const entries = await readdir(workspace.rootPath, { withFileTypes: true }); + return Promise.all(entries.slice(0, 200).map(async (entry) => { + const absolute = path.join(workspace.rootPath, entry.name); + const info = await stat(absolute); + return { + path: entry.name, + type: entry.isDirectory() ? "directory" : "file", + size: entry.isFile() ? info.size : undefined, + modifiedAt: info.mtime.toISOString() + }; + })); + }); + + ipcMain.handle("workspace:read", async (_event, id: string, relativePath: string) => { + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) throw new Error("Workspace not found."); + const absolute = confinedPath(workspace.rootPath, relativePath); + const info = await stat(absolute); + if (!info.isFile() || info.size > 512 * 1024) throw new Error("File is not readable in preview."); + return readFile(absolute, "utf8"); + }); + + ipcMain.handle("agent:run", async (_event, request) => { + const state = await readState(); + const provider = request.providerId + ? state.providers.find((item) => item.id === request.providerId) + : state.providers.find((item) => item.enabled); + const model = request.model ?? provider?.defaults.coding ?? provider?.defaults.chat; + return runAgent( + { + prompt: String(request.prompt ?? ""), + workspace: request.workspace, + provider, + model, + messages: request.messages ?? [], + brain: state.brain + }, + (messages) => completeWithProvider(provider, model, messages) + ); + }); + + ipcMain.handle("web:settings", async (_event, settings: WebAccessSettings) => { + const state = await mutateState((current) => ({ ...current, webAccess: settings })); + await restartWebServer(state.webAccess); + return state; + }); + + ipcMain.handle("web:pairing:create", () => { + activePairing = createPairingChallenge(); + return activePairing; + }); +} + +async function completeWithProvider(provider: ModelProvider | undefined, model: string | undefined, messages: { role: string; content: string }[]) { + if (!provider || !model) { + return "No model is configured yet. Add Ollama, LM Studio, or another OpenAI-compatible provider in Settings."; + } + const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}) + }, + body: JSON.stringify({ + model, + messages: messages.map((message) => ({ role: message.role, content: message.content })), + temperature: 0.2 + }) + }); + if (!response.ok) return `Model request failed with HTTP ${response.status}.`; + const body = await response.json() as { choices?: Array<{ message?: { content?: string } }> }; + return body.choices?.[0]?.message?.content ?? "The model returned an empty response."; +} + +function confinedPath(root: string, relativePath: string) { + const absolute = path.resolve(root, relativePath); + const normalizedRoot = path.resolve(root); + if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) { + throw new Error("Path is outside the workspace."); + } + return absolute; +} + +async function restartWebServer(settings: WebAccessSettings) { + if (webServer) { + await new Promise((resolve) => webServer?.close(() => resolve())); + webServer = undefined; + } + if (!settings.enabled) return; + webServer = createServer(handleWebRequest); + await new Promise((resolve, reject) => { + webServer?.listen(settings.port, settings.bindHost, resolve); + webServer?.on("error", reject); + }); +} + +async function handleWebRequest(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url ?? "/", "http://coworker.local"); + if (url.pathname === "/api/pair" && req.method === "POST") return handlePair(req, res); + if (url.pathname === "/api/state") return sendJson(res, await publicState(req)); + if (url.pathname.startsWith("/api/") && !(await isAuthorized(req))) return sendJson(res, { error: "Pairing required." }, 401); + if (url.pathname === "/api/providers" && req.method === "POST") return handleWebProviderSave(req, res); + if (url.pathname.match(/^\/api\/providers\/[^/]+\/probe$/) && req.method === "POST") return handleWebProviderProbe(url, res); + if (url.pathname === "/api/brain" && req.method === "POST") return handleWebBrainAdd(req, res); + if (url.pathname.match(/^\/api\/brain\/[^/]+$/) && req.method === "DELETE") return handleWebBrainRemove(url, res); + if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files$/)) return handleWebWorkspaceList(url, res); + if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/.+$/)) return handleWebWorkspaceRead(url, res); + if (url.pathname === "/api/agent/run" && req.method === "POST") return handleWebAgent(req, res); + return serveStatic(url.pathname, res); +} + +async function handlePair(req: IncomingMessage, res: ServerResponse) { + const body = await readJsonBody<{ code?: string; name?: string }>(req); + if (!isPairingChallengeValid(activePairing, body.code ?? "")) return sendJson(res, { error: "Invalid pairing code." }, 403); + const token = randomBytes(24).toString("hex"); + const device = await createPairedDevice(body.name ?? "Mobile device", token, digest); + await mutateState((state) => ({ ...state, pairedDevices: [device, ...state.pairedDevices] })); + res.setHeader("Set-Cookie", `coworker_token=${token}; HttpOnly; SameSite=Lax; Path=/`); + sendJson(res, { token }); +} + +async function handleWebProviderSave(req: IncomingMessage, res: ServerResponse) { + const provider = await readJsonBody(req); + const state = await mutateState((current) => ({ + ...current, + providers: current.providers.some((item) => item.id === provider.id) + ? current.providers.map((item) => item.id === provider.id ? { ...provider, updatedAt: nowIso() } : item) + : [...current.providers, { ...createProvider(provider.kind, provider.name, provider.baseUrl), ...provider, updatedAt: nowIso() }] + })); + sendJson(res, sanitizeState(state)); +} + +async function handleWebProviderProbe(url: URL, res: ServerResponse) { + const id = decodeURIComponent(url.pathname.split("/")[3]); + const provider = (await readState()).providers.find((item) => item.id === id); + if (!provider) return sendJson(res, { error: "Provider not found." }, 404); + sendJson(res, await probeOpenAiCompatibleProvider(provider)); +} + +async function handleWebBrainAdd(req: IncomingMessage, res: ServerResponse) { + const input = await readJsonBody(req); + const state = await mutateState((current) => ({ ...current, brain: [createBrainItem(input), ...current.brain] })); + sendJson(res, sanitizeState(state)); +} + +async function handleWebBrainRemove(url: URL, res: ServerResponse) { + const id = decodeURIComponent(url.pathname.split("/")[3]); + const state = await mutateState((current) => ({ ...current, brain: current.brain.filter((item) => item.id !== id) })); + sendJson(res, sanitizeState(state)); +} + +async function handleWebWorkspaceList(url: URL, res: ServerResponse) { + const id = decodeURIComponent(url.pathname.split("/")[3]); + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404); + const entries = await readdir(workspace.rootPath, { withFileTypes: true }); + const files = await Promise.all(entries.slice(0, 200).map(async (entry) => { + const absolute = path.join(workspace.rootPath, entry.name); + const info = await stat(absolute); + return { + path: entry.name, + type: entry.isDirectory() ? "directory" : "file", + size: entry.isFile() ? info.size : undefined, + modifiedAt: info.mtime.toISOString() + }; + })); + sendJson(res, files); +} + +async function handleWebWorkspaceRead(url: URL, res: ServerResponse) { + const parts = url.pathname.split("/"); + const id = decodeURIComponent(parts[3]); + const relativePath = decodeURIComponent(parts.slice(5).join("/")); + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404); + const absolute = confinedPath(workspace.rootPath, relativePath); + const info = await stat(absolute); + if (!info.isFile() || info.size > 512 * 1024) return sendJson(res, { error: "File is not readable in preview." }, 400); + sendJson(res, { content: await readFile(absolute, "utf8") }); +} + +async function handleWebAgent(req: IncomingMessage, res: ServerResponse) { + const body = await readJsonBody<{ prompt?: string; messages?: unknown[]; providerId?: string; model?: string }>(req); + const state = await readState(); + const provider = body.providerId ? state.providers.find((item) => item.id === body.providerId) : state.providers[0]; + const result = await runAgent( + { prompt: body.prompt ?? "", messages: [], brain: state.brain, provider, model: body.model }, + (messages) => completeWithProvider(provider, body.model ?? provider?.defaults.chat, messages) + ); + sendJson(res, result); +} + +async function publicState(req: IncomingMessage) { + const state = await readState(); + const paired = await isAuthorized(req); + return { + paired, + webAccess: state.webAccess, + providers: paired ? state.providers.map(({ apiKey, ...provider }) => provider) : [], + brain: paired ? state.brain : [], + workspaces: paired ? state.workspaces : [] + }; +} + +async function isAuthorized(req: IncomingMessage) { + const token = (req.headers.cookie ?? "").split(";").map((item) => item.trim()).find((item) => item.startsWith("coworker_token="))?.split("=")[1]; + if (!token) return false; + const hash = await digest(token); + const state = await readState(); + return state.pairedDevices.some((device) => !device.revokedAt && device.tokenHash === hash); +} + +async function digest(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +function sanitizeState(state: AppState): AppState { + return { + ...state, + providers: state.providers.map(({ apiKey, ...provider }) => provider), + pairedDevices: state.pairedDevices.map((device) => ({ ...device, tokenHash: "" })) + }; +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as T; +} + +function sendJson(res: ServerResponse, data: unknown, status = 200) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function serveStatic(urlPath: string, res: ServerResponse) { + try { + const safePath = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, ""); + const absolute = confinedPath(rendererDir, safePath); + const finalPath = absolute.endsWith(path.sep) ? path.join(absolute, "index.html") : absolute; + createReadStream(finalPath) + .on("error", () => createReadStream(path.join(rendererDir, "index.html")).pipe(res)) + .pipe(res); + } catch { + res.writeHead(403); + res.end("Forbidden"); + } +} + +app.whenReady().then(async () => { + registerIpc(); + createWindow(); + await restartWebServer((await readState()).webAccess); + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts new file mode 100644 index 0000000..988ffad --- /dev/null +++ b/apps/desktop/electron/preload.ts @@ -0,0 +1,31 @@ +import { contextBridge, ipcRenderer } from "electron"; + +const api = { + state: () => ipcRenderer.invoke("state:get"), + providers: { + save: (provider: unknown) => ipcRenderer.invoke("providers:save", provider), + remove: (id: string) => ipcRenderer.invoke("providers:remove", id), + probe: (id: string) => ipcRenderer.invoke("providers:probe", id) + }, + brain: { + add: (input: unknown) => ipcRenderer.invoke("brain:add", input), + update: (id: string, patch: unknown) => ipcRenderer.invoke("brain:update", id, patch), + remove: (id: string) => ipcRenderer.invoke("brain:remove", id) + }, + workspace: { + open: () => ipcRenderer.invoke("workspace:open"), + list: (id: string) => ipcRenderer.invoke("workspace:list", id), + read: (id: string, relativePath: string) => ipcRenderer.invoke("workspace:read", id, relativePath) + }, + agent: { + run: (request: unknown) => ipcRenderer.invoke("agent:run", request) + }, + web: { + saveSettings: (settings: unknown) => ipcRenderer.invoke("web:settings", settings), + createPairing: () => ipcRenderer.invoke("web:pairing:create") + } +}; + +contextBridge.exposeInMainWorld("coworker", api); + +export type CoworkerApi = typeof api; diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..1bc2205 --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Coworker + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..9e00fb9 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,6 @@ +{ + "name": "@coworker/desktop", + "version": "0.1.0", + "type": "module", + "private": true +} diff --git a/apps/desktop/public/icon.svg b/apps/desktop/public/icon.svg new file mode 100644 index 0000000..bdec161 --- /dev/null +++ b/apps/desktop/public/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/public/manifest.webmanifest b/apps/desktop/public/manifest.webmanifest new file mode 100644 index 0000000..1feb38b --- /dev/null +++ b/apps/desktop/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Coworker", + "short_name": "Coworker", + "display": "standalone", + "background_color": "#111317", + "theme_color": "#111317", + "start_url": "/", + "scope": "/", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/apps/desktop/public/sw.js b/apps/desktop/public/sw.js new file mode 100644 index 0000000..2c51f8a --- /dev/null +++ b/apps/desktop/public/sw.js @@ -0,0 +1,13 @@ +self.addEventListener("install", (event) => { + event.waitUntil(caches.open("coworker-shell-v1").then((cache) => cache.addAll(["/", "/manifest.webmanifest", "/icon.svg"]))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((cached) => cached || caches.match("/")))); +}); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts new file mode 100644 index 0000000..a9f7ac5 --- /dev/null +++ b/apps/desktop/src/api.ts @@ -0,0 +1,86 @@ +import type { + AgentRunResult, + AppState, + BrainInput, + BrainItem, + ModelProvider, + PairingChallenge, + ProviderProbeResult, + WebAccessSettings, + WorkspaceFile, + WorkspaceState +} from "@coworker/core"; + +type AgentRequest = { + prompt: string; + providerId?: string; + model?: string; + workspace?: WorkspaceState; + messages?: Array<{ role: string; content: string }>; +}; + +async function json(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}) + } + }); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export const coworkerApi = { + state(): Promise { + if (window.coworker) return window.coworker.state() as Promise; + return json("/api/state"); + }, + saveProvider(provider: ModelProvider): Promise { + if (window.coworker) return window.coworker.providers.save(provider) as Promise; + return json("/api/providers", { method: "POST", body: JSON.stringify(provider) }); + }, + probeProvider(id: string): Promise { + if (window.coworker) return window.coworker.providers.probe(id) as Promise; + return json(`/api/providers/${id}/probe`, { method: "POST" }); + }, + addBrain(input: BrainInput): Promise { + if (window.coworker) return window.coworker.brain.add(input) as Promise; + return json("/api/brain", { method: "POST", body: JSON.stringify(input) }); + }, + removeBrain(id: string): Promise { + if (window.coworker) return window.coworker.brain.remove(id) as Promise; + return json(`/api/brain/${id}`, { method: "DELETE" }); + }, + openWorkspace(): Promise { + if (window.coworker) return window.coworker.workspace.open() as Promise; + throw new Error("Workspace selection is available in the desktop app."); + }, + listWorkspace(id: string): Promise { + if (window.coworker) return window.coworker.workspace.list(id) as Promise; + return json(`/api/workspaces/${id}/files`); + }, + readWorkspace(id: string, relativePath: string): Promise { + if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise; + return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content); + }, + runAgent(request: AgentRequest): Promise { + if (window.coworker) return window.coworker.agent.run(request) as Promise; + return json("/api/agent/run", { method: "POST", body: JSON.stringify(request) }); + }, + saveWebSettings(settings: WebAccessSettings): Promise { + if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise; + throw new Error("Web settings are available in the desktop app."); + }, + createPairing(): Promise { + if (window.coworker) return window.coworker.web.createPairing() as Promise; + throw new Error("Pairing is started from the desktop app."); + }, + pair(code: string, name: string): Promise<{ token: string }> { + return json<{ token: string }>("/api/pair", { method: "POST", body: JSON.stringify({ code, name }) }); + } +}; + +export function displayBrainItem(item: BrainItem) { + return `${item.kind.toUpperCase()} / ${item.scope}${item.pinned ? " / PINNED" : ""}`; +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts new file mode 100644 index 0000000..43412e1 --- /dev/null +++ b/apps/desktop/src/global.d.ts @@ -0,0 +1,7 @@ +import type { CoworkerApi } from "../electron/preload"; + +declare global { + interface Window { + coworker?: CoworkerApi; + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..3254104 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { AppState, BrainItem, ChatMessage, ModelProvider, WorkspaceFile, WorkspaceState } from "@coworker/core"; +import { createProvider } from "@coworker/core"; +import { coworkerApi, displayBrainItem } from "./api"; +import "./styles.css"; + +type View = "agent" | "models" | "brain" | "files" | "settings"; + +const emptyState: AppState = { + providers: [], + brain: [], + workspaces: [], + webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true }, + pairedDevices: [] +}; + +function App() { + const [state, setState] = useState(emptyState); + const [view, setView] = useState("agent"); + const [error, setError] = useState(""); + const [workspace, setWorkspace] = useState(); + + async function refresh() { + try { + setState(await coworkerApi.state()); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load Coworker state."); + } + } + + useEffect(() => { + void refresh(); + if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js").catch(() => undefined); + }, []); + + useEffect(() => { + setWorkspace(state.workspaces[0]); + }, [state.workspaces]); + + const activeProvider = state.providers.find((provider) => provider.enabled) ?? state.providers[0]; + + return ( +
+ + +
+
+
+

{label(view)}

+

{subtitle(view, workspace)}

+
+ {error ? {error} : Ready} +
+ + {view === "agent" && } + {view === "files" && } + {view === "models" && } + {view === "brain" && } + {view === "settings" && } +
+
+ ); +} + +function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) { + const [prompt, setPrompt] = useState(""); + const [messages, setMessages] = useState([]); + const [running, setRunning] = useState(false); + const provider = state.providers.find((item) => item.enabled) ?? state.providers[0]; + const model = provider?.defaults.coding ?? provider?.defaults.chat; + + async function run() { + if (!prompt.trim()) return; + setRunning(true); + const userMessage: ChatMessage = { + id: `local_${Date.now()}`, + role: "user", + content: prompt, + createdAt: new Date().toISOString() + }; + setMessages((current) => [...current, userMessage]); + setPrompt(""); + try { + const result = await coworkerApi.runAgent({ prompt, providerId: provider?.id, model, workspace, messages }); + setMessages((current) => [...current, result.assistantMessage]); + onError(""); + } catch (err) { + onError(err instanceof Error ? err.message : "Agent failed."); + } finally { + setRunning(false); + } + } + + return ( +
+
+ {messages.length === 0 ? ( +
+

Start a coding session

+

Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.

+
+ ) : messages.map((message) => ( +
+ {message.role} +
{message.content}
+
+ ))} +
+