Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ npm run package # package the app (no installers)
npm run dist # package + electron-builder → branded installers in dist/
npm run gen:icons # regenerate runtime PNG icons from assets/icon.svg
npm run gen:installer # regenerate Windows installer art (.ico + NSIS BMPs)
npm run make # legacy Forge makers (unused — makers: [] in forge.config.ts)
npm run publish # legacy Forge publish (unused; releases ship via GitLab CI)
npm run make # alias for `npm run dist` (Forge has no makers; builds the current-OS installer)
npm run publish # alias for `npm run dist:publish` (build + upload to the GitHub release feed)
```

There is **no `npm run dev`** — use `npm start` (Electron Forge drives Vite).
Expand Down Expand Up @@ -516,6 +516,25 @@ the real (no-mock) UI. Each owns one responsibility:
- **Agent Manager** (`managers/AgentManager.ts`) — drives `@anthropic-ai/claude-
agent-sdk` (plan/implement modes), risk-gated `canUseTool`, path-guarded to the
workspace, persists transcript/activity/diagnostics, resumes SDK sessions.
- **Attachment Manager** (`managers/attachments/AttachmentManager.ts` +
`validate.ts`) — ChatGPT-style composer attachments as **session-owned
workspace resources**: picker/drag-drop/paste files are validated (realpath +
symlink guard, size/count caps, name sanitize, image magic-byte + NUL sniff,
elevated-risk extension policy — attaching never executes, archives never
extracted), streamed-SHA-256-hashed with live progress (`attachment:progress`
→ the composer chip's `CircularProgress` ring), deduped per session by hash,
staged under `userData/attachments/<sessionId>/`, and recorded in the
`attachments` table (schema v9; `message_id` NULL = composer draft). The agent
consumes them tool-first: a per-turn `<attachments>` manifest + SDK
`additionalDirectories` (this session's staging dir only, read tools only via
a scoped `canUseTool` carve-out — writes/Bash stay blocked) so Read/Grep pull
content on demand; raster images additionally ride as base64 vision blocks
via one-shot streaming input (nativeImage downscale above the threshold). A
Read of a staged file flips the chip to `read`. Trash keeps staged files
(restore-safe); purge + a boot orphan sweep delete them. UI: `AttachmentStrip`
/ `AttachmentChip` in the Composer (drop zone + paste + paperclip), read-only
chips on sent turns, `useAttachmentStore`, Settings › Attachments
(`settings.attachments`, bounds in `ATTACHMENT_LIMITS`).
- **Memory System** (`managers/memory/MemoryManager.ts`) — the **Local Memory
System** (see below).
- **Search Engine** (`managers/search/SearchManager.ts`) — the **Search Engine**
Expand Down
17 changes: 15 additions & 2 deletions docs/reference/window-limboo-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ Every method maps to a channel name in
[IPC channels reference](ipc-channels.md). Subscriptions (the `on*` methods) return
an unsubscribe function.

The API has 17 namespaces:
The API has 18 namespaces:

```
window.limboo.{ window, settings, system, app, events,
workspace, session, agent, fs, terminal, git,
worktree, services, memory, search, updates, voice }
worktree, services, memory, search, updates, voice,
attachment }
Comment on lines +13 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language tag to the namespace list fence.

This fenced block will trigger the MD040 markdownlint warning. text or plaintext keeps the rendered output unchanged and silences the lint error.

Proposed fix
-```
+```text
 window.limboo.{ window, settings, system, app, events,
                workspace, session, agent, fs, terminal, git,
                worktree, services, memory, search, updates, voice,
                attachment }
-```
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
The API has 18 namespaces:
```
window.limboo.{ window, settings, system, app, events,
workspace, session, agent, fs, terminal, git,
worktree, services, memory, search, updates, voice }
worktree, services, memory, search, updates, voice,
attachment }
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 15-15: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/reference/window-limboo-api.md` around lines 13 - 19, The fenced
namespace list in the documentation lacks a language tag, which triggers the
markdownlint MD040 warning. Update the code fence in the window.limboo namespace
list to use a plain-text tag such as text or plaintext so the rendered output
stays the same and the lint warning is silenced.

Source: Linters/SAST tools

```

## window
Expand Down Expand Up @@ -182,6 +183,18 @@ Local voice subsystem: runtime controls (`getState`, `start`, `stop`,
`voice:*` event subscriptions. Mic audio streams main-ward over the
fire-and-forget `voice:audio-chunk` send channel.

## attachment

Attachment Manager — session-owned files staged for the agent's tool loop.

- `list(sessionId)` — all attachments (drafts + sent), oldest first.
- `pickFiles(sessionId)` — native multi-file picker → stage.
- `addPaths(sessionId, paths)` — stage dropped files (paths from `getPathForFile`).
- `addPasted(sessionId, name, mime, bytes)` — stage a pasted image.
- `remove(sessionId, id)`, `reveal(sessionId, id)`
- `getPathForFile(file)` — resolve a dropped `File`'s real path (webUtils).
- `onChanged(cb)` / `onProgress(cb)` — subscriptions (set changes / staging %).

## Usage note

Renderer calls guard with optional chaining (`window.limboo?.…`) so the UI still
Expand Down
9 changes: 9 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ directories:

# Publish provider for releases AND the auto-update feed electron-updater reads
# (it fetches latest*.yml from this repo's GitHub Releases).
#
# releaseType: release — publish straight to a full GitHub Release (which
# auto-creates the `v${version}` tag) rather than the electron-builder default
# `draft`. This matches the repo's existing releases (all non-draft), so a local
# `npm run publish` never hits the "existing type not compatible with publishing
# type" mismatch, and the auto-generated tag (v<version>) stays consistent with
# package.json. The version is the single source of truth: bump it (git tag or
# `node ci/scripts/apply-tag-version.mjs vX.Y.Z`) and the tag follows.
publish:
provider: github
owner: BotCoder254
repo: limboo
releaseType: release

# ---------------------------------------------------------------------------
# Windows — the branded, multi-page NSIS wizard (the "first screen of the app").
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"name": "limboo",
"productName": "Limboo",
"version": "1.2.5",
"version": "1.3.4",
"description": "My Electron application description",
"main": ".vite/build/main.js",
"private": true,
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"make": "npm run dist",
"publish": "npm run dist:publish",
"dist": "electron-forge package && node scripts/dist.mjs",
"dist:publish": "electron-forge package && node scripts/dist.mjs --publish always",
"gen:icons": "node scripts/gen-icons.mjs",
Expand Down
32 changes: 31 additions & 1 deletion scripts/dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,40 @@
* (e.g. `--publish always`) are forwarded straight to electron-builder.
*/
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { writeAppUpdateYml } from './write-app-update-yml.mjs';

/**
* Load `.env` (gitignored) from the repo root into `process.env` so a local
* `npm run publish` picks up secrets like `GH_TOKEN` regardless of which shell
* it runs in (setx / export only affect newly-spawned shells). Only fills keys
* that are NOT already set, so CI/CD env variables always take precedence and a
* stale local `.env` can never override them. Never logs values.
*/
function loadDotEnv() {
const envPath = resolve(process.cwd(), '.env');
if (!existsSync(envPath)) return;
for (const raw of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
if (!key || (process.env[key] ?? '') !== '') continue;
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}

loadDotEnv();

// Forge names the packaged dir from packagerConfig.name ('Limboo') + platform/arch.
const prepackaged = resolve(
process.cwd(),
Expand Down
30 changes: 30 additions & 0 deletions src/main/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,36 @@ function migrate(database: Database.Database): void {
VALUES (new.rowid, new.name, new.signature);
END;

-- Attachment Manager (schema v9) — session-owned files attached in the
-- composer. The staged copy lives under userData/attachments/<session_id>/
-- <stored_name>; this table is the metadata + lifecycle record. message_id
-- is NULL while the attachment is a composer draft and set when it is sent
-- with a user turn. Deduped per session by content hash. All values bound.
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
stored_name TEXT NOT NULL,
mime TEXT NOT NULL,
category TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ready',
origin TEXT NOT NULL,
risk TEXT NOT NULL DEFAULT 'safe',
message_id TEXT,
thumb TEXT,
error TEXT,
meta TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attachments_session
ON attachments (session_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_dedupe
ON attachments (session_id, sha256);

-- Recent searches (most-recent-first) per scope. workspace_id NULL = global.
CREATE TABLE IF NOT EXISTS search_history (
id TEXT PRIMARY KEY,
Expand Down
10 changes: 10 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { SearchManager } from './managers/search/SearchManager';
import { AutoUpdateManager } from './managers/AutoUpdateManager';
import { VoiceManager } from './managers/voice/VoiceManager';
import { VoiceModelManager } from './managers/voice/VoiceModelManager';
import { AttachmentManager } from './managers/attachments/AttachmentManager';
import { getDb, closeDb } from './db/database';
import { registerAllIpc } from './ipc';

Expand Down Expand Up @@ -84,6 +85,7 @@ function bootstrap(): void {
let services: ServiceManager;
let proxy: ProxyServer;
let memory: MemoryManager;
let attachments: AttachmentManager;
let search: SearchManager;
let updates: AutoUpdateManager;
let voiceModels: VoiceModelManager;
Expand Down Expand Up @@ -133,6 +135,10 @@ function bootstrap(): void {
// into prompts before they reach the harness.
memory = new MemoryManager(settings);
memory.seedDefaults(null); // global / user-scope starters
// The Attachment Manager — session-owned files staged for the agent's tool
// loop. Sweeps orphaned staging dirs shortly after boot (off the hot path).
attachments = new AttachmentManager(sessions, settings);
void attachments.sweepOrphans().catch((err) => logger.warn('attachment sweep failed', err));
// The Search Engine — a platform service owned by the app. Maintains the local
// file/symbol index and federates every other subsystem behind one query
// interface; also the primary context provider for the coding agent.
Expand All @@ -149,6 +155,9 @@ function bootstrap(): void {
// new memories from commits. Both treat memory as an optional collaborator.
agent.setMemoryManager(memory);
git.setMemoryManager(memory);
// The agent consumes attachments: manifest + staging-dir read access per
// prompt, vision blocks for images, and read-status tracking on tool use.
agent.setAttachmentManager(attachments);
// The Search Engine federates memory / git / sessions at query time, powers the
// Global Search UI, and feeds ranked context into the agent prompt.
search.setMemoryManager(memory);
Expand Down Expand Up @@ -189,6 +198,7 @@ function bootstrap(): void {
worktree: worktrees,
services,
memory,
attachments,
search,
updates,
voice,
Expand Down
31 changes: 26 additions & 5 deletions src/main/ipc/agentHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* prototype-polluting keys.
*/
import { IpcChannels } from '@shared/ipc-channels';
import { AGENT_LIMITS } from '@shared/constants';
import { AGENT_LIMITS, ATTACHMENT_LIMITS } from '@shared/constants';
import type {
AgentDiagnostic,
AgentInstall,
Expand Down Expand Up @@ -65,16 +65,37 @@ export function registerAgentHandlers(agent: AgentManager): void {
agent.getSnapshot(assertSessionId(sessionId)),
);

handle<[string, string, SessionPermissionMode?, string?], void>(
handle<[string, string, SessionPermissionMode?, string?, string[]?], void>(
IpcChannels.agentSend,
async (_event, sessionId, prompt, mode, clientMessageId) => {
async (_event, sessionId, prompt, mode, clientMessageId, attachmentIds) => {
const id = assertSessionId(sessionId);
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
// Attachment ids are optional; each must be a short, plain token. The
// Attachment Manager re-validates session ownership before use.
let attachIds: string[] | undefined;
if (attachmentIds !== undefined) {
if (!Array.isArray(attachmentIds) || attachmentIds.length > ATTACHMENT_LIMITS.maxFilesPerMessage.max) {
throw new Error('Invalid attachment list');
}
attachIds = attachmentIds.filter(
(a): a is string =>
typeof a === 'string' &&
a.length > 0 &&
a.length <= ATTACHMENT_LIMITS.idMax &&
/^[A-Za-z0-9_-]+$/.test(a),
);
if (attachIds.length !== attachmentIds.length) {
throw new Error('Invalid attachment id');
}
if (attachIds.length === 0) attachIds = undefined;
}
if (typeof prompt !== 'string' || (prompt.trim().length === 0 && !attachIds)) {
throw new Error('Prompt must be a non-empty string');
}
if (prompt.length > AGENT_LIMITS.promptMax) {
throw new Error('Prompt is too long');
}
// An attachments-only send gets a minimal instruction as the visible turn.
const effective = prompt.trim().length === 0 ? 'Review the attached files.' : prompt;
// Optional renderer-supplied id so the optimistic bubble and the persisted
// message share one id (dedup on echo). Validated: a short, plain string.
const clientId =
Expand All @@ -83,7 +104,7 @@ export function registerAgentHandlers(agent: AgentManager): void {
clientMessageId.length <= 64
? clientMessageId
: undefined;
await agent.send(id, prompt, assertMode(mode), clientId);
await agent.send(id, effective, assertMode(mode), clientId, attachIds);
},
);

Expand Down
89 changes: 89 additions & 0 deletions src/main/ipc/attachmentHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* IPC handlers for the Attachment Manager. Reached from the renderer through
* `window.limboo.attachment.*`. Every handler validates and caps its input
* before it touches the manager (CLAUDE.md §6): ids/paths are bounded strings,
* pasted bytes are size-capped, and the manager re-validates everything again
* (realpath, magic bytes, session ownership) as defense in depth.
*/
import { IpcChannels } from '@shared/ipc-channels';
import { ATTACHMENT_LIMITS } from '@shared/constants';
import type { AttachmentMeta } from '@shared/types';
import type { AttachmentManager } from '../managers/attachments/AttachmentManager';
import { handle } from './registry';

function assertSessionId(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > 200) {
throw new Error('Expected a valid session id');
}
return value;
}

function assertAttachmentId(value: unknown): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > ATTACHMENT_LIMITS.idMax ||
!/^[A-Za-z0-9_-]+$/.test(value)
) {
throw new Error('Expected a valid attachment id');
}
return value;
}

export function registerAttachmentHandlers(attachments: AttachmentManager): void {
handle<[string], AttachmentMeta[]>(IpcChannels.attachmentList, (_event, sessionId) =>
attachments.list(assertSessionId(sessionId)),
);

handle<[string], AttachmentMeta[]>(IpcChannels.attachmentPickFiles, (_event, sessionId) =>
attachments.pickAndAdd(assertSessionId(sessionId)),
);

handle<[string, string[]], AttachmentMeta[]>(
IpcChannels.attachmentAddPaths,
(_event, sessionId, paths) => {
const id = assertSessionId(sessionId);
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error('Expected a non-empty array of paths');
}
if (paths.length > ATTACHMENT_LIMITS.maxFilesPerMessage.max) {
throw new Error('Too many files');
}
for (const p of paths) {
if (typeof p !== 'string' || p.length === 0 || p.length > ATTACHMENT_LIMITS.pathMax) {
throw new Error('Invalid path');
}
}
Comment on lines +52 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Security Vulnerability: Missing null byte validation on paths. The attachmentAddPaths handler validates path length but doesn't check for null bytes (\0), while AttachmentManager.stageOne does check for this (line 506). An attacker could bypass the AttachmentManager's null byte check if paths containing \0 pass through IPC validation, potentially causing path truncation attacks.1

Suggested change
for (const p of paths) {
if (typeof p !== 'string' || p.length === 0 || p.length > ATTACHMENT_LIMITS.pathMax) {
throw new Error('Invalid path');
}
}
for (const p of paths) {
if (typeof p !== 'string' || p.length === 0 || p.length > ATTACHMENT_LIMITS.pathMax || p.includes('\0')) {
throw new Error('Invalid path');
}
}

Footnotes

  1. CWE-158: Improper Neutralization of Null Byte or NUL Character - https://cwe.mitre.org/data/definitions/158.html

return attachments.addFromPaths(id, paths, 'drop');
},
);

handle<[string, string, string, ArrayBuffer], AttachmentMeta>(
IpcChannels.attachmentAddPasted,
(_event, sessionId, name, mime, bytes) => {
const id = assertSessionId(sessionId);
if (typeof name !== 'string' || name.length > ATTACHMENT_LIMITS.nameMax * 2) {
throw new Error('Invalid file name');
}
if (typeof mime !== 'string' || mime.length === 0 || mime.length > 64) {
throw new Error('Invalid MIME type');
}
if (!(bytes instanceof ArrayBuffer) && !ArrayBuffer.isView(bytes)) {
throw new Error('Expected image bytes');
}
const view = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : new Uint8Array(bytes.buffer);
if (view.byteLength === 0 || view.byteLength > ATTACHMENT_LIMITS.pasteBytesMax) {
throw new Error('Pasted image is too large');
}
return attachments.addFromBytes(id, name, mime, view);
},
);

handle<[string, string], void>(IpcChannels.attachmentRemove, (_event, sessionId, id) => {
attachments.remove(assertSessionId(sessionId), assertAttachmentId(id));
});

handle<[string, string], void>(IpcChannels.attachmentReveal, (_event, sessionId, id) => {
attachments.reveal(assertSessionId(sessionId), assertAttachmentId(id));
});
}
Loading
Loading