diff --git a/README.md b/README.md index 5980f2d..c2fe68f 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,35 @@ agent sandbox variable list # list sandbox env variable names (values are agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env/.json) agent sandbox variable rm K # delete a variable +agent hooks install # install Claude Code Stop/SessionEnd hooks + enroll this repo +agent hooks enroll [owner/name] # opt a repo in to transcript sync (per-repo consent) +agent hooks unenroll [owner/name] # opt a repo out +agent hooks status # show installed hooks + enrolled repos +agent session sync # sync the local Claude Code transcript (hooks run this for you) +agent session handoff "finish the tests and open a PR" # hand this session off to a cloud agent + agent budget # current budget summary agent usage # usage dashboard for the period agent ping # check authenticated /v1 connectivity ``` +## Local Claude Code session sync + +`agent hooks install` writes `Stop` + `SessionEnd` hooks into +`~/.claude/settings.json`; each fires `agent session sync --hook`, which +uploads the session's transcript to your Ellipsis account so local sessions +appear alongside cloud ones. Consent is per-repo: the hook silently does +nothing outside repos you've enrolled with `agent hooks enroll`. Transcripts +are redacted client-side (token/key patterns never leave the machine +unredacted), gzipped, and synced after every turn; offline syncs are spooled +under `~/.config/ellipsis/spool/` and retried automatically. + +`agent session handoff ""` closes the loop: it captures your +dirty working tree as a WIP commit (without touching it), pushes it to +`refs/ellipsis/handoff/`, syncs the transcript one last time, and starts a +cloud agent session that checks out your WIP commit and continues from your +instructions — chained to the local session so the history stays connected. + Most commands accept `--json` to print the raw API response. The CLI talks to the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL` (or the legacy `ELLIPSIS_API_BASE`). diff --git a/src/cli.tsx b/src/cli.tsx index 59f4729..17d46e2 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,6 +2,8 @@ import { Command } from 'commander' import { registerLogin } from './commands/login' import { registerMe } from './commands/me' import { registerRun } from './commands/run' +import { registerSession } from './commands/session' +import { registerHooks } from './commands/hooks' import { registerConfig } from './commands/config' import { registerSandbox } from './commands/sandbox' import { registerTemplate } from './commands/template' @@ -19,6 +21,8 @@ program registerLogin(program) registerMe(program) registerRun(program) +registerSession(program) +registerHooks(program) registerConfig(program) registerSandbox(program) registerTemplate(program) diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts new file mode 100644 index 0000000..1f18e56 --- /dev/null +++ b/src/commands/hooks.ts @@ -0,0 +1,124 @@ +// `agent hooks …` — install the Claude Code hooks that power laptop transcript +// sync, and manage per-repo enrollment (the consent gate `agent session sync` +// checks before uploading anything). + +import type { Command } from 'commander' +import { + claudeSettingsPath, + installHooks, + installedHookEvents, + uninstallHooks, +} from '../lib/hooks' +import { + enrollRepo, + enrolledRepos, + isEnrolled, + repoForCwd, + unenrollRepo, +} from '../lib/enrollment' +import { runAction } from '../lib/output' + +// Resolve the repo argument: explicit "owner/name", else the cwd's remote. +function resolveRepoArg(repo?: string): string { + if (repo) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) { + throw new Error(`"${repo}" is not an owner/name repo`) + } + return repo + } + const fromCwd = repoForCwd(process.cwd()) + if (!fromCwd) { + throw new Error( + 'not inside a git repo with a recognizable GitHub remote — pass the repo explicitly (owner/name)', + ) + } + return fromCwd +} + +export function registerHooks(program: Command): void { + const hooks = program + .command('hooks') + .description('Claude Code hooks for syncing local session transcripts to Ellipsis') + + hooks + .command('install') + .description( + 'Install the Stop + SessionEnd hooks in ~/.claude/settings.json (and enroll the current repo)', + ) + .option('--no-enroll', 'install the hooks without enrolling the current repo') + .action(async (opts: { enroll?: boolean }) => { + await runAction(async () => { + const path = installHooks() + console.log(`installed Stop + SessionEnd sync hooks in ${path}`) + if (opts.enroll === false) return + const repo = repoForCwd(process.cwd()) + if (!repo) { + console.log( + 'no GitHub repo detected in the current directory — run `agent hooks enroll` inside each repo you want synced.', + ) + return + } + if (isEnrolled(repo)) { + console.log(`repo ${repo} is already enrolled for transcript sync`) + return + } + enrollRepo(repo) + console.log( + `enrolled ${repo} for transcript sync — Claude Code sessions in this repo now sync to Ellipsis.`, + ) + }) + }) + + hooks + .command('uninstall') + .description('Remove the sync hooks from ~/.claude/settings.json (enrollment is kept)') + .action(async () => { + await runAction(async () => { + const path = uninstallHooks() + console.log(`removed sync hooks from ${path}`) + }) + }) + + hooks + .command('status') + .description('Show installed sync hooks and enrolled repos') + .action(async () => { + await runAction(async () => { + const events = installedHookEvents() + console.log( + events.length > 0 + ? `hooks installed (${events.join(', ')}) in ${claudeSettingsPath()}` + : `no sync hooks installed (run \`agent hooks install\`)`, + ) + const repos = enrolledRepos() + if (repos.length === 0) { + console.log('no repos enrolled for transcript sync') + } else { + console.log('enrolled repos:') + for (const repo of repos) console.log(` ${repo}`) + } + }) + }) + + hooks + .command('enroll [repo]') + .description('Enroll a repo (default: the current one) for transcript sync') + .action(async (repo?: string) => { + await runAction(async () => { + const resolved = resolveRepoArg(repo) + enrollRepo(resolved) + console.log(`enrolled ${resolved} for transcript sync`) + }) + }) + + hooks + .command('unenroll [repo]') + .description('Stop syncing a repo (default: the current one)') + .action(async (repo?: string) => { + await runAction(async () => { + const resolved = resolveRepoArg(repo) + unenrollRepo(resolved) + console.log(`unenrolled ${resolved} from transcript sync`) + }) + }) +} diff --git a/src/commands/session.ts b/src/commands/session.ts new file mode 100644 index 0000000..88b509c --- /dev/null +++ b/src/commands/session.ts @@ -0,0 +1,288 @@ +// `agent session sync` — laptop transcript ingestion (the client half of +// documents/eng/LOCAL_CLAUDE_CODE.md §7.1 in ellipsis-dev/ellipsis). +// +// Normally invoked by the Claude Code `Stop` / `SessionEnd` hooks that +// `agent hooks install` writes (`--hook`: hook JSON on stdin, always exits 0 so +// a sync problem can never disturb the developer's session). Can also be run +// by hand against an explicit transcript file. +// +// Flow: resolve the cwd's repo from its git remote → silently no-op unless the +// repo is enrolled (per-repo consent) → read the on-disk JSONL transcript → +// redact client-side → gzip+base64 → POST /v1/sessions/sync. Network-class +// failures spool to disk and are retried before the next successful sync. + +import { basename, join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { gzipSync } from 'node:zlib' +import type { Command } from 'commander' +import { ApiClient, ApiError } from '../lib/api' +import { resolveAppBase } from '../lib/config' +import { isEnrolled, repoForCwd } from '../lib/enrollment' +import { redactTranscript } from '../lib/redact' +import { flushSpool, spoolSync } from '../lib/spool' +import { printJson, runAction } from '../lib/output' +import type { SyncHookEvent, SyncSessionRequest } from '../lib/types' + +interface HookInput { + session_id?: string + transcript_path?: string + cwd?: string + hook_event_name?: string + reason?: string +} + +function hookEventFromName(name?: string): SyncHookEvent { + if (name === 'Stop') return 'stop' + if (name === 'SessionEnd') return 'session_end' + return 'manual' +} + +// Claude Code stores transcripts under ~/.claude/projects//, where +// the slug is the cwd with every non-alphanumeric character replaced by `-`. +// Used only for manual (non-hook) invocations without --transcript. +function latestTranscriptForCwd(cwd: string): string | undefined { + const base = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') + const slug = cwd.replace(/[^a-zA-Z0-9]/g, '-') + const dir = join(base, 'projects', slug) + if (!existsSync(dir)) return undefined + const candidates = readdirSync(dir) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime) + return candidates.length > 0 ? join(dir, candidates[0].f) : undefined +} + +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') +} + +interface SyncOpts { + hook?: boolean + transcript?: string + event?: string + json?: boolean +} + +async function doSync(opts: SyncOpts): Promise { + const quiet = Boolean(opts.hook) + // In hook mode everything below must be non-fatal: the hook contract is + // "never disturb the session" — problems are printed to stderr (visible in + // CC's debug log) and we exit 0. + const fail = (message: string): void => { + if (quiet) { + console.error(`agent session sync: ${message}`) + return + } + throw new Error(message) + } + + let hookInput: HookInput = {} + if (opts.hook) { + try { + hookInput = JSON.parse(await readStdin()) as HookInput + } catch { + return fail('could not parse hook JSON from stdin') + } + } + + const cwd = hookInput.cwd ?? process.cwd() + const repo = repoForCwd(cwd) + // The consent gate: only enrolled repos ever sync; everything else is a + // silent no-op (by design — the hook fires in every project). + if (!repo || !isEnrolled(repo)) { + if (!quiet) { + console.log( + repo + ? `repo ${repo} is not enrolled for transcript sync — run \`agent hooks enroll\` in it first.` + : 'not inside a git repo with a recognizable GitHub remote; nothing to sync.', + ) + } + return + } + + const transcriptPath = + opts.transcript ?? hookInput.transcript_path ?? latestTranscriptForCwd(cwd) + if (!transcriptPath || !existsSync(transcriptPath)) { + return fail( + transcriptPath + ? `transcript not found: ${transcriptPath}` + : 'no transcript found for this directory (pass --transcript )', + ) + } + + const ccSessionId = + hookInput.session_id ?? basename(transcriptPath).replace(/\.jsonl$/, '') + const text = redactTranscript(readFileSync(transcriptPath, 'utf8')) + if (!text.trim()) { + if (!quiet) console.log('transcript is empty; nothing to sync.') + return + } + + const payload: SyncSessionRequest = { + cc_session_id: ccSessionId, + transcript_gzip_b64: gzipSync(Buffer.from(text, 'utf8')).toString('base64'), + repo, + cwd, + hook_event: (opts.event as SyncHookEvent) ?? hookEventFromName(hookInput.hook_event_name), + reason: hookInput.reason, + } + + const api = new ApiClient() + try { + const res = await api.syncSession(payload) + // A successful sync is the opportunistic moment to drain anything spooled + // by earlier offline syncs. + await flushSpool(api) + if (!quiet) { + if (opts.json) printJson(res) + else + console.log( + `synced ${res.event_count} events → session ${res.agent_session_id}` + + (res.stored ? '' : ' (server already had this snapshot)'), + ) + } + } catch (err) { + if (err instanceof ApiError && err.status < 500) { + // A 4xx will not succeed on retry — don't spool it. + return fail(err.message) + } + // Network-class failure: spool the snapshot and retry on a later sync. + spoolSync(payload) + return fail(`sync failed (${(err as Error).message}); spooled for retry`) + } +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +// Build a sync payload for an explicit (non-hook) transcript upload. Used by +// handoff, which is itself the consent action — no enrollment gate here. +function buildSyncPayload( + cwd: string, + repo: string, + transcriptPath: string, + event: SyncHookEvent, +): { ccSessionId: string; payload: SyncSessionRequest } { + const ccSessionId = basename(transcriptPath).replace(/\.jsonl$/, '') + const text = redactTranscript(readFileSync(transcriptPath, 'utf8')) + return { + ccSessionId, + payload: { + cc_session_id: ccSessionId, + transcript_gzip_b64: gzipSync(Buffer.from(text, 'utf8')).toString('base64'), + repo, + cwd, + hook_event: event, + }, + } +} + +interface HandoffOpts { + transcript?: string + json?: boolean +} + +// Laptop → cloud handoff (LOCAL_CLAUDE_CODE.md §7.2): (1) build a commit of +// the dirty working tree without disturbing it and push it to +// refs/ellipsis/handoff/; (2) final transcript sync; (3) POST +// /v1/sessions/handoff — the server starts a cloud session on the built-in +// handoff config with the WIP sha as checkout target, chained to the laptop +// session via parent_kind=handoff. +async function doHandoff(instructions: string, opts: HandoffOpts): Promise { + const cwd = process.cwd() + const repo = repoForCwd(cwd) + if (!repo) { + throw new Error( + 'not inside a git repo with a recognizable GitHub remote — handoff needs a repo to push the WIP commit to.', + ) + } + const transcriptPath = opts.transcript ?? latestTranscriptForCwd(cwd) + if (!transcriptPath || !existsSync(transcriptPath)) { + throw new Error( + 'no local Claude Code transcript found for this directory (pass --transcript ).', + ) + } + + // (1) WIP commit: `git stash create` captures tracked changes as a commit + // without touching the working tree; a clean tree hands off HEAD itself. + const stashSha = git(cwd, ['stash', 'create', 'ellipsis handoff']) + const wipSha = stashSha || git(cwd, ['rev-parse', 'HEAD']) + const { ccSessionId, payload } = buildSyncPayload( + cwd, + repo, + transcriptPath, + 'manual', + ) + const ref = `refs/ellipsis/handoff/${ccSessionId.slice(0, 8)}` + git(cwd, ['push', 'origin', `${wipSha}:${ref}`]) + console.log(`pushed WIP commit ${wipSha.slice(0, 12)} → ${ref}`) + + // (2) final transcript sync — running handoff IS the consent action for + // this session, so no enrollment gate. + const api = new ApiClient() + await api.syncSession(payload) + + // (3) start the cloud session. + const session = await api.startHandoffSession({ + cc_session_id: ccSessionId, + repo, + wip_sha: wipSha, + prompt: instructions, + }) + if (opts.json) { + printJson(session) + return + } + const me = await api.whoami().catch(() => undefined) + console.log(`started handoff session ${session.id}`) + if (me) { + console.log( + `${resolveAppBase()}/${encodeURIComponent(me.customer_login)}/agents/sessions/${encodeURIComponent(session.id)}`, + ) + } +} + +export function registerSession(program: Command): void { + const session = program + .command('session') + .description('Work with agent sessions') + + session + .command('sync') + .description( + 'Sync the local Claude Code session transcript to Ellipsis (enrolled repos only)', + ) + .option('--hook', 'hook mode: read Claude Code hook JSON from stdin, never fail') + .option('--transcript ', 'explicit transcript .jsonl path') + .option('--event ', 'override the hook event (stop|session_end|manual)') + .option('--json', 'output the raw JSON response') + .action(async (opts: SyncOpts) => { + if (opts.hook) { + // Hook contract: always exit 0; doSync reports problems on stderr. + await doSync(opts).catch((err) => + console.error(`agent session sync: ${(err as Error).message}`), + ) + return + } + await runAction(() => doSync(opts)) + }) + + session + .command('handoff ') + .description( + 'Hand this Claude Code session off to a cloud agent: push a WIP commit, sync the transcript, start a session with your instructions', + ) + .option('--transcript ', 'explicit transcript .jsonl path') + .option('--json', 'output the created session as raw JSON') + .action(async (instructions: string, opts: HandoffOpts) => { + await runAction(() => doHandoff(instructions, opts)) + }) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index f4e77a5..3f74c84 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,6 +9,7 @@ import type { CreateAgentConfigRequest, CreatedAgentConfig, GetSandboxVariablesResponse, + HandoffSessionRequest, ListAgentConfigsResponse, ListAgentRunsQuery, ListAgentRunsResponse, @@ -18,6 +19,8 @@ import type { SandboxVariableSummary, SavedAgentConfig, StartAgentRunRequest, + SyncSessionRequest, + SyncSessionResponse, UsageDashboard, WhoAmI, } from './types' @@ -125,6 +128,20 @@ export class ApiClient { ) } + // ------------------------------ laptop sync ----------------------------- + + // Sync a local Claude Code session transcript (fired by the CC Stop / + // SessionEnd hooks via `agent session sync`). User tokens only server-side. + syncSession(req: SyncSessionRequest): Promise { + return this.request('POST', '/v1/sessions/sync', req) + } + + // Hand a synced laptop session off to a cloud agent session (the WIP commit + // must already be pushed; see `agent session handoff`). + startHandoffSession(req: HandoffSessionRequest): Promise { + return this.request('POST', '/v1/sessions/handoff', req) + } + // ----------------------------- agent configs ---------------------------- async listAgentConfigs(): Promise { diff --git a/src/lib/config.ts b/src/lib/config.ts index 076eadf..747134a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -17,6 +17,10 @@ function configFile(): string { export interface CliConfig { token?: string apiBase?: string + // Repos ("owner/name") opted in to laptop transcript sync. Consent is + // per-repo, developer-local (see lib/enrollment.ts); `agent session sync` + // silently no-ops anywhere else. + enrolledRepos?: string[] } export function loadConfig(): CliConfig { diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts new file mode 100644 index 0000000..ce67dc9 --- /dev/null +++ b/src/lib/enrollment.ts @@ -0,0 +1,63 @@ +// Per-repo enrollment for laptop transcript sync. +// +// Consent is per-repo opt-in, never account-wide (LOCAL_CLAUDE_CODE.md §7.1): +// `agent session sync` resolves the cwd's git remote to an "owner/name" repo +// and silently no-ops unless that repo is in the enrolled set. The set lives in +// the CLI config file on the developer's machine — enrollment is a local, +// developer-owned decision, not server state. + +import { execFileSync } from 'node:child_process' +import { loadConfig, saveConfig } from './config' + +// Parse a git remote URL to "owner/name". Handles the two shapes GitHub +// actually emits: ssh (git@github.com:owner/name.git) and https +// (https://github.com/owner/name.git). Returns undefined for anything else — +// an unrecognized remote means "not enrolled", never a crash. +export function parseRepoFromRemoteUrl(url: string): string | undefined { + const trimmed = url.trim().replace(/\.git$/, '') + const ssh = trimmed.match(/^[\w.-]+@[\w.-]+:([\w.-]+\/[\w.-]+)$/) + if (ssh) return ssh[1] + const https = trimmed.match(/^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)$/) + if (https) return https[1] + return undefined +} + +// Resolve the repo ("owner/name") for a working directory via its `origin` +// remote. Undefined when the cwd isn't in a git repo, has no origin, or the +// remote URL isn't a recognizable GitHub-style URL. +export function repoForCwd(cwd: string): string | undefined { + try { + const url = execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + return parseRepoFromRemoteUrl(url) + } catch { + return undefined + } +} + +export function enrolledRepos(): string[] { + return loadConfig().enrolledRepos ?? [] +} + +export function isEnrolled(repo: string): boolean { + return enrolledRepos().some((r) => r.toLowerCase() === repo.toLowerCase()) +} + +export function enrollRepo(repo: string): void { + const config = loadConfig() + const repos = config.enrolledRepos ?? [] + if (!repos.some((r) => r.toLowerCase() === repo.toLowerCase())) { + repos.push(repo) + } + saveConfig({ ...config, enrolledRepos: repos }) +} + +export function unenrollRepo(repo: string): void { + const config = loadConfig() + const repos = (config.enrolledRepos ?? []).filter( + (r) => r.toLowerCase() !== repo.toLowerCase(), + ) + saveConfig({ ...config, enrolledRepos: repos }) +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts new file mode 100644 index 0000000..741e785 --- /dev/null +++ b/src/lib/hooks.ts @@ -0,0 +1,108 @@ +// Claude Code hook management for laptop transcript sync. +// +// `agent hooks install` writes `Stop` + `SessionEnd` handlers into the user's +// ~/.claude/settings.json (user scope: hooks fire in every project; the +// per-REPO consent gate lives in enrollment, not here). Each handler execs +// `agent session sync --hook`, which reads the hook JSON from stdin and +// silently no-ops for unenrolled repos. Stop runs async so a slow upload never +// blocks the developer's session; SessionEnd runs synchronously (the CC +// process is exiting — an async handler may never finish) with a timeout. + +import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' + +export const HOOK_COMMAND = 'agent session sync --hook' +const HOOK_EVENTS = ['Stop', 'SessionEnd'] as const + +interface HookHandler { + type: 'command' + command: string + async?: boolean + timeout?: number +} + +interface MatcherGroup { + matcher?: string + hooks: HookHandler[] +} + +type SettingsHooks = Record + +export function claudeSettingsPath(): string { + const base = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') + return join(base, 'settings.json') +} + +function isOurs(handler: HookHandler): boolean { + return ( + handler.type === 'command' && + typeof handler.command === 'string' && + handler.command.includes('agent session sync') + ) +} + +function readSettings(path: string): Record { + if (!existsSync(path)) return {} + return JSON.parse(readFileSync(path, 'utf8')) as Record +} + +// Idempotently add our Stop + SessionEnd handlers, preserving everything else +// in the settings file (other hooks, other keys). Returns the settings path. +export function installHooks(): string { + const path = claudeSettingsPath() + const settings = readSettings(path) + const hooks = (settings.hooks ?? {}) as SettingsHooks + for (const event of HOOK_EVENTS) { + const groups: MatcherGroup[] = hooks[event] ?? [] + // Drop any previous incarnation of our handler, then append the current one. + for (const group of groups) { + group.hooks = group.hooks.filter((h) => !isOurs(h)) + } + const kept = groups.filter((g) => g.hooks.length > 0) + kept.push({ + hooks: [ + event === 'Stop' + ? { type: 'command', command: HOOK_COMMAND, async: true } + : { type: 'command', command: HOOK_COMMAND, timeout: 60 }, + ], + }) + hooks[event] = kept + } + settings.hooks = hooks + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, JSON.stringify(settings, null, 2) + '\n') + return path +} + +// Remove our handlers (and any matcher groups left empty), preserving +// everything else. Returns the settings path. +export function uninstallHooks(): string { + const path = claudeSettingsPath() + const settings = readSettings(path) + const hooks = settings.hooks as SettingsHooks | undefined + if (hooks) { + for (const event of HOOK_EVENTS) { + const groups = hooks[event] + if (!groups) continue + for (const group of groups) { + group.hooks = group.hooks.filter((h) => !isOurs(h)) + } + const kept = groups.filter((g) => g.hooks.length > 0) + if (kept.length > 0) hooks[event] = kept + else delete hooks[event] + } + writeFileSync(path, JSON.stringify(settings, null, 2) + '\n') + } + return path +} + +// Which of our hook events are currently installed. +export function installedHookEvents(): string[] { + const settings = readSettings(claudeSettingsPath()) + const hooks = settings.hooks as SettingsHooks | undefined + if (!hooks) return [] + return HOOK_EVENTS.filter((event) => + (hooks[event] ?? []).some((g) => g.hooks.some((h) => isOurs(h))), + ) +} diff --git a/src/lib/redact.ts b/src/lib/redact.ts new file mode 100644 index 0000000..4ff5030 --- /dev/null +++ b/src/lib/redact.ts @@ -0,0 +1,56 @@ +// Client-side redaction for laptop transcript sync (LOCAL_CLAUDE_CODE.md +// §7.1): secrets never leave the laptop unredacted. This runs over the raw +// transcript JSONL text BEFORE gzip/upload, replacing recognizable credential +// material with a marker. Pattern-based, so it is best-effort by construction — +// the point is that the obvious, high-value token shapes (cloud keys, VCS +// tokens, private key blocks, Authorization headers) never reach the server. + +const REDACTED = '[REDACTED]' + +// Order matters only for overlapping matches (first pattern wins the range); +// each pattern is applied globally to every line. +const SECRET_PATTERNS: RegExp[] = [ + // Private key blocks (PEM), including ones embedded in JSON strings with + // literal \n escapes. + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, + // GitHub tokens: classic + fine-grained. + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, + // AWS access key ids (secret keys are unprefixed 40-char base64 — too + // ambiguous to match without the id, which we do catch). + /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g, + // Anthropic / OpenAI / Stripe-style "sk-" keys (covers sk-ant-…, sk-proj-…). + /\bsk-[A-Za-z0-9_-]{16,}\b/g, + // Slack tokens. + /\bxox[baprse]-[A-Za-z0-9-]{10,}\b/g, + // GitLab personal access tokens. + /\bglpat-[A-Za-z0-9_-]{20,}\b/g, + // npm automation tokens. + /\bnpm_[A-Za-z0-9]{36}\b/g, + // JWTs (three dot-separated base64url segments with the JOSE header prefix). + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, + // Authorization headers wherever they appear in tool output. + /\b(Authorization|authorization)(["']?\s*[:=]\s*["']?)(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]+/g, +] + +export function redactLine(line: string): string { + let out = line + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, (match, ...groups) => { + // The Authorization pattern keeps the header name + scheme so the + // transcript stays readable; everything else is replaced wholesale. + if (typeof groups[0] === 'string' && /^authorization$/i.test(groups[0])) { + return `${groups[0]}${groups[1]}${groups[2]} ${REDACTED}` + } + return REDACTED + }) + } + return out +} + +export function redactTranscript(text: string): string { + return text + .split('\n') + .map((line) => (line ? redactLine(line) : line)) + .join('\n') +} diff --git a/src/lib/spool.ts b/src/lib/spool.ts new file mode 100644 index 0000000..e0d01aa --- /dev/null +++ b/src/lib/spool.ts @@ -0,0 +1,84 @@ +// Spool-and-retry for laptop transcript sync (LOCAL_CLAUDE_CODE.md §7.1). +// +// A sync that fails for network-ish reasons is written to a spool directory +// (one file per CC session — every sync is a whole snapshot, so the latest +// payload supersedes any earlier spooled one) and re-attempted opportunistically +// before the next successful sync. 4xx rejections are never spooled: they will +// not succeed on retry. + +import { join } from 'node:path' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { homedir } from 'node:os' +import type { ApiClient } from './api' +import { ApiError } from './api' +import type { SyncSessionRequest } from './types' + +// Keep the spool bounded: a laptop that is offline for a long stretch should +// not accumulate unbounded transcript snapshots. +const MAX_SPOOLED = 50 + +function spoolDir(): string { + const base = + process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis') + return join(base, 'spool') +} + +export function spoolSync(payload: SyncSessionRequest): void { + const dir = spoolDir() + mkdirSync(dir, { recursive: true }) + // One file per CC session: the latest snapshot supersedes earlier ones. + const safeName = payload.cc_session_id.replace(/[^A-Za-z0-9._-]/g, '_') + writeFileSync(join(dir, `${safeName}.json`), JSON.stringify(payload), { + mode: 0o600, + }) + // Evict oldest beyond the cap. + const files = readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => a.mtime - b.mtime) + for (const { f } of files.slice(0, Math.max(0, files.length - MAX_SPOOLED))) { + rmSync(join(dir, f), { force: true }) + } +} + +// Retry every spooled sync. A 4xx drops the file (it will never succeed); any +// other failure stops the flush — the network is likely still down, and the +// next sync retries again. Returns how many spooled syncs were delivered. +export async function flushSpool(api: ApiClient): Promise { + const dir = spoolDir() + if (!existsSync(dir)) return 0 + const files = readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + let delivered = 0 + for (const f of files) { + const path = join(dir, f) + let payload: SyncSessionRequest + try { + payload = JSON.parse(readFileSync(path, 'utf8')) as SyncSessionRequest + } catch { + rmSync(path, { force: true }) + continue + } + try { + await api.syncSession(payload) + rmSync(path, { force: true }) + delivered += 1 + } catch (err) { + if (err instanceof ApiError && err.status < 500) { + rmSync(path, { force: true }) + continue + } + break + } + } + return delivered +} diff --git a/src/lib/types.ts b/src/lib/types.ts index b70bf04..9e43f5f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -257,3 +257,36 @@ export interface CliAuthPoll { status: CliAuthPollStatus access_token?: string } + +// ---------------------------- laptop sync -------------------------------- + +// POST /v1/sessions/sync — mirrors SyncLaptopSessionRequest/-Response in +// ellipsis/src/public_api/services/laptop_sync_service.py. +export type SyncHookEvent = 'stop' | 'session_end' | 'manual' + +export interface SyncSessionRequest { + cc_session_id: string + // The full on-disk JSONL transcript, redacted client-side, gzipped, base64. + transcript_gzip_b64: string + repo?: string + cwd?: string + hook_event?: SyncHookEvent + reason?: string +} + +export interface SyncSessionResponse { + agent_session_id: string + agent_process_id: string + event_count: number + stored: boolean +} + +// POST /v1/sessions/handoff — mirrors HandoffSessionRequest in +// ellipsis/src/public_api/services/handoff_service.py. The response is the +// created agent session (same shape the run endpoints return). +export interface HandoffSessionRequest { + cc_session_id: string + repo: string + wip_sha: string + prompt?: string +} diff --git a/test/enrollment.test.ts b/test/enrollment.test.ts new file mode 100644 index 0000000..eaa6688 --- /dev/null +++ b/test/enrollment.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + enrollRepo, + enrolledRepos, + isEnrolled, + parseRepoFromRemoteUrl, + unenrollRepo, +} from '../src/lib/enrollment' + +describe('parseRepoFromRemoteUrl', () => { + it('parses ssh remotes', () => { + expect(parseRepoFromRemoteUrl('git@github.com:ellipsis-dev/cli.git')).toBe( + 'ellipsis-dev/cli', + ) + expect(parseRepoFromRemoteUrl('git@github.com:ellipsis-dev/cli')).toBe( + 'ellipsis-dev/cli', + ) + }) + + it('parses https remotes', () => { + expect( + parseRepoFromRemoteUrl('https://github.com/ellipsis-dev/ellipsis.git'), + ).toBe('ellipsis-dev/ellipsis') + expect(parseRepoFromRemoteUrl('https://github.com/a/b\n')).toBe('a/b') + }) + + it('rejects unrecognizable urls', () => { + expect(parseRepoFromRemoteUrl('/local/path/repo')).toBeUndefined() + expect(parseRepoFromRemoteUrl('https://example.com/only-one-segment')).toBeUndefined() + }) +}) + +describe('enrollment set', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ellipsis-test-')) + process.env.ELLIPSIS_CONFIG_DIR = dir + }) + + afterEach(() => { + delete process.env.ELLIPSIS_CONFIG_DIR + rmSync(dir, { recursive: true, force: true }) + }) + + it('enrolls, checks (case-insensitively), and unenrolls', () => { + expect(enrolledRepos()).toEqual([]) + enrollRepo('ellipsis-dev/cli') + enrollRepo('ellipsis-dev/cli') // idempotent + expect(enrolledRepos()).toEqual(['ellipsis-dev/cli']) + expect(isEnrolled('Ellipsis-Dev/CLI')).toBe(true) + unenrollRepo('ELLIPSIS-DEV/cli') + expect(isEnrolled('ellipsis-dev/cli')).toBe(false) + }) +}) diff --git a/test/redact.test.ts b/test/redact.test.ts new file mode 100644 index 0000000..a1fabbe --- /dev/null +++ b/test/redact.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { redactLine, redactTranscript } from '../src/lib/redact' + +describe('redactLine', () => { + it('redacts GitHub tokens', () => { + const line = 'set GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz123456 done' + expect(redactLine(line)).toBe('set GH_TOKEN=[REDACTED] done') + expect( + redactLine('github_pat_11ABCDEFG0abcdefghijklmnopqrstuv'), + ).toBe('[REDACTED]') + }) + + it('redacts AWS access key ids and sk- keys', () => { + expect(redactLine('key AKIAIOSFODNN7EXAMPLE end')).toBe('key [REDACTED] end') + expect(redactLine('sk-ant-api03-averyveryverylongkeyvalue')).toBe('[REDACTED]') + }) + + it('redacts Authorization headers but keeps the scheme', () => { + expect(redactLine('"Authorization": "Bearer abc.def-ghi"')).toContain( + 'Bearer [REDACTED]', + ) + }) + + it('redacts PEM private key blocks including JSON-escaped ones', () => { + const pem = + '-----BEGIN RSA PRIVATE KEY-----\\nMIIEow…\\n-----END RSA PRIVATE KEY-----' + expect(redactLine(pem)).toBe('[REDACTED]') + }) + + it('redacts JWTs', () => { + expect( + redactLine( + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcDEF123_-abcDEF123', + ), + ).toBe('[REDACTED]') + }) + + it('leaves ordinary text alone', () => { + const line = '{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}' + expect(redactLine(line)).toBe(line) + }) +}) + +describe('redactTranscript', () => { + it('redacts per line and preserves line structure', () => { + const text = 'a\nxoxb-123456789012-abcdefghijkl\nb' + expect(redactTranscript(text)).toBe('a\n[REDACTED]\nb') + }) +})