Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a8c51fd
feat(eve): port Claude Code agent pattern from gather
Rishavraaj Jul 15, 2026
13f583c
style: format eve files for line length consistency
Rishavraaj Jul 15, 2026
f37bb7e
chore: fix knip unused file/export warnings
Rishavraaj Jul 15, 2026
3a12f33
feat(accounts): replace OpenAI with Anthropic Claude SDK integration
Rishavraaj Jul 15, 2026
f95f4e1
chore: remove skills-lock.json and .agents from tracking
Rishavraaj Jul 15, 2026
de6dd3a
chore: gitignore .agents/ and skills-lock.json
Rishavraaj Jul 15, 2026
4fd993f
chore(ai-agent): pin claude-agent-sdk and ai-sdk-provider-claude-code…
Rishavraaj Jul 15, 2026
c1e1761
chore: update bun.lock
Rishavraaj Jul 15, 2026
97419c7
fix(backend): remove unused getValidClaudeOauthFromPool to fix TS6133
Rishavraaj Jul 15, 2026
191363f
format: format the code Co-Authored-By: Claude Sonnet 4.6 <noreply@an…
Rishavraaj Jul 15, 2026
eb0828f
fix(web): remove unused cookieStore in eve proxy route
Rishavraaj Jul 15, 2026
3cbb79a
fix(web): remove unused state and imports from chat components
Rishavraaj Jul 15, 2026
0227c84
test(web): refactor chat tests to use useEveAgent hook instead of loc…
Rishavraaj Jul 15, 2026
1d76c28
style(web): format chat test file for consistency
Rishavraaj Jul 15, 2026
dc39d66
test(e2e): update chat tests to use /chat route instead of pre-create…
Rishavraaj Jul 16, 2026
6983d07
test(e2e): wait for agent idle before sending second message in chat …
Rishavraaj Jul 16, 2026
0ad6d90
refactor(backend): move account DB queries to AccountsRepository
Rishavraaj Jul 17, 2026
62a830d
refactor(backend): extract EveProxyService, keep controller lean
Rishavraaj Jul 17, 2026
b3e0a76
style(backend): format EveProxyService
Rishavraaj Jul 17, 2026
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,7 @@ GEMINI.md

# Nango runtime data
nango-data/

# Claude Code agent skills
.agents/
skills-lock.json
14 changes: 7 additions & 7 deletions apps/ai-agent/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { defineAgent } from 'eve';
import { openai } from '@ai-sdk/openai';
import { claudeCode } from './lib/model/claude-code.js';

export default defineAgent({
// OpenAI as the model. eve's built-in sandbox tools (bash, read_file,
// write_file, glob, grep) drive this session's Fly Sprite via the
// defineSandbox backend in sandbox/ — no model-specific spawn path needed.
model: openai('gpt-4.1'),
// Claude Code as the model — the `claude` CLI spawned inside this session's
// Fly Sprite (see lib/model/claude-code.ts + the sandbox/ folder, where eve
// owns the sprite via defineSandbox).
model: claudeCode('sonnet'),
// eve can't look up a context window for a custom (non-AI-Gateway) model,
// which its compaction feature needs. gpt-4.1 accepts ~1M input tokens.
modelContextWindowTokens: 1_047_576,
// which its compaction feature needs. Supply Sonnet's 200k window explicitly.
modelContextWindowTokens: 200_000,
});
76 changes: 28 additions & 48 deletions apps/ai-agent/agent/channels/eve.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,32 @@
import { type AuthFn } from 'eve/channels/auth';
import { eveChannel } from 'eve/channels/eve';
import { type AuthFn, UnauthenticatedError } from 'eve/channels/auth';
import { env } from '../lib/env';

interface BetterAuthSession {
user: { id: string; email: string; name: string };
session: { id: string; activeOrganizationId?: string };
}

function betterAuth(): AuthFn<Request> {
return async (request) => {
const backendUrl = env().ZUKO_BACKEND_URL;

// Prefer cookie from request (HTTP API / browser); fall back to env (TUI dev)
const cookieFromRequest = request.headers.get('cookie');
const cookieFromEnv = env().ZUKO_SESSION_TOKEN
? `better-auth.session_token=${env().ZUKO_SESSION_TOKEN}`
: null;
const sessionCookie = cookieFromRequest || cookieFromEnv;
if (!sessionCookie) return null;

const res = await fetch(`${backendUrl}/auth/get-session`, {
headers: { cookie: sessionCookie },
});
if (!res.ok) return null;

const data = (await res.json()) as BetterAuthSession | null;
if (!data?.user?.id) return null;

const orgId = data.session?.activeOrganizationId;
if (!orgId) {
throw new UnauthenticatedError({
message: 'No active organization on session. Select one first.',
});
}

import { verifyEvePrincipal } from '../lib/eve-principal.js';

/**
* Trusted-header AuthFn — the SOLE authenticator. The Zuko NestJS edge
* terminates the user's session and forwards an HMAC-signed principal in
* `x-eve-principal`; we verify it and project it onto eve's `SessionAuthContext`.
*
* Returns `null` when the header is absent or invalid. With no other
* authenticator in the walk, that yields a clean 401 — a signed principal is
* required, with no loopback/local-dev escape hatch.
*/
export const trustedHeaderAuth: AuthFn<Request> = (request) => {
const header = request.headers.get('x-eve-principal');
if (!header) return null;
try {
const { userId, orgId } = verifyEvePrincipal(header);
return {
authenticator: 'betterAuth',
issuer: backendUrl,
principalId: data.user.id,
authenticator: 'zuko-trusted-header',
principalId: String(userId),
principalType: 'user',
subject: data.user.email,
attributes: {
orgId,
userId: data.user.id,
sessionCookie,
},
attributes: { orgId: String(orgId) },
};
};
}

export default eveChannel({ auth: [betterAuth()] });
} catch {
return null; // present-but-invalid → unauthenticated, not a server error
}
};

export default eveChannel({
auth: [trustedHeaderAuth],
});
29 changes: 2 additions & 27 deletions apps/ai-agent/agent/instructions.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,3 @@
# Zuko Task Assistant
# Identity

You are Zuko's task assistant. You manage tasks using the available tools.

## Behavior

- **Updates need ids.** Before updating or deleting a task, look it up with
`list_tasks` or `get_task` first. Never guess ids.
- **Report ids back.** After creating or updating a task, state its id and key
fields so the user can verify.
- **Deleting is permanent.** Always confirm with the user before calling
`delete_task`. State the task title and id before proceeding.
- **Root vs subtasks.** For "list all tasks" or any broad listing, omit
`parentId` entirely — do NOT pass `null`. Only pass `parentId: null` when
the user explicitly asks for top-level or root tasks only.
- **Empty strings.** Never pass `search: ""` — omit the field if there is no search term.
- **Marking done.** When marking a task done, set `status: "DONE"` and
`completedAt` to the current ISO 8601 timestamp.
- **Never invent parentId.** Only set `parentId` when the user explicitly says
"subtask of X" or "under task X". Default: omit `parentId` entirely.
- **Statuses** are: `TODO`, `IN_PROGRESS`, `DONE`.

- **Authentication.** If a tool fails with "Not authenticated", ask the user
to paste their `better-auth.session_token` cookie value (browser DevTools →
Application → Cookies), then call `set_session_token` with it.

Be concise. Do the work with tools; don't speculate about task state you have
not queried.
You are a helpful assistant.
7 changes: 4 additions & 3 deletions apps/ai-agent/agent/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { z } from 'zod';

const envSchema = z.object({
OPENAI_API_KEY: z.string().min(1),
// Fly Sprites auth — sandbox provisioning (agent/sandbox/fly-sprite.ts)
SPRITES_TOKEN: z.string().min(1),
// HMAC signing for x-eve-principal — must match backend BETTER_AUTH_SECRET
BETTER_AUTH_SECRET: z.string().min(1),
// Claude Code OAuth token — used when user hasn't linked Claude in Settings
CLAUDE_CODE_OAUTH_TOKEN: z.string().min(1),
ZUKO_BACKEND_URL: z.string().url().default('http://localhost:3001'),
// TUI dev: copy better-auth.session_token from browser DevTools → Application → Cookies
ZUKO_SESSION_TOKEN: z.string().optional(),
});

let cached: z.infer<typeof envSchema> | undefined;
Expand Down
50 changes: 50 additions & 0 deletions apps/ai-agent/agent/lib/eve-principal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// NOTE: keep byte-identical (logic) with apps/backend/src/utils/eve-principal.ts — duplicated across the eve/backend build boundary (eve bundles separately and cannot import backend code).
import { createHmac, timingSafeEqual } from 'node:crypto';

export interface EvePrincipal {
readonly userId: number;
readonly orgId: number;
}

const MAX_PRINCIPAL_AGE_MS = 60_000;

function secret(): string {
const s = process.env['BETTER_AUTH_SECRET'];
if (!s) throw new Error('eve-principal: BETTER_AUTH_SECRET is required');
return s;
}

function hmac(payloadB64: string): string {
return createHmac('sha256', secret()).update(payloadB64).digest('hex');
}

export function signEvePrincipal(p: EvePrincipal): string {
const payloadB64 = Buffer.from(
JSON.stringify({ userId: p.userId, orgId: p.orgId, iat: Date.now() }),
).toString('base64url');
return `${payloadB64}.${hmac(payloadB64)}`;
}

export function verifyEvePrincipal(token: string): EvePrincipal {
const dotIdx = token.indexOf('.');
if (dotIdx === -1 || token.indexOf('.', dotIdx + 1) !== -1)
throw new Error('eve-principal: malformed token');
const b64 = token.slice(0, dotIdx);
const sig = token.slice(dotIdx + 1);
const expected = hmac(b64);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('eve-principal: invalid signature');
}
const { userId, orgId, iat } = JSON.parse(
Buffer.from(b64, 'base64url').toString(),
);
if (typeof userId !== 'number' || typeof orgId !== 'number') {
throw new Error('eve-principal: invalid payload');
}
if (typeof iat !== 'number' || Date.now() - iat > MAX_PRINCIPAL_AGE_MS) {
throw new Error('eve-principal: expired token');
}
return { userId, orgId };
}
191 changes: 191 additions & 0 deletions apps/ai-agent/agent/lib/model/claude-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { randomUUID } from 'node:crypto';
import { PassThrough } from 'node:stream';
import type {
SpawnOptions,
SpawnedProcess,
} from '@anthropic-ai/claude-agent-sdk';
import type { SpriteCommand } from '@fly/sprites';
import { createClaudeCode } from 'ai-sdk-provider-claude-code';
import {
getClaudeSessionId,
requireSessionSprite,
setClaudeSessionId,
} from '../../sandbox/fly-sprite';

/** Where the `claude` CLI runs inside the sprite. */
const WORKDIR = '/sandbox';

/** The `claude` binary path inside the sprite image (not the host's). */
const CLAUDE_EXECUTABLE = '/home/sprite/.local/bin/claude';

/** claude CLI session-selection flags the provider may inject from its shared
* capture; we strip them and substitute this session's own choice. */
const VALUE_FLAGS = new Set([
'--resume',
'--session-id',
'--resume-session-at',
]);
const BOOL_FLAGS = new Set(['--fork-session', '--continue']);

/**
* Rewrite the `claude` CLI args so this eve session uses its OWN conversation
* id, never the shared model instance's captured one.
*
* The `ai-sdk-provider-claude-code` model is a module-level singleton shared by
* every concurrent eve session and captures the claude session id on the
* instance; a shared id would leak session A's conversation into session B's
* fresh sprite (`--resume` → "No conversation found"). We own the id per
* eve-session in the durable `gather.session` record (see fly-sprite.ts) and
* substitute it here: first spawn mints a fresh id (`--session-id <uuid>`),
* later spawns resume it (`--resume <uuid>`).
*/
function scopeSessionArgs(args: readonly string[]): string[] {
const out: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (VALUE_FLAGS.has(arg)) {
i++; // also drop the flag's value
continue;
}
if (BOOL_FLAGS.has(arg)) continue;
out.push(arg);
}
const existing = getClaudeSessionId();
if (existing) {
out.push('--resume', existing);
} else {
const id = randomUUID();
setClaudeSessionId(id);
out.push('--session-id', id);
}
return out;
}

/** Any Claude auth token the host explicitly set (fallback to the creds file
* the sandbox backend writes into the sprite). */
function claudeAuthEnv(): Record<string, string> {
const env: Record<string, string> = {};
const oauth = process.env['CLAUDE_CODE_OAUTH_TOKEN'];
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (oauth) env['CLAUDE_CODE_OAUTH_TOKEN'] = oauth;
if (apiKey) env['ANTHROPIC_API_KEY'] = apiKey;
return env;
}

/** Locale/terminal vars safe to forward verbatim. */
const SAFE_ENV_KEYS = new Set(['LANG', 'LC_ALL', 'LC_CTYPE', 'TERM']);
/** Prefixes claude / the claude-code SDK legitimately read. */
const SAFE_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_AGENT_SDK_'];

/**
* Build a curated env for the sprite-side `claude` CLI — default-deny.
*
* The provider forwards the eve HOST's entire `process.env`, which carries host
* secrets (AWS keys, Google OAuth, DB URLs) that must NEVER cross the isolation
* boundary into the sandbox, plus host-session `CLAUDE_CODE_*` vars whose paths
* point at the host. We forward only an allowlist (locale + `ANTHROPIC_*`/
* `CLAUDE_AGENT_SDK_*`), then pin sprite-correct `HOME`/`USER`/`PATH` (claude
* reads OAuth from `$HOME/.claude`, so a leaked host `HOME` breaks auth).
*/
function buildSpawnEnv(env: SpawnOptions['env']): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (v === undefined) continue;
if (
SAFE_ENV_KEYS.has(k) ||
SAFE_ENV_PREFIXES.some((p) => k.startsWith(p))
) {
out[k] = v;
}
}
return {
...out,
...claudeAuthEnv(),
HOME: '/home/sprite',
USER: 'sprite',
PATH: '/home/sprite/.local/bin:/usr/local/bin:/usr/bin:/bin',
};
}

/**
* Adapt a Fly {@link SpriteCommand} to claude-code's {@link SpawnedProcess}.
*
* Two mismatches to bridge:
* 1. Shape: `SpriteCommand.exitCode()` is a METHOD (returns -1 before exit)
* while `SpawnedProcess.exitCode` is a nullable PROPERTY, and `killed`
* doesn't exist on `SpriteCommand` — so we expose real getters.
* 2. Timing: Fly connects the command's WebSocket asynchronously (~100-200ms),
* but the provider writes the init message to stdin synchronously right after
* spawn. Writing to `cmd.stdin` before the WS is open makes `SpriteCommand`
* emit a fatal "WebSocket not open" error ("ProcessTransport is not ready for
* writing"). We hand the provider a buffering `PassThrough` and only pipe it
* into the real stdin once `spawn` fires (WS open).
*/
function toSpawnedProcess(cmd: SpriteCommand): SpawnedProcess {
let killed = false;
const stdin = new PassThrough();
cmd.once('spawn', () => stdin.pipe(cmd.stdin));
const adapter = {
stdin,
stdout: cmd.stdout,
get killed() {
return killed;
},
get exitCode(): number | null {
const code = cmd.exitCode();
return code < 0 ? null : code;
},
kill(signal: NodeJS.Signals): boolean {
killed = true;
cmd.kill(signal);
return true;
},
on(event: 'exit' | 'error', listener: (...args: never[]) => void): void {
cmd.on(event, listener as (...a: unknown[]) => void);
},
once(event: 'exit' | 'error', listener: (...args: never[]) => void): void {
cmd.once(event, listener as (...a: unknown[]) => void);
},
off(event: 'exit' | 'error', listener: (...args: never[]) => void): void {
cmd.off(event, listener as (...a: unknown[]) => void);
},
};
return adapter as unknown as SpawnedProcess;
}

/**
* The synchronous `spawnClaudeCodeProcess` hook: spawn the `claude` CLI in THIS
* session's sprite (provisioned + owned by eve's `defineSandbox`; reached via
* the ALS bridge). `sprite.spawn` is synchronous (the transport connects
* lazily), so no await in the model hot path. The CLI needs a full-duplex
* process — writable stdin for streaming input, readable stdout for output.
*/
function spawnInSprite(opts: SpawnOptions): SpawnedProcess {
const sprite = requireSessionSprite();
const cmd = sprite.spawn(opts.command, scopeSessionArgs(opts.args), {
cwd: opts.cwd ?? WORKDIR,
env: buildSpawnEnv(opts.env),
});
// Fly's spawn takes no AbortSignal; bridge cancellation to a kill.
if (opts.signal.aborted) {
cmd.kill();
} else {
opts.signal.addEventListener('abort', () => cmd.kill(), { once: true });
}
return toSpawnedProcess(cmd);
}

/**
* Claude Code as eve's model, spawned INSIDE this session's Fly Sprite. eve's
* `model` slot accepts the AI SDK LanguageModel this produces; the synchronous
* `spawnClaudeCodeProcess` hook runs the `claude` CLI in the sprite eve
* provisioned (see the `sandbox/` folder). `bypassPermissions` is safe here —
* the CLI runs inside the sprite's isolation boundary, not on the eve host.
*/
export const claudeCode = createClaudeCode({
defaultSettings: {
spawnClaudeCodeProcess: spawnInSprite,
pathToClaudeCodeExecutable: CLAUDE_EXECUTABLE,
permissionMode: 'bypassPermissions',
},
});
Loading
Loading