also looking at this
feat(engine): answer texts with Codex as well as Claude Code
#7
18 files
+1080
-142
| @@ -138,14 +138,32 @@ TS_CLOUD_UI_PASSWORD= | ||
| 138 | 138 | # forward this process cannot see, or 0 to keep a public box off public DNS. |
| 139 | 139 | # TS_CLOUD_SSH_PUBLISH_DNS= |
| 140 | 140 | |
| 141 | # Uplink - text this Mac, get Claude. See README.md. | |
| 141 | # Uplink - text this Mac, get an agent. See README.md. | |
| 142 | 142 | # Who may command it. Empty = this Mac's own Messages handles only ("text myself"). |
| 143 | 143 | # Anyone listed can make an agent act on this Mac, so list only yourself. |
| 144 | 144 | UPLINK_ALLOWED= |
| 145 | # Which agent CLI answers: claude or codex. Empty = claude. | |
| 146 | UPLINK_ENGINE= | |
| 147 | # --- Claude Code --- | |
| 145 | 148 | # From `claude setup-token` (your Claude Max login, good for a year). Encrypt it: ./buddy env:set |
| 146 | 149 | CLAUDE_CODE_OAUTH_TOKEN= |
| 147 | 150 | # Optional: model alias for runs (sonnet, opus, haiku). Empty = the CLI's default. |
| 151 | # UPLINK_MODEL is the older name for this and still works. | |
| 152 | UPLINK_CLAUDE_MODEL= | |
| 148 | 153 | UPLINK_MODEL= |
| 154 | # Optional: where the claude binary is, if it is not on PATH. | |
| 155 | UPLINK_CLAUDE_BIN= | |
| 156 | # --- Codex --- | |
| 157 | # Sign in with `codex login` (or `codex login --device-auth` over SSH). Codex keeps the | |
| 158 | # credential itself, under CODEX_HOME, so there is no token to put here. | |
| 159 | # Optional: where the codex binary is. Uplink also looks inside ChatGPT.app, which bundles one. | |
| 160 | UPLINK_CODEX_BIN= | |
| 161 | # Optional: model the agent should use. Empty = the CLI's default. | |
| 162 | UPLINK_CODEX_MODEL= | |
| 163 | # How much Codex may do without asking. `bypass` matches what Uplink asks of Claude Code: | |
| 164 | # no approval prompts, no sandbox, because nobody is at the Mac to answer one. | |
| 165 | # Also: danger-full-access, workspace-write, read-only. | |
| 166 | UPLINK_CODEX_PERMISSION=bypass | |
| 149 | 167 | # Where runs start when a text names no path. |
| 150 | 168 | UPLINK_WORKDIR=~ |
| 151 | 169 | # Replies longer than UPLINK_MAX_CHARS are split; past UPLINK_MAX_PARTS the rest waits for "more". |
| @@ -1,9 +1,11 @@ | ||
| 1 | import type { EngineId } from '../Uplink/engine' | |
| 1 | 2 | import { copyFileSync, existsSync, watch } from 'node:fs' |
| 2 | 3 | import process from 'node:process' |
| 3 | 4 | import { defineCommand } from '@stacksjs/cli' |
| 4 | import { loadConfig, systemPrompt } from '../Uplink/config' | |
| 5 | import { runChecks, writeHeartbeat } from '../Uplink/doctor' | |
| 6 | import { ClaudeEngine } from '../Uplink/engine' | |
| 5 | import { loadConfig } from '../Uplink/config' | |
| 6 | import { checksPass, runChecks, writeHeartbeat } from '../Uplink/doctor' | |
| 7 | import { ENGINE_IDS, isEngineId } from '../Uplink/engine' | |
| 8 | import { engineFor, selectedEngine } from '../Uplink/engines' | |
| 7 | 9 | import { formatDuration, toPlainText } from '../Uplink/format' |
| 8 | 10 | import { MessagesAccessError, MessagesDb } from '../Uplink/messages-db' |
| 9 | 11 | import { ModelStore } from '../Uplink/model-store' |
| @@ -18,16 +20,6 @@ import { detectWorkdir } from '../Uplink/workdir' | ||
| 18 | 20 | const HEARTBEAT_MS = 15_000 |
| 19 | 21 | const RETRY_OPEN_MS = 30_000 |
| 20 | 22 | |
| 21 | function engineFor(config: ReturnType<typeof loadConfig>): ClaudeEngine { | |
| 22 | return new ClaudeEngine({ | |
| 23 | bin: config.claudeBin, | |
| 24 | model: config.model, | |
| 25 | permissionMode: config.permissionMode, | |
| 26 | systemPrompt: systemPrompt(config), | |
| 27 | timeoutMs: config.timeoutMs, | |
| 28 | }) | |
| 29 | } | |
| 30 | ||
| 31 | 23 | async function openMessages(config: ReturnType<typeof loadConfig>, appDir: string, startedAt: number): Promise<MessagesDb> { |
| 32 | 24 | // Without Full Disk Access this fails. Under launchd, exiting would only |
| 33 | 25 | // restart-loop, so wait for the grant instead and say why in the heartbeat. |
| @@ -85,7 +77,7 @@ export default defineCommand((cli) => { | ||
| 85 | 77 | config, |
| 86 | 78 | messages, |
| 87 | 79 | sender: new AppleScriptSender(), |
| 88 | engine: engineFor(config), | |
| 80 | engine: selectedEngine(config), | |
| 89 | 81 | store: new ModelStore(`${appDir}/storage/uplink/cursor`), |
| 90 | 82 | }) |
| 91 | 83 | await uplink.start() |
| @@ -134,11 +126,19 @@ export default defineCommand((cli) => { | ||
| 134 | 126 | cli |
| 135 | 127 | .command('uplink:ask <prompt>', 'Run one prompt through the agent exactly as a text would, and print the reply') |
| 136 | 128 | .option('--cwd <dir>', 'Working directory (default: detected from the prompt)') |
| 137 | .action(async (prompt: string, options: { cwd?: string }) => { | |
| 129 | .option('--engine <name>', 'claude or codex (default: UPLINK_ENGINE)') | |
| 130 | .action(async (prompt: string, options: { cwd?: string, engine?: string }) => { | |
| 138 | 131 | const config = loadConfig() |
| 139 | 132 | const cwd = options.cwd ?? detectWorkdir(prompt, config.workdir) |
| 140 | console.log(`[uplink] running in ${cwd}`) | |
| 141 | const run = engineFor(config).run({ | |
| 133 | // Naming an engine here is how the other one gets tried without editing | |
| 134 | // .env, which is the whole point of this command. | |
| 135 | if (options.engine && !isEngineId(options.engine)) { | |
| 136 | console.error(`[uplink] unknown engine "${options.engine}". Use ${ENGINE_IDS.join(' or ')}.`) | |
| 137 | process.exit(1) | |
| 138 | } | |
| 139 | const engine = options.engine ? engineFor(options.engine as EngineId, config) : selectedEngine(config) | |
| 140 | console.log(`[uplink] running in ${cwd} on ${engine.label}`) | |
| 141 | const run = engine.run({ | |
| 142 | 142 | prompt, |
| 143 | 143 | cwd, |
| 144 | 144 | onEvent: (event) => { |
| @@ -161,7 +161,7 @@ export default defineCommand((cli) => { | ||
| 161 | 161 | if (!check.ok && check.fix) |
| 162 | 162 | console.log(` → ${check.fix}`) |
| 163 | 163 | } |
| 164 | process.exit(checks.every(c => c.ok) ? 0 : 1) | |
| 164 | process.exit(checksPass(checks) ? 0 : 1) | |
| 165 | 165 | }) |
| 166 | 166 | |
| 167 | 167 | cli |
| @@ -0,0 +1,378 @@ | ||
| 1 | import type { Subprocess } from 'bun' | |
| 2 | import type { Engine, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine' | |
| 3 | import { randomUUID } from 'node:crypto' | |
| 4 | import { rmSync } from 'node:fs' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | import { join } from 'node:path' | |
| 7 | import process from 'node:process' | |
| 8 | import { PROBE_TIMEOUT_MS } from './engine' | |
| 9 | import { truncate } from './format' | |
| 10 | ||
| 11 | /** | |
| 12 | * Runs a prompt through the OpenAI Codex CLI in its non-interactive mode | |
| 13 | * (`codex exec`). | |
| 14 | * | |
| 15 | * Same reasoning as the Claude engine: going through the CLI uses whatever | |
| 16 | * account it is logged into - a ChatGPT plan via `codex login`, no API key | |
| 17 | * needed - and brings Codex's own toolset and each repository's AGENTS.md. | |
| 18 | * | |
| 19 | * Four things about this CLI differ from Claude Code and shape everything | |
| 20 | * below. They were confirmed against a real run, not read off documentation. | |
| 21 | * | |
| 22 | * 1. Resuming is a subcommand, `codex exec resume <id> <prompt>`, not a flag. | |
| 23 | * It accepts neither `-C/--cd` nor `-s/--sandbox`, so a resumed run takes | |
| 24 | * its working directory from the recorded session. Session lookup is | |
| 25 | * filtered by cwd, so the process still has to be spawned in the right | |
| 26 | * directory. | |
| 27 | * 2. There is no `--append-system-prompt`. Uplink's instructions are prepended | |
| 28 | * to the prompt instead, which works on every version. | |
| 29 | * 3. Codex refuses to run outside a git repository without | |
| 30 | * `--skip-git-repo-check`, and Uplink's default working directory is the | |
| 31 | * home directory. The flag is mandatory, not optional. | |
| 32 | * 4. The JSONL flag is spelled `--json` on some versions and | |
| 33 | * `--experimental-json` on others, and without it the CLI prints prose | |
| 34 | * there is nothing to parse. It is resolved against the installed binary | |
| 35 | * rather than hardcoded. | |
| 36 | */ | |
| 37 | ||
| 38 | export interface CodexEngineOptions { | |
| 39 | bin: string | |
| 40 | model?: string | null | |
| 41 | /** | |
| 42 | * How much the agent may do without asking. `bypass` is the equivalent of | |
| 43 | * Claude Code's `bypassPermissions`, which is what Uplink has always used: | |
| 44 | * the point of the product is that nobody is at the Mac to approve anything. | |
| 45 | */ | |
| 46 | permission: CodexPermission | |
| 47 | systemPrompt: string | |
| 48 | timeoutMs: number | |
| 49 | env?: Record<string, string | undefined> | |
| 50 | } | |
| 51 | ||
| 52 | export type CodexPermission = 'bypass' | 'danger-full-access' | 'workspace-write' | 'read-only' | |
| 53 | ||
| 54 | export const CODEX_PERMISSIONS: readonly CodexPermission[] = ['bypass', 'danger-full-access', 'workspace-write', 'read-only'] | |
| 55 | ||
| 56 | /** A 401 from the backend, which is what a signed-out CLI produces. */ | |
| 57 | const UNAUTHORIZED = /\b401\b|unauthorized|missing bearer|not logged in|no codex credentials/i | |
| 58 | ||
| 59 | export class CodexEngine implements Engine { | |
| 60 | readonly id = 'codex' as const | |
| 61 | readonly label = 'Codex' | |
| 62 | readonly authFailureHint = 'run "codex login" on the Mac (or "codex login --device-auth" over SSH)' | |
| 63 | ||
| 64 | constructor(private readonly options: CodexEngineOptions) {} | |
| 65 | ||
| 66 | /** | |
| 67 | * `codex login status` exits 0 and says which account when signed in. Unlike | |
| 68 | * Claude Code, proof of life costs nothing here: no turn is spent, so this | |
| 69 | * can be checked as often as the UI likes. | |
| 70 | */ | |
| 71 | async probe(): Promise<EngineProbe> { | |
| 72 | try { | |
| 73 | const proc = Bun.spawn([this.options.bin, 'login', 'status'], { | |
| 74 | cwd: tmpdir(), | |
| 75 | env: this.env(), | |
| 76 | stdin: 'ignore', | |
| 77 | stdout: 'pipe', | |
| 78 | stderr: 'pipe', | |
| 79 | }) | |
| 80 | const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS) | |
| 81 | const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) | |
| 82 | const exitCode = await proc.exited | |
| 83 | clearTimeout(timer) | |
| 84 | const said = truncate((out.trim() || err.trim()) || 'Codex said nothing', 140) | |
| 85 | return exitCode === 0 | |
| 86 | ? { ok: true, detail: said } | |
| 87 | : { ok: false, detail: said === 'Codex said nothing' ? 'Not signed in yet.' : said } | |
| 88 | } | |
| 89 | catch { | |
| 90 | return { ok: false, detail: `Could not run ${this.options.bin}. Is Codex installed?` } | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Which spelling of the JSONL flag this binary offers, asked once per binary | |
| 96 | * per process. The two are not interchangeable and passing the wrong one | |
| 97 | * fails silently: the CLI prints prose instead of events and the run looks | |
| 98 | * empty rather than broken. `--help` is inert, so asking is cheap and safe. | |
| 99 | */ | |
| 100 | resolveJsonFlag(): Promise<string> { | |
| 101 | return resolveJsonFlag(this.options.bin, this.env()) | |
| 102 | } | |
| 103 | ||
| 104 | args(request: EngineRequest, jsonFlag: string, lastMessageFile: string): string[] { | |
| 105 | const prompt = `${this.options.systemPrompt}\n\n---\n\n${request.prompt}` | |
| 106 | ||
| 107 | // `codex exec resume` accepts a much shorter flag list than `codex exec`: | |
| 108 | // `--sandbox` and `--cd` are both rejected with "unexpected argument" and | |
| 109 | // exit 2. So the sandbox is set through `-c sandbox_mode=`, which both | |
| 110 | // forms accept, and only a fresh run gets `--cd`. | |
| 111 | const permission = this.options.permission === 'bypass' | |
| 112 | ? ['--dangerously-bypass-approvals-and-sandbox'] | |
| 113 | : ['--config', `sandbox_mode="${this.options.permission}"`] | |
| 114 | ||
| 115 | const args = request.sessionId | |
| 116 | ? ['exec', 'resume', request.sessionId] | |
| 117 | : ['exec'] | |
| 118 | ||
| 119 | args.push(jsonFlag, '--skip-git-repo-check', '--output-last-message', lastMessageFile) | |
| 120 | // Resume has no --cd: it takes the working directory from the recorded | |
| 121 | // session, which is why the process is spawned in `request.cwd` regardless. | |
| 122 | if (!request.sessionId) | |
| 123 | args.push('--cd', request.cwd) | |
| 124 | args.push(...permission) | |
| 125 | if (this.options.model) | |
| 126 | args.push('--model', this.options.model) | |
| 127 | args.push(prompt) | |
| 128 | return args | |
| 129 | } | |
| 130 | ||
| 131 | run(request: EngineRequest): EngineRun { | |
| 132 | const started = Date.now() | |
| 133 | let cancelled = false | |
| 134 | let timedOut = false | |
| 135 | const lastMessageFile = join(tmpdir(), `uplink-codex-${randomUUID()}.txt`) | |
| 136 | ||
| 137 | let proc: Subprocess<'ignore', 'pipe', 'pipe'> | null = null | |
| 138 | const killTree = (): void => { | |
| 139 | if (!proc) | |
| 140 | return | |
| 141 | try { | |
| 142 | process.kill(-proc.pid, 'SIGTERM') | |
| 143 | } | |
| 144 | catch { | |
| 145 | proc.kill('SIGTERM') | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | let timer: ReturnType<typeof setTimeout> | null = null | |
| 150 | ||
| 151 | const done = (async (): Promise<EngineResult> => { | |
| 152 | const jsonFlag = await this.resolveJsonFlag() | |
| 153 | ||
| 154 | proc = Bun.spawn([this.options.bin, ...this.args(request, jsonFlag, lastMessageFile)], { | |
| 155 | cwd: request.cwd, | |
| 156 | stdin: 'ignore', | |
| 157 | stdout: 'pipe', | |
| 158 | stderr: 'pipe', | |
| 159 | env: this.env(), | |
| 160 | // Its own process group, so stopping a run also stops what the agent | |
| 161 | // started. Killing only `codex` leaves those holding stdout open and | |
| 162 | // the run never ends. | |
| 163 | detached: true, | |
| 164 | }) | |
| 165 | ||
| 166 | // Cancelling before the process existed still has to take effect. | |
| 167 | if (cancelled) { | |
| 168 | killTree() | |
| 169 | return failure('Stopped.', request.sessionId ?? null, Date.now() - started) | |
| 170 | } | |
| 171 | ||
| 172 | timer = setTimeout(() => { | |
| 173 | timedOut = true | |
| 174 | killTree() | |
| 175 | }, this.options.timeoutMs) | |
| 176 | ||
| 177 | let sessionId = request.sessionId ?? null | |
| 178 | let lastText = '' | |
| 179 | let turns = 0 | |
| 180 | let failed: string | null = null | |
| 181 | ||
| 182 | const handle = (line: string): void => { | |
| 183 | const event = parseLine(line) | |
| 184 | if (!event) | |
| 185 | return | |
| 186 | switch (event.type) { | |
| 187 | case 'thread.started': { | |
| 188 | const id = typeof event.thread_id === 'string' ? event.thread_id : null | |
| 189 | if (id && id !== sessionId) { | |
| 190 | sessionId = id | |
| 191 | request.onEvent?.({ kind: 'session', sessionId: id }) | |
| 192 | } | |
| 193 | break | |
| 194 | } | |
| 195 | case 'item.completed': { | |
| 196 | const item = event.item as Record<string, any> | undefined | |
| 197 | if (!item) | |
| 198 | break | |
| 199 | if (item.type === 'agent_message') { | |
| 200 | const text = String(item.text ?? '') | |
| 201 | if (text.trim()) { | |
| 202 | lastText = text | |
| 203 | request.onEvent?.({ kind: 'text', text }) | |
| 204 | } | |
| 205 | } | |
| 206 | // `reasoning` is the model thinking aloud, not an action worth | |
| 207 | // texting as progress. Everything else is something it did. | |
| 208 | else if (item.type !== 'reasoning') { | |
| 209 | request.onEvent?.({ kind: 'tool', name: String(item.type ?? 'tool'), summary: summarizeItem(item) }) | |
| 210 | } | |
| 211 | break | |
| 212 | } | |
| 213 | case 'turn.completed': | |
| 214 | turns += 1 | |
| 215 | break | |
| 216 | case 'turn.failed': | |
| 217 | failed = errorText(event) ?? 'the turn failed' | |
| 218 | break | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | const stderr = new Response(proc.stderr).text() | |
| 223 | const decoder = new TextDecoder() | |
| 224 | let buffered = '' | |
| 225 | for await (const bytes of proc.stdout) { | |
| 226 | buffered += decoder.decode(bytes, { stream: true }) | |
| 227 | let newline = buffered.indexOf('\n') | |
| 228 | while (newline !== -1) { | |
| 229 | handle(buffered.slice(0, newline)) | |
| 230 | buffered = buffered.slice(newline + 1) | |
| 231 | newline = buffered.indexOf('\n') | |
| 232 | } | |
| 233 | } | |
| 234 | if (buffered.trim()) | |
| 235 | handle(buffered) | |
| 236 | ||
| 237 | const exitCode = await proc.exited | |
| 238 | if (timer) | |
| 239 | clearTimeout(timer) | |
| 240 | const errorOutput = (await stderr).trim() | |
| 241 | const durationMs = Date.now() - started | |
| 242 | ||
| 243 | // The last message is written to a file as well as streamed, so a stream | |
| 244 | // this version spells differently still yields the answer. | |
| 245 | const fromFile = await readAndRemove(lastMessageFile) | |
| 246 | ||
| 247 | if (cancelled) | |
| 248 | return failure('Stopped.', sessionId, durationMs) | |
| 249 | if (timedOut) | |
| 250 | return failure(`Timed out after ${Math.round(this.options.timeoutMs / 60000)} minutes.`, sessionId, durationMs) | |
| 251 | ||
| 252 | const text = (fromFile || lastText).trim() | |
| 253 | ||
| 254 | if (exitCode === 0 && !failed) { | |
| 255 | return { | |
| 256 | ok: true, | |
| 257 | text: text || 'Done.', | |
| 258 | sessionId, | |
| 259 | // Codex reports tokens, never a cost. A figure invented from a price | |
| 260 | // table would read as measured, the way Claude's genuinely is. | |
| 261 | costCents: null, | |
| 262 | durationMs, | |
| 263 | turns: turns || null, | |
| 264 | authFailure: false, | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | const reason = failed ?? errorOutput ?? '' | |
| 269 | const said = truncate(reason || text || `codex exited with code ${exitCode}`, 400) | |
| 270 | return { ...failure(said, sessionId, durationMs), authFailure: await this.looksUnauthenticated(said) } | |
| 271 | })() | |
| 272 | ||
| 273 | return { | |
| 274 | done, | |
| 275 | cancel: () => { | |
| 276 | cancelled = true | |
| 277 | killTree() | |
| 278 | }, | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | /** | |
| 283 | * Asked rather than pattern-matched where possible. A regex over failure text | |
| 284 | * is how Claude's engine has to do it, but Codex will answer the question | |
| 285 | * directly, and a wrong answer here sends someone to the wrong fix. | |
| 286 | */ | |
| 287 | private async looksUnauthenticated(said: string): Promise<boolean> { | |
| 288 | if (UNAUTHORIZED.test(said)) | |
| 289 | return true | |
| 290 | const probe = await this.probe() | |
| 291 | return !probe.ok | |
| 292 | } | |
| 293 | ||
| 294 | private env(): Record<string, string | undefined> { | |
| 295 | return { ...process.env, ...this.options.env } | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | /** One answer per binary, for the life of the process. */ | |
| 300 | const jsonFlags = new Map<string, Promise<string>>() | |
| 301 | ||
| 302 | export function resolveJsonFlag(bin: string, env: Record<string, string | undefined>): Promise<string> { | |
| 303 | const cached = jsonFlags.get(bin) | |
| 304 | if (cached) | |
| 305 | return cached | |
| 306 | const resolved = (async (): Promise<string> => { | |
| 307 | try { | |
| 308 | const proc = Bun.spawn([bin, 'exec', '--help'], { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore', env }) | |
| 309 | const help = await new Response(proc.stdout).text() | |
| 310 | await proc.exited | |
| 311 | // Prefer the plain spelling when a version offers both. | |
| 312 | if (/(^|\s)--json(\s|$)/m.test(help)) | |
| 313 | return '--json' | |
| 314 | return /--experimental-json/.test(help) ? '--experimental-json' : '--json' | |
| 315 | } | |
| 316 | catch { | |
| 317 | return '--json' | |
| 318 | } | |
| 319 | })() | |
| 320 | jsonFlags.set(bin, resolved) | |
| 321 | return resolved | |
| 322 | } | |
| 323 | ||
| 324 | /** Forget the cached answers. For tests, which use a different stub per case. */ | |
| 325 | export function clearJsonFlagCache(): void { | |
| 326 | jsonFlags.clear() | |
| 327 | } | |
| 328 | ||
| 329 | function failure(text: string, sessionId: string | null, durationMs: number): EngineResult { | |
| 330 | return { ok: false, text, sessionId, costCents: null, durationMs, turns: null, authFailure: false } | |
| 331 | } | |
| 332 | ||
| 333 | function parseLine(line: string): Record<string, any> | null { | |
| 334 | const trimmed = line.trim() | |
| 335 | if (!trimmed.startsWith('{')) | |
| 336 | return null | |
| 337 | try { | |
| 338 | return JSON.parse(trimmed) | |
| 339 | } | |
| 340 | catch { | |
| 341 | return null | |
| 342 | } | |
| 343 | } | |
| 344 | ||
| 345 | function errorText(event: Record<string, any>): string | null { | |
| 346 | const error = event.error ?? event.message | |
| 347 | if (typeof error === 'string') | |
| 348 | return error | |
| 349 | if (error && typeof error.message === 'string') | |
| 350 | return error.message | |
| 351 | return null | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * One human-readable line for something the agent did. The item kinds beyond | |
| 356 | * `agent_message` and `reasoning` are not a closed set across versions, so this | |
| 357 | * reads whichever descriptive field is present rather than switching on a name. | |
| 358 | */ | |
| 359 | export function summarizeItem(item: Record<string, any>): string { | |
| 360 | const name = String(item.type ?? 'tool') | |
| 361 | const detail = item.command ?? item.path ?? item.query ?? item.url ?? item.name ?? item.text ?? '' | |
| 362 | const shown = typeof detail === 'string' | |
| 363 | ? detail.replace(/^\/Users\/[^/]+/, '~') | |
| 364 | : Array.isArray(detail) ? detail.join(' ') : '' | |
| 365 | return shown ? `${name}: ${truncate(shown, 80)}` : name | |
| 366 | } | |
| 367 | ||
| 368 | async function readAndRemove(path: string): Promise<string> { | |
| 369 | try { | |
| 370 | const file = Bun.file(path) | |
| 371 | const text = await file.exists() ? await file.text() : '' | |
| 372 | rmSync(path, { force: true }) | |
| 373 | return text | |
| 374 | } | |
| 375 | catch { | |
| 376 | return '' | |
| 377 | } | |
| 378 | } | |
| @@ -1,6 +1,10 @@ | ||
| 1 | import type { CodexPermission } from './codex-engine' | |
| 2 | import type { EngineId } from './engine' | |
| 1 | 3 | import { homedir } from 'node:os' |
| 2 | 4 | import { join } from 'node:path' |
| 3 | 5 | import process from 'node:process' |
| 6 | import { CODEX_PERMISSIONS } from './codex-engine' | |
| 7 | import { isEngineId } from './engine' | |
| 4 | 8 | import { parseHandleList } from './handles' |
| 5 | 9 | import { DEFAULT_MESSAGES_DB } from './messages-db' |
| 6 | 10 | |
| @@ -23,9 +27,16 @@ export interface UplinkConfig { | ||
| 23 | 27 | /** A thread idle longer than this starts a fresh agent session. */ |
| 24 | 28 | sessionIdleMs: number |
| 25 | 29 | workdir: string |
| 30 | /** Which agent CLI answers a text, unless a thread says otherwise. */ | |
| 31 | engine: EngineId | |
| 26 | 32 | claudeBin: string |
| 27 | model: string | null | |
| 33 | claudeModel: string | null | |
| 34 | /** How much Claude Code may do without asking. */ | |
| 28 | 35 | permissionMode: string |
| 36 | codexBin: string | |
| 37 | codexModel: string | null | |
| 38 | /** How much Codex may do without asking. The two CLIs spell this differently. */ | |
| 39 | codexPermission: CodexPermission | |
| 29 | 40 | timeoutMs: number |
| 30 | 41 | } |
| 31 | 42 | |
| @@ -62,13 +73,31 @@ export function loadConfig(env: Env = process.env): UplinkConfig { | ||
| 62 | 73 | progressEveryMs: int(env, 'UPLINK_PROGRESS_EVERY_MS', 10 * MINUTE), |
| 63 | 74 | sessionIdleMs: int(env, 'UPLINK_SESSION_IDLE_MS', 6 * 60 * MINUTE), |
| 64 | 75 | workdir: expandHome(str(env, 'UPLINK_WORKDIR', homedir())), |
| 76 | engine: engineId(env.UPLINK_ENGINE), | |
| 65 | 77 | claudeBin: expandHome(str(env, 'UPLINK_CLAUDE_BIN', findClaude())), |
| 66 | model: env.UPLINK_MODEL || null, | |
| 78 | // UPLINK_MODEL predates the second engine and meant Claude's model alias, | |
| 79 | // so an existing .env keeps working. | |
| 80 | claudeModel: env.UPLINK_CLAUDE_MODEL || env.UPLINK_MODEL || null, | |
| 67 | 81 | permissionMode: str(env, 'UPLINK_PERMISSION_MODE', 'bypassPermissions'), |
| 82 | codexBin: expandHome(str(env, 'UPLINK_CODEX_BIN', findCodex())), | |
| 83 | codexModel: env.UPLINK_CODEX_MODEL || null, | |
| 84 | codexPermission: codexPermission(env.UPLINK_CODEX_PERMISSION), | |
| 68 | 85 | timeoutMs: int(env, 'UPLINK_TIMEOUT_MS', 90 * MINUTE), |
| 69 | 86 | } |
| 70 | 87 | } |
| 71 | 88 | |
| 89 | function engineId(value: string | undefined): EngineId { | |
| 90 | return value && isEngineId(value) ? value : 'claude' | |
| 91 | } | |
| 92 | ||
| 93 | function codexPermission(value: string | undefined): CodexPermission { | |
| 94 | return value && (CODEX_PERMISSIONS as readonly string[]).includes(value) | |
| 95 | ? value as CodexPermission | |
| 96 | // Claude Code runs with bypassPermissions, because nobody is at the Mac to | |
| 97 | // approve anything. Codex's equivalent is the same trade, spelled its way. | |
| 98 | : 'bypass' | |
| 99 | } | |
| 100 | ||
| 72 | 101 | /** |
| 73 | 102 | * launchd starts agents with a bare PATH, so `claude` from an interactive |
| 74 | 103 | * shell is not necessarily findable. Check where the installers put it. |
| @@ -89,6 +118,29 @@ function findClaude(): string { | ||
| 89 | 118 | return 'claude' |
| 90 | 119 | } |
| 91 | 120 | |
| 121 | /** | |
| 122 | * The same bare-PATH problem as findClaude, plus one path that is easy to miss: | |
| 123 | * the ChatGPT desktop app bundles its own codex binary and does not put it on | |
| 124 | * PATH. On a Mac with ChatGPT installed that is the working Codex, so a search | |
| 125 | * that skips it reports Codex missing on a machine that has it. | |
| 126 | */ | |
| 127 | function findCodex(): string { | |
| 128 | const fromPath = Bun.which('codex') | |
| 129 | if (fromPath) | |
| 130 | return fromPath | |
| 131 | for (const candidate of [ | |
| 132 | '/Applications/ChatGPT.app/Contents/Resources/codex', | |
| 133 | join(homedir(), '.local', 'bin', 'codex'), | |
| 134 | join(homedir(), '.bun', 'bin', 'codex'), | |
| 135 | '/opt/homebrew/bin/codex', | |
| 136 | '/usr/local/bin/codex', | |
| 137 | ]) { | |
| 138 | if (Bun.file(candidate).size > 0) | |
| 139 | return candidate | |
| 140 | } | |
| 141 | return 'codex' | |
| 142 | } | |
| 143 | ||
| 92 | 144 | export function systemPrompt(config: UplinkConfig): string { |
| 93 | 145 | return [ |
| 94 | 146 | 'You are Uplink, reached by text message. The user is away from this Mac and texting from a phone,', |
| @@ -3,7 +3,7 @@ import { homedir } from 'node:os' | ||
| 3 | 3 | import process from 'node:process' |
| 4 | 4 | import Run from '../Models/Run' |
| 5 | 5 | import { loadConfig } from './config' |
| 6 | import { readHeartbeat, runChecks } from './doctor' | |
| 6 | import { checksPass, readHeartbeat, runChecks } from './doctor' | |
| 7 | 7 | import { formatDuration, truncate } from './format' |
| 8 | 8 | |
| 9 | 9 | /** |
| @@ -62,7 +62,7 @@ export async function loadDashboard(appDir: string = process.cwd()): Promise<Das | ||
| 62 | 62 | ]) |
| 63 | 63 | |
| 64 | 64 | const live = isLive(heartbeat) |
| 65 | const ready = checks.every(check => check.ok) | |
| 65 | const ready = checksPass(checks) | |
| 66 | 66 | const runs = rows.map((row): RunRow => ({ |
| 67 | 67 | id: row.id, |
| 68 | 68 | prompt: truncate(row.prompt ?? '', 140), |
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | import type { Server } from 'bun' |
| 3 | 3 | import type { UplinkConfig } from './config' |
| 4 | 4 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 5 | import { homedir, tmpdir } from 'node:os' | |
| 5 | import { homedir } from 'node:os' | |
| 6 | 6 | import { join } from 'node:path' |
| 7 | 7 | import process from 'node:process' |
| 8 | import { loadConfig, systemPrompt } from './config' | |
| 9 | import { ClaudeEngine } from './engine' | |
| 8 | import { loadConfig } from './config' | |
| 9 | import { isEngineId } from './engine' | |
| 10 | import { allEngines, selectedEngine } from './engines' | |
| 10 | 11 | import { formatDuration, truncate } from './format' |
| 11 | 12 | import { MessagesAccessError, MessagesDb } from './messages-db' |
| 12 | 13 | import { AppleScriptSender } from './sender' |
| @@ -40,7 +41,7 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid') | ||
| 40 | 41 | const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) |
| 41 | 42 | const RETRY_OPEN_MS = 5_000 |
| 42 | 43 | |
| 43 | type ClaudeState = { ok: boolean, detail: string, checkedAt: number } | null | |
| 44 | type EngineState = { ok: boolean, detail: string, checkedAt: number } | |
| 44 | 45 | |
| 45 | 46 | export interface DesktopAgent { |
| 46 | 47 | port: number |
| @@ -124,29 +125,6 @@ export function setOpenAtLogin(enabled: boolean): void { | ||
| 124 | 125 | `) |
| 125 | 126 | } |
| 126 | 127 | |
| 127 | async function probeClaude(bin: string, token: string | null): Promise<ClaudeState> { | |
| 128 | const env: Record<string, string | undefined> = { ...process.env } | |
| 129 | if (token) | |
| 130 | env.CLAUDE_CODE_OAUTH_TOKEN = token | |
| 131 | else | |
| 132 | delete env.CLAUDE_CODE_OAUTH_TOKEN | |
| 133 | try { | |
| 134 | const proc = Bun.spawn([bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { cwd: tmpdir(), env, stdout: 'pipe', stderr: 'pipe' }) | |
| 135 | const timer = setTimeout(() => proc.kill(), 60_000) | |
| 136 | const text = await new Response(proc.stdout).text() | |
| 137 | clearTimeout(timer) | |
| 138 | await proc.exited | |
| 139 | const result = JSON.parse(text) as { is_error?: boolean, result?: string } | |
| 140 | if (result.is_error && !token && /authenticate|oauth|log ?in/i.test(result.result ?? '')) | |
| 141 | return { ok: false, detail: 'Not signed in yet.', checkedAt: Date.now() } | |
| 142 | return result.is_error | |
| 143 | ? { ok: false, detail: truncate(result.result ?? 'Claude did not answer', 140), checkedAt: Date.now() } | |
| 144 | : { ok: true, detail: token ? 'Signed in with your token' : 'Signed in through the Claude CLI', checkedAt: Date.now() } | |
| 145 | } | |
| 146 | catch { | |
| 147 | return { ok: false, detail: `Could not run ${bin}. Is Claude Code installed?`, checkedAt: Date.now() } | |
| 148 | } | |
| 149 | } | |
| 150 | 128 | |
| 151 | 129 | export async function startDesktopAgent(options: { version: string }): Promise<DesktopAgent> { |
| 152 | 130 | mkdirSync(DATA_DIR, { recursive: true }) |
| @@ -169,23 +147,25 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 169 | 147 | let messages: MessagesDb | null = null |
| 170 | 148 | let messagesError: string | null = null |
| 171 | 149 | let uplink: Uplink | null = null |
| 172 | let claude: ClaudeState = null | |
| 173 | let checkingClaude = false | |
| 174 | ||
| 175 | const engine = (): ClaudeEngine => new ClaudeEngine({ | |
| 176 | bin: config.claudeBin, | |
| 177 | model: config.model, | |
| 178 | permissionMode: config.permissionMode, | |
| 179 | systemPrompt: systemPrompt(config), | |
| 180 | timeoutMs: config.timeoutMs, | |
| 181 | }) | |
| 182 | ||
| 183 | const checkClaude = async (): Promise<void> => { | |
| 184 | if (checkingClaude) | |
| 150 | const engines = new Map<string, EngineState>() | |
| 151 | let checkingEngines = false | |
| 152 | ||
| 153 | /** | |
| 154 | * Probe every engine, not only the selected one: the popover shows the other | |
| 155 | * as available rather than as a failure, which is how a person discovers they | |
| 156 | * can switch. | |
| 157 | */ | |
| 158 | const checkEngines = async (): Promise<void> => { | |
| 159 | if (checkingEngines) | |
| 185 | 160 | return |
| 186 | checkingClaude = true | |
| 187 | claude = await probeClaude(config.claudeBin, token) | |
| 188 | checkingClaude = false | |
| 161 | checkingEngines = true | |
| 162 | try { | |
| 163 | for (const engine of allEngines(config)) | |
| 164 | engines.set(engine.id, { ...await engine.probe(), checkedAt: Date.now() }) | |
| 165 | } | |
| 166 | finally { | |
| 167 | checkingEngines = false | |
| 168 | } | |
| 189 | 169 | } |
| 190 | 170 | |
| 191 | 171 | const startWatching = async (): Promise<void> => { |
| @@ -199,7 +179,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 199 | 179 | messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error) |
| 200 | 180 | return |
| 201 | 181 | } |
| 202 | uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: engine(), store }) | |
| 182 | uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: selectedEngine(config), store }) | |
| 203 | 183 | await uplink.start() |
| 204 | 184 | } |
| 205 | 185 | |
| @@ -222,7 +202,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 222 | 202 | }, 15_000) |
| 223 | 203 | |
| 224 | 204 | await startWatching() |
| 225 | void checkClaude() | |
| 205 | void checkEngines() | |
| 226 | 206 | |
| 227 | 207 | const status = () => { |
| 228 | 208 | const recent = store.recentRuns(8).map(run => ({ |
| @@ -244,21 +224,33 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 244 | 224 | name: 'Read Messages', |
| 245 | 225 | ok: messages !== null, |
| 246 | 226 | detail: messages ? 'Full Disk Access granted' : (messagesError ?? 'Waiting for Full Disk Access'), |
| 227 | informational: false, | |
| 247 | 228 | }, |
| 248 | { | |
| 249 | id: 'claude', | |
| 250 | name: 'Sign in to Claude', | |
| 251 | ok: claude?.ok ?? false, | |
| 252 | detail: checkingClaude ? 'Checking' : (claude?.detail ?? 'Not checked yet'), | |
| 253 | }, | |
| 229 | ...allEngines(config).map((engine) => { | |
| 230 | const state = engines.get(engine.id) | |
| 231 | const selected = engine.id === config.engine | |
| 232 | return { | |
| 233 | // `engine` is the selected one, and the popover draws its sign-in | |
| 234 | // step. The other is listed so switching is discoverable. | |
| 235 | id: selected ? 'engine' : `engine:${engine.id}`, | |
| 236 | name: selected ? `Sign in to ${engine.label}` : engine.label, | |
| 237 | engineId: engine.id, | |
| 238 | label: engine.label, | |
| 239 | ok: state?.ok ?? false, | |
| 240 | detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'), | |
| 241 | informational: !selected, | |
| 242 | } | |
| 243 | }), | |
| 254 | 244 | { |
| 255 | 245 | id: 'handles', |
| 256 | 246 | name: 'Who can text it', |
| 257 | 247 | ok: allowed.length > 0, |
| 258 | 248 | detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.', |
| 249 | informational: false, | |
| 259 | 250 | }, |
| 260 | 251 | ] |
| 261 | const ready = checks.every(check => check.ok) | |
| 252 | // An engine the person has not selected must not hold the app in setup. | |
| 253 | const ready = checks.every(check => check.ok || check.informational) | |
| 262 | 254 | const state = settings.paused ? 'paused' : !ready ? 'setup' : active.length > 0 ? 'working' : 'listening' |
| 263 | 255 | return { |
| 264 | 256 | state, |
| @@ -267,11 +259,18 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 267 | 259 | checks, |
| 268 | 260 | active, |
| 269 | 261 | recent, |
| 270 | settings: { allowed: settings.allowed, openAtLogin: settings.openAtLogin, paused: settings.paused, model: settings.model }, | |
| 262 | settings: { allowed: settings.allowed, engine: settings.engine, openAtLogin: settings.openAtLogin, paused: settings.paused, model: settings.model }, | |
| 263 | engines: allEngines(config).map(engine => ({ id: engine.id, label: engine.label })), | |
| 271 | 264 | lastError: uplink?.lastError ?? null, |
| 272 | 265 | } |
| 273 | 266 | } |
| 274 | 267 | |
| 268 | /** The selected engine's probe, which is what a sign-in button wants back. */ | |
| 269 | const engineResult = (): { ok: boolean, detail: string } => { | |
| 270 | const state = engines.get(config.engine) | |
| 271 | return { ok: state?.ok ?? false, detail: state?.detail ?? 'Not checked yet' } | |
| 272 | } | |
| 273 | ||
| 275 | 274 | const json = (body: unknown, status = 200): Response => Response.json(body, { status, headers: { 'cache-control': 'no-store' } }) |
| 276 | 275 | |
| 277 | 276 | const server = Bun.serve({ |
| @@ -301,10 +300,12 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 301 | 300 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles']) |
| 302 | 301 | return json({ ok: true }) |
| 303 | 302 | |
| 304 | case '/api/open/terminal': | |
| 305 | // `claude setup-token` needs a real terminal for its browser sign-in. | |
| 306 | Bun.spawnSync(['osascript', '-e', 'tell application "Terminal" to do script "claude setup-token"', '-e', 'tell application "Terminal" to activate']) | |
| 307 | return json({ ok: true }) | |
| 303 | case '/api/open/terminal': { | |
| 304 | // Both sign-ins need a real terminal for their browser round trip. | |
| 305 | const command = config.engine === 'codex' ? 'codex login' : 'claude setup-token' | |
| 306 | Bun.spawnSync(['osascript', '-e', `tell application "Terminal" to do script "${command}"`, '-e', 'tell application "Terminal" to activate']) | |
| 307 | return json({ ok: true, command }) | |
| 308 | } | |
| 308 | 309 | |
| 309 | 310 | case '/api/token': { |
| 310 | 311 | const cleaned = cleanToken(String(body.token ?? '')) |
| @@ -313,17 +314,22 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 313 | 314 | writeToken(cleaned) |
| 314 | 315 | token = cleaned |
| 315 | 316 | process.env.CLAUDE_CODE_OAUTH_TOKEN = cleaned |
| 316 | await checkClaude() | |
| 317 | return json({ ok: claude?.ok ?? false, detail: claude?.detail }) | |
| 317 | await checkEngines() | |
| 318 | return json(engineResult()) | |
| 318 | 319 | } |
| 319 | 320 | |
| 321 | // `/api/claude/test` is the old name for this and still answers, because | |
| 322 | // a popover cached from an older build would otherwise have a dead button. | |
| 320 | 323 | case '/api/claude/test': |
| 321 | await checkClaude() | |
| 322 | return json({ ok: claude?.ok ?? false, detail: claude?.detail }) | |
| 324 | case '/api/engine/test': | |
| 325 | await checkEngines() | |
| 326 | return json(engineResult()) | |
| 323 | 327 | |
| 324 | 328 | case '/api/settings': { |
| 325 | 329 | if (Array.isArray(body.allowed)) |
| 326 | 330 | settings.allowed = body.allowed.map(String).map((s: string) => s.trim()).filter(Boolean) |
| 331 | if (typeof body.engine === 'string' && isEngineId(body.engine)) | |
| 332 | settings.engine = body.engine | |
| 327 | 333 | if (typeof body.paused === 'boolean') |
| 328 | 334 | settings.paused = body.paused |
| 329 | 335 | if (typeof body.openAtLogin === 'boolean') { |
| @@ -334,6 +340,9 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 334 | 340 | settings = readSettings() |
| 335 | 341 | config = loadConfig(settingsEnv(settings)) |
| 336 | 342 | await restartWatching() |
| 343 | // A switched engine has not been probed under the new selection yet, | |
| 344 | // and the popover reads the answer straight back. | |
| 345 | await checkEngines() | |
| 337 | 346 | return json(status()) |
| 338 | 347 | } |
| 339 | 348 | |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import type { UplinkConfig } from './config' |
| 2 | import type { Engine } from './engine' | |
| 2 | 3 | import { existsSync } from 'node:fs' |
| 3 | 4 | import { join } from 'node:path' |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import process from 'node:process' | |
| 6 | import { formatDuration, truncate } from './format' | |
| 5 | import { allEngines } from './engines' | |
| 6 | import { formatDuration } from './format' | |
| 7 | 7 | import { MessagesDb } from './messages-db' |
| 8 | 8 | import { servicePaths, serviceState } from './service' |
| 9 | 9 | |
| @@ -18,6 +18,16 @@ export interface Check { | ||
| 18 | 18 | ok: boolean |
| 19 | 19 | detail: string |
| 20 | 20 | fix?: string |
| 21 | /** | |
| 22 | * Shown, but not counted towards whether Uplink is ready. An engine the | |
| 23 | * person has not selected belongs here: it is information, not a problem. | |
| 24 | */ | |
| 25 | informational?: boolean | |
| 26 | } | |
| 27 | ||
| 28 | /** Whether every check that counts passed. */ | |
| 29 | export function checksPass(checks: Check[]): boolean { | |
| 30 | return checks.every(check => check.ok || check.informational) | |
| 21 | 31 | } |
| 22 | 32 | |
| 23 | 33 | export interface Heartbeat { |
| @@ -51,34 +61,14 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> { | ||
| 51 | 61 | const HEARTBEAT_STALE_MS = 2 * 60_000 |
| 52 | 62 | |
| 53 | 63 | /** |
| 54 | * Whether the claude CLI can actually answer, checked with one real turn. | |
| 55 | * | |
| 56 | * Only asking "is a token set" passed a token truncated at the terminal's | |
| 57 | * 80-column wrap: set, well-formed, and rejected with a 401 on first use. | |
| 64 | * What to do about an engine that cannot answer. The engine's own | |
| 65 | * `authFailureHint` is written for a text message, so it names the CLI and the | |
| 66 | * command but not the surrounding `.env` step this install needs. | |
| 58 | 67 | */ |
| 59 | async function claudeAuth(bin: string): Promise<{ ok: boolean, detail: string }> { | |
| 60 | try { | |
| 61 | const proc = Bun.spawn([bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { | |
| 62 | stdout: 'pipe', | |
| 63 | stderr: 'pipe', | |
| 64 | cwd: tmpdir(), | |
| 65 | }) | |
| 66 | const timer = setTimeout(() => proc.kill(), 60_000) | |
| 67 | const text = await new Response(proc.stdout).text() | |
| 68 | clearTimeout(timer) | |
| 69 | await proc.exited | |
| 70 | const result = JSON.parse(text) as { is_error?: boolean, result?: string } | |
| 71 | const via = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'the CLI login' | |
| 72 | if (!result.is_error) | |
| 73 | return { ok: true, detail: `answers (via ${via})` } | |
| 74 | const hint = process.env.CLAUDE_CODE_OAUTH_TOKEN | |
| 75 | ? ` - the token is ${process.env.CLAUDE_CODE_OAUTH_TOKEN.length} characters; a paste cut at a line wrap is the usual cause` | |
| 76 | : '' | |
| 77 | return { ok: false, detail: `${truncate(result.result ?? 'no answer', 120)}${hint}` } | |
| 78 | } | |
| 79 | catch (error) { | |
| 80 | return { ok: false, detail: `could not run ${bin}: ${error instanceof Error ? error.message : String(error)}` } | |
| 81 | } | |
| 68 | function engineFix(engine: Engine): string { | |
| 69 | return engine.id === 'claude' | |
| 70 | ? 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart' | |
| 71 | : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart' | |
| 82 | 72 | } |
| 83 | 73 | |
| 84 | 74 | export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> { |
| @@ -123,10 +113,24 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C | ||
| 123 | 113 | ? { name: 'Who can text it', ok: true, detail: config.allowed.length > 0 ? `UPLINK_ALLOWED: ${allowed.join(', ')}` : `your own handles: ${allowed.join(', ')}` } |
| 124 | 114 | : { name: 'Who can text it', ok: false, detail: 'nobody yet', fix: 'Set UPLINK_ALLOWED in .env to your phone number (and/or Apple ID email)' }) |
| 125 | 115 | |
| 126 | const auth = await claudeAuth(config.claudeBin) | |
| 127 | checks.push(auth.ok | |
| 128 | ? { name: 'Claude account', ok: true, detail: auth.detail } | |
| 129 | : { name: 'Claude account', ok: false, detail: auth.detail, fix: 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart' }) | |
| 116 | // One check per engine. Only the selected one is allowed to fail the doctor: | |
| 117 | // a person using Codex has done nothing wrong by never signing in to Claude, | |
| 118 | // and seeing the other engine listed is how they learn they can switch. | |
| 119 | for (const engine of allEngines(config)) { | |
| 120 | const selected = engine.id === config.engine | |
| 121 | const probe = await engine.probe() | |
| 122 | checks.push({ | |
| 123 | name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`, | |
| 124 | ok: probe.ok, | |
| 125 | detail: probe.detail, | |
| 126 | informational: !selected, | |
| 127 | fix: probe.ok | |
| 128 | ? undefined | |
| 129 | : selected | |
| 130 | ? engineFix(engine) | |
| 131 | : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine)}`, | |
| 132 | }) | |
| 133 | } | |
| 130 | 134 | |
| 131 | 135 | const messages = Bun.spawnSync(['pgrep', '-x', 'Messages']) |
| 132 | 136 | checks.push(messages.exitCode === 0 |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { Subprocess } from 'bun' |
| 2 | import { tmpdir } from 'node:os' | |
| 2 | 3 | import process from 'node:process' |
| 3 | 4 | import { truncate } from './format' |
| 4 | 5 | |
| @@ -43,8 +44,37 @@ export interface EngineRun { | ||
| 43 | 44 | cancel: () => void |
| 44 | 45 | } |
| 45 | 46 | |
| 47 | /** Which agent CLI answers a text. */ | |
| 48 | export type EngineId = 'claude' | 'codex' | |
| 49 | ||
| 50 | export const ENGINE_IDS: readonly EngineId[] = ['claude', 'codex'] | |
| 51 | ||
| 52 | export function isEngineId(value: string): value is EngineId { | |
| 53 | return (ENGINE_IDS as readonly string[]).includes(value) | |
| 54 | } | |
| 55 | ||
| 56 | /** Whether a CLI can answer right now, for the doctor and the menubar. */ | |
| 57 | export interface EngineProbe { | |
| 58 | ok: boolean | |
| 59 | detail: string | |
| 60 | } | |
| 61 | ||
| 46 | 62 | export interface Engine { |
| 63 | readonly id: EngineId | |
| 64 | /** How the engine is named to a person: "Claude Code", "Codex". */ | |
| 65 | readonly label: string | |
| 66 | /** | |
| 67 | * What to text back when a run failed on authentication. The person is away | |
| 68 | * from the Mac, so this has to name the CLI and the exact command. | |
| 69 | */ | |
| 70 | readonly authFailureHint: string | |
| 47 | 71 | run: (request: EngineRequest) => EngineRun |
| 72 | /** | |
| 73 | * Can this CLI answer right now. Separate from `run` because setup has to | |
| 74 | * answer it before any text arrives, and because the two CLIs disagree on | |
| 75 | * what proof of life costs: Claude spends a turn, Codex does not. | |
| 76 | */ | |
| 77 | probe: () => Promise<EngineProbe> | |
| 48 | 78 | } |
| 49 | 79 | |
| 50 | 80 | export interface ClaudeEngineOptions { |
| @@ -58,9 +88,59 @@ export interface ClaudeEngineOptions { | ||
| 58 | 88 | |
| 59 | 89 | const AUTH_FAILURE = /failed to authenticate|oauth|invalid api key|please run \/login|not logged in|credit balance/i |
| 60 | 90 | |
| 91 | /** A probe that hangs is worse than one that fails: setup shows "Checking" for ever. */ | |
| 92 | export const PROBE_TIMEOUT_MS = 60_000 | |
| 93 | ||
| 61 | 94 | export class ClaudeEngine implements Engine { |
| 95 | readonly id = 'claude' as const | |
| 96 | readonly label = 'Claude Code' | |
| 97 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' | |
| 98 | ||
| 62 | 99 | constructor(private readonly options: ClaudeEngineOptions) {} |
| 63 | 100 | |
| 101 | /** | |
| 102 | * One real turn, on the cheapest model. Only asking "is a token set" passed a | |
| 103 | * token truncated at the terminal's 80-column wrap: set, well-formed, and | |
| 104 | * rejected with a 401 on first use. | |
| 105 | */ | |
| 106 | async probe(): Promise<EngineProbe> { | |
| 107 | try { | |
| 108 | const proc = Bun.spawn([this.options.bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { | |
| 109 | cwd: tmpdir(), | |
| 110 | env: this.env(), | |
| 111 | stdin: 'ignore', | |
| 112 | stdout: 'pipe', | |
| 113 | stderr: 'pipe', | |
| 114 | }) | |
| 115 | const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS) | |
| 116 | const text = await new Response(proc.stdout).text() | |
| 117 | clearTimeout(timer) | |
| 118 | await proc.exited | |
| 119 | const result = JSON.parse(text) as { is_error?: boolean, result?: string } | |
| 120 | if (result.is_error) { | |
| 121 | const said = truncate(result.result ?? 'Claude did not answer', 140) | |
| 122 | return { ok: false, detail: AUTH_FAILURE.test(said) ? 'Not signed in yet.' : said } | |
| 123 | } | |
| 124 | return { ok: true, detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI' } | |
| 125 | } | |
| 126 | catch { | |
| 127 | return { ok: false, detail: `Could not run ${this.options.bin}. Is Claude Code installed?` } | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * A .env copied from .env.example carries `CLAUDE_CODE_OAUTH_TOKEN=`; an | |
| 133 | * empty token must not stand in for the CLI's own login. The probe needs the | |
| 134 | * same treatment as a run, or a fresh install reports a failure a real run | |
| 135 | * would not have. | |
| 136 | */ | |
| 137 | private env(): Record<string, string | undefined> { | |
| 138 | const env: Record<string, string | undefined> = { ...process.env, ...this.options.env } | |
| 139 | if (!env.CLAUDE_CODE_OAUTH_TOKEN) | |
| 140 | delete env.CLAUDE_CODE_OAUTH_TOKEN | |
| 141 | return env | |
| 142 | } | |
| 143 | ||
| 64 | 144 | args(request: EngineRequest): string[] { |
| 65 | 145 | const args = [ |
| 66 | 146 | '-p', |
| @@ -85,11 +165,7 @@ export class ClaudeEngine implements Engine { | ||
| 85 | 165 | let cancelled = false |
| 86 | 166 | let timedOut = false |
| 87 | 167 | |
| 88 | const env: Record<string, string | undefined> = { ...process.env, ...this.options.env } | |
| 89 | // A .env copied from .env.example carries `CLAUDE_CODE_OAUTH_TOKEN=`; an | |
| 90 | // empty token must not stand in for the CLI's own login. | |
| 91 | if (!env.CLAUDE_CODE_OAUTH_TOKEN) | |
| 92 | delete env.CLAUDE_CODE_OAUTH_TOKEN | |
| 168 | const env = this.env() | |
| 93 | 169 | |
| 94 | 170 | const proc: Subprocess<'ignore', 'pipe', 'pipe'> = Bun.spawn([this.options.bin, ...this.args(request)], { |
| 95 | 171 | cwd: request.cwd, |
| @@ -0,0 +1,46 @@ | ||
| 1 | import type { UplinkConfig } from './config' | |
| 2 | import type { Engine, EngineId } from './engine' | |
| 3 | import { CodexEngine } from './codex-engine' | |
| 4 | import { systemPrompt } from './config' | |
| 5 | import { ClaudeEngine, ENGINE_IDS } from './engine' | |
| 6 | ||
| 7 | /** | |
| 8 | * Builds engines from config, so the watcher, the app and the doctor all select | |
| 9 | * one the same way. Both construction sites used to say `new ClaudeEngine(...)` | |
| 10 | * with their own copy of the options, which is two places to forget. | |
| 11 | */ | |
| 12 | ||
| 13 | export function engineFor(id: EngineId, config: UplinkConfig): Engine { | |
| 14 | const prompt = systemPrompt(config) | |
| 15 | if (id === 'codex') { | |
| 16 | return new CodexEngine({ | |
| 17 | bin: config.codexBin, | |
| 18 | model: config.codexModel, | |
| 19 | permission: config.codexPermission, | |
| 20 | systemPrompt: prompt, | |
| 21 | timeoutMs: config.timeoutMs, | |
| 22 | }) | |
| 23 | } | |
| 24 | return new ClaudeEngine({ | |
| 25 | bin: config.claudeBin, | |
| 26 | model: config.claudeModel, | |
| 27 | permissionMode: config.permissionMode, | |
| 28 | systemPrompt: prompt, | |
| 29 | timeoutMs: config.timeoutMs, | |
| 30 | }) | |
| 31 | } | |
| 32 | ||
| 33 | /** The configured engine. */ | |
| 34 | export function selectedEngine(config: UplinkConfig): Engine { | |
| 35 | return engineFor(config.engine, config) | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Every engine, selected one first. The doctor and the menubar report on all of | |
| 40 | * them: an engine the person has not set up is information, not a failure, and | |
| 41 | * seeing that the other one is available is how they learn they can switch. | |
| 42 | */ | |
| 43 | export function allEngines(config: UplinkConfig): Engine[] { | |
| 44 | const ordered = [config.engine, ...ENGINE_IDS.filter(id => id !== config.engine)] | |
| 45 | return ordered.map(id => engineFor(id, config)) | |
| 46 | } | |
| @@ -9,6 +9,7 @@ import { CHAT_STYLE_DIRECT } from './messages-db' | ||
| 9 | 9 | * rule is an allowlist, never a denylist, and every check fails closed. |
| 10 | 10 | * |
| 11 | 11 | * A message is a command when ALL of these hold: |
| 12 | * - it arrived over iMessage, never SMS (see below), | |
| 12 | 13 | * - it is in a one-to-one chat (never a group: anyone could be added to one), |
| 13 | 14 | * - that chat is with an allowed handle, |
| 14 | 15 | * - it was sent BY that handle - or, in a chat with one of this Mac's own |
| @@ -33,7 +34,26 @@ export type Verdict = | ||
| 33 | 34 | | { accept: true, text: string } |
| 34 | 35 | | { accept: false, reason: string } |
| 35 | 36 | |
| 37 | /** | |
| 38 | * Only iMessage carries an identity worth trusting. | |
| 39 | * | |
| 40 | * With Text Message Forwarding on, which is the default once an iPhone is | |
| 41 | * paired and is the setup this product assumes, forwarded SMS lands in chat.db | |
| 42 | * as a direct chat whose identifier is the sender's number. SMS sender IDs are | |
| 43 | * spoofable through commodity bulk-SMS gateways; iMessage sender IDs are not. | |
| 44 | * Without this check, anyone who knows the owner's phone number could send one | |
| 45 | * SMS that passes every other test here and reaches an agent running with | |
| 46 | * approvals disabled in the owner's home directory. | |
| 47 | * | |
| 48 | * iMessage is also the only service that reaches this Mac over satellite, which | |
| 49 | * is what Uplink is for, so nothing real is lost. | |
| 50 | */ | |
| 51 | const COMMAND_SERVICE = 'iMessage' | |
| 52 | ||
| 36 | 53 | export function classify(message: IncomingMessage, ctx: FilterContext): Verdict { |
| 54 | if (message.service !== COMMAND_SERVICE) | |
| 55 | return { accept: false, reason: `not ${COMMAND_SERVICE}` } | |
| 56 | ||
| 37 | 57 | if (message.chatStyle !== CHAT_STYLE_DIRECT) |
| 38 | 58 | return { accept: false, reason: 'group chat' } |
| 39 | 59 | |
| @@ -1,6 +1,8 @@ | ||
| 1 | import type { EngineId } from './engine' | |
| 1 | 2 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 2 | 3 | import { homedir } from 'node:os' |
| 3 | 4 | import { join } from 'node:path' |
| 5 | import { isEngineId } from './engine' | |
| 4 | 6 | |
| 5 | 7 | /** |
| 6 | 8 | * Settings for the downloadable app, which has no .env: a JSON file for |
| @@ -17,12 +19,16 @@ export const DATABASE_PATH = join(DATA_DIR, 'uplink.sqlite') | ||
| 17 | 19 | export const LOG_PATH = join(homedir(), 'Library', 'Logs', 'Uplink.log') |
| 18 | 20 | |
| 19 | 21 | const KEYCHAIN_SERVICE = 'com.stacksjs.uplink' |
| 22 | // Claude Code's token. Codex keeps its own credentials in CODEX_HOME via | |
| 23 | // `codex login`, so there is nothing of its to store here. | |
| 20 | 24 | const KEYCHAIN_ACCOUNT = 'claude-oauth-token' |
| 21 | 25 | |
| 22 | 26 | export interface Settings { |
| 23 | 27 | /** Handles allowed to command Uplink. Empty: this Mac's own handles. */ |
| 24 | 28 | allowed: string[] |
| 25 | /** Model alias for runs, or null for the CLI's default. */ | |
| 29 | /** Which agent CLI answers a text. */ | |
| 30 | engine: EngineId | |
| 31 | /** Claude's model alias for runs, or null for the CLI's default. */ | |
| 26 | 32 | model: string | null |
| 27 | 33 | /** Where a run starts when a text names no path. */ |
| 28 | 34 | workdir: string |
| @@ -34,6 +40,7 @@ export interface Settings { | ||
| 34 | 40 | |
| 35 | 41 | export const DEFAULT_SETTINGS: Settings = { |
| 36 | 42 | allowed: [], |
| 43 | engine: 'claude', | |
| 37 | 44 | model: null, |
| 38 | 45 | workdir: homedir(), |
| 39 | 46 | openAtLogin: true, |
| @@ -45,7 +52,12 @@ export function readSettings(path: string = SETTINGS_PATH): Settings { | ||
| 45 | 52 | return { ...DEFAULT_SETTINGS } |
| 46 | 53 | try { |
| 47 | 54 | const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<Settings> |
| 48 | return { ...DEFAULT_SETTINGS, ...parsed } | |
| 55 | const settings = { ...DEFAULT_SETTINGS, ...parsed } | |
| 56 | // A hand-edited or downgraded file can name an engine this build does not | |
| 57 | // have. Falling back beats refusing to start. | |
| 58 | if (!isEngineId(String(settings.engine))) | |
| 59 | settings.engine = DEFAULT_SETTINGS.engine | |
| 60 | return settings | |
| 49 | 61 | } |
| 50 | 62 | catch { |
| 51 | 63 | // A hand-edited file with a typo must not stop the app from starting. |
| @@ -65,6 +77,7 @@ export function writeSettings(settings: Settings, path: string = SETTINGS_PATH): | ||
| 65 | 77 | export function settingsEnv(settings: Settings, env: Record<string, string | undefined> = process.env): Record<string, string | undefined> { |
| 66 | 78 | return { |
| 67 | 79 | UPLINK_ALLOWED: settings.allowed.join(','), |
| 80 | UPLINK_ENGINE: settings.engine, | |
| 68 | 81 | UPLINK_MODEL: settings.model ?? '', |
| 69 | 82 | UPLINK_WORKDIR: settings.workdir, |
| 70 | 83 | ...Object.fromEntries(Object.entries(env).filter(([key]) => key.startsWith('UPLINK_'))), |
| @@ -366,8 +366,12 @@ export class Uplink { | ||
| 366 | 366 | }) |
| 367 | 367 | |
| 368 | 368 | if (!active.stoppedByUser) { |
| 369 | // Naming the wrong CLI, or a .env the downloadable app does not have, | |
| 370 | // sends someone at the Mac to fix something that is not broken. The | |
| 371 | // engine carries its own remedy. | |
| 372 | const engine = this.deps.engine | |
| 369 | 373 | const text = result.authFailure |
| 370 | ? 'I cannot reach Claude: the claude CLI on the Mac is not logged in. Someone at the Mac needs to run "claude setup-token" and put the token in Uplink\'s .env.' | |
| 374 | ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.` | |
| 371 | 375 | : result.ok ? result.text : `That failed: ${result.text}` |
| 372 | 376 | await this.reply(active.target, text, { keepRest: true }) |
| 373 | 377 | } |
| @@ -0,0 +1,241 @@ | ||
| 1 | import type { EngineEvent } from '../../../app/Uplink/engine' | |
| 2 | import { afterAll, beforeEach, describe, expect, it } from 'bun:test' | |
| 3 | import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { clearJsonFlagCache, CodexEngine, summarizeItem } from '../../../app/Uplink/codex-engine' | |
| 7 | ||
| 8 | const dir = mkdtempSync(join(tmpdir(), 'uplink-codex-')) | |
| 9 | afterAll(() => rmSync(dir, { recursive: true, force: true })) | |
| 10 | beforeEach(() => clearJsonFlagCache()) | |
| 11 | ||
| 12 | let counter = 0 | |
| 13 | ||
| 14 | /** | |
| 15 | * A stand-in `codex` printing the JSONL that `codex exec --json` really emits, | |
| 16 | * captured from a run of codex-cli 0.155: a thread.started carrying the id, a | |
| 17 | * turn.started, one item.completed per thing the agent did, and a | |
| 18 | * turn.completed carrying token usage and no cost. | |
| 19 | * | |
| 20 | * It also answers `exec --help`, because the engine asks the binary which | |
| 21 | * spelling of the JSONL flag it offers before using it. | |
| 22 | */ | |
| 23 | function stubCodex(options: { events?: object[], exit?: number, stderr?: string, jsonFlag?: string, lastMessage?: string } = {}): string { | |
| 24 | const path = join(dir, `codex-${counter += 1}`) | |
| 25 | const events = options.events ?? [ | |
| 26 | { type: 'thread.started', thread_id: 'th-9' }, | |
| 27 | { type: 'turn.started' }, | |
| 28 | { type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'git log --oneline -3', exit_code: 0 } }, | |
| 29 | { type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'Chiefs 24-21' } }, | |
| 30 | { type: 'turn.completed', usage: { input_tokens: 15119, cached_input_tokens: 12032, cache_write_input_tokens: 0, output_tokens: 5, reasoning_output_tokens: 0 } }, | |
| 31 | ] | |
| 32 | const lines = events.map(event => `echo '${JSON.stringify(event)}'`).join('\n') | |
| 33 | // `-o <file>` is the last-message file; the real CLI writes it on success only. | |
| 34 | const writeLast = options.lastMessage === undefined | |
| 35 | ? `for a in "$@"; do if [ "$prev" = "--output-last-message" ]; then printf '%s' "${options.lastMessage ?? 'Chiefs 24-21'}" > "$a"; fi; prev="$a"; done` | |
| 36 | : `for a in "$@"; do if [ "$prev" = "--output-last-message" ]; then printf '%s' "${options.lastMessage}" > "$a"; fi; prev="$a"; done` | |
| 37 | ||
| 38 | writeFileSync(path, `#!/bin/sh | |
| 39 | if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then | |
| 40 | echo "Usage: codex exec [OPTIONS] [PROMPT]" | |
| 41 | echo " ${options.jsonFlag ?? '--json'}" | |
| 42 | echo " Print events to stdout as JSONL" | |
| 43 | exit 0 | |
| 44 | fi | |
| 45 | printf '%s\\n' "$@" > "${path}.args" | |
| 46 | pwd > "${path}.cwd" | |
| 47 | prev="" | |
| 48 | ${writeLast} | |
| 49 | ${lines} | |
| 50 | ${options.stderr ? `echo "${options.stderr}" >&2` : ''} | |
| 51 | exit ${options.exit ?? 0} | |
| 52 | `) | |
| 53 | chmodSync(path, 0o755) | |
| 54 | return path | |
| 55 | } | |
| 56 | ||
| 57 | function engine(bin: string, overrides: Partial<{ model: string | null, permission: 'bypass' | 'workspace-write', timeoutMs: number }> = {}): CodexEngine { | |
| 58 | return new CodexEngine({ | |
| 59 | bin, | |
| 60 | model: overrides.model ?? null, | |
| 61 | permission: overrides.permission ?? 'bypass', | |
| 62 | systemPrompt: 'Answer in plain text.', | |
| 63 | timeoutMs: overrides.timeoutMs ?? 10_000, | |
| 64 | }) | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * The flags, one per line. The prompt is deliberately excluded: it carries the | |
| 69 | * system prompt and is many lines long, so it would otherwise be scattered | |
| 70 | * across this array. Use `rawArgsOf` to assert on it. | |
| 71 | */ | |
| 72 | function argsOf(bin: string): string[] { | |
| 73 | return rawArgsOf(bin).split('\n').filter(line => line.startsWith('-') || !line.includes(' ')) | |
| 74 | } | |
| 75 | ||
| 76 | function rawArgsOf(bin: string): string { | |
| 77 | return readFileSync(`${bin}.args`, 'utf8').trim() | |
| 78 | } | |
| 79 | ||
| 80 | describe('CodexEngine', () => { | |
| 81 | it('parses the event stream into a result and events', async () => { | |
| 82 | const bin = stubCodex() | |
| 83 | const events: EngineEvent[] = [] | |
| 84 | const result = await engine(bin).run({ prompt: 'scores?', cwd: dir, onEvent: e => events.push(e) }).done | |
| 85 | ||
| 86 | expect(result.ok).toBe(true) | |
| 87 | expect(result.text).toBe('Chiefs 24-21') | |
| 88 | expect(result.sessionId).toBe('th-9') | |
| 89 | // Codex reports tokens and no dollar figure, so a cost here would be invented. | |
| 90 | expect(result.costCents).toBeNull() | |
| 91 | expect(result.turns).toBe(1) | |
| 92 | expect(result.authFailure).toBe(false) | |
| 93 | ||
| 94 | expect(events).toContainEqual({ kind: 'session', sessionId: 'th-9' }) | |
| 95 | expect(events).toContainEqual({ kind: 'text', text: 'Chiefs 24-21' }) | |
| 96 | expect(events).toContainEqual({ kind: 'tool', name: 'command_execution', summary: 'command_execution: git log --oneline -3' }) | |
| 97 | }) | |
| 98 | ||
| 99 | it('always passes --skip-git-repo-check, because the default workdir is not a repo', async () => { | |
| 100 | const bin = stubCodex() | |
| 101 | await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 102 | expect(argsOf(bin)).toContain('--skip-git-repo-check') | |
| 103 | }) | |
| 104 | ||
| 105 | it('starts a fresh run with exec, --cd and the bypass flag', async () => { | |
| 106 | const bin = stubCodex() | |
| 107 | await engine(bin).run({ prompt: 'scores?', cwd: dir }).done | |
| 108 | const args = argsOf(bin) | |
| 109 | ||
| 110 | expect(args[0]).toBe('exec') | |
| 111 | expect(args).toContain('--json') | |
| 112 | expect(args.slice(args.indexOf('--cd'), args.indexOf('--cd') + 2)).toEqual(['--cd', dir]) | |
| 113 | expect(args).toContain('--dangerously-bypass-approvals-and-sandbox') | |
| 114 | // No --append-system-prompt exists, so the instructions ride on the prompt, | |
| 115 | // which is why the prompt is many lines and the last argv line is not it. | |
| 116 | const raw = rawArgsOf(bin) | |
| 117 | expect(raw).toContain('Answer in plain text.') | |
| 118 | expect(raw).toEndWith('scores?') | |
| 119 | expect(readFileSync(`${bin}.cwd`, 'utf8').trim()).toEndWith(dir.replace(/^\/private/, '')) | |
| 120 | }) | |
| 121 | ||
| 122 | it('resumes through the subcommand, with the session id positional', async () => { | |
| 123 | const bin = stubCodex() | |
| 124 | await engine(bin).run({ prompt: 'who won?', cwd: dir, sessionId: 'th-7' }).done | |
| 125 | const args = argsOf(bin) | |
| 126 | ||
| 127 | expect(args.slice(0, 3)).toEqual(['exec', 'resume', 'th-7']) | |
| 128 | // `resume` rejects both of these with "unexpected argument" and exit 2. | |
| 129 | expect(args).not.toContain('--cd') | |
| 130 | expect(args).not.toContain('--sandbox') | |
| 131 | }) | |
| 132 | ||
| 133 | it('sets a non-bypass sandbox through -c, which resume accepts', async () => { | |
| 134 | const bin = stubCodex() | |
| 135 | await engine(bin, { permission: 'workspace-write' }).run({ prompt: 'x', cwd: dir, sessionId: 'th-7' }).done | |
| 136 | const args = argsOf(bin) | |
| 137 | ||
| 138 | expect(args).not.toContain('--sandbox') | |
| 139 | expect(args.slice(args.indexOf('--config'), args.indexOf('--config') + 2)) | |
| 140 | .toEqual(['--config', 'sandbox_mode="workspace-write"']) | |
| 141 | }) | |
| 142 | ||
| 143 | it('passes the model only when one is set', async () => { | |
| 144 | const withModel = stubCodex() | |
| 145 | await engine(withModel, { model: 'gpt-5-codex' }).run({ prompt: 'x', cwd: dir }).done | |
| 146 | const args = argsOf(withModel) | |
| 147 | expect(args.slice(args.indexOf('--model'), args.indexOf('--model') + 2)).toEqual(['--model', 'gpt-5-codex']) | |
| 148 | ||
| 149 | clearJsonFlagCache() | |
| 150 | const without = stubCodex() | |
| 151 | await engine(without).run({ prompt: 'x', cwd: dir }).done | |
| 152 | expect(argsOf(without)).not.toContain('--model') | |
| 153 | }) | |
| 154 | ||
| 155 | it('uses the version spelling of the JSONL flag that the binary offers', async () => { | |
| 156 | const bin = stubCodex({ jsonFlag: '--experimental-json' }) | |
| 157 | await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 158 | const args = argsOf(bin) | |
| 159 | expect(args).toContain('--experimental-json') | |
| 160 | expect(args).not.toContain('--json') | |
| 161 | }) | |
| 162 | ||
| 163 | it('falls back to the streamed message when the last-message file is absent', async () => { | |
| 164 | // The real CLI does not write that file when a turn fails, so the stream has | |
| 165 | // to be the source of truth. | |
| 166 | const bin = stubCodex({ lastMessage: '' }) | |
| 167 | const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 168 | expect(result.text).toBe('Chiefs 24-21') | |
| 169 | }) | |
| 170 | ||
| 171 | it('reports a turn.failed as a failure', async () => { | |
| 172 | const bin = stubCodex({ | |
| 173 | events: [ | |
| 174 | { type: 'thread.started', thread_id: 'th-9' }, | |
| 175 | { type: 'turn.failed', error: { message: 'context window exceeded' } }, | |
| 176 | ], | |
| 177 | exit: 1, | |
| 178 | lastMessage: '', | |
| 179 | }) | |
| 180 | const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 181 | expect(result.ok).toBe(false) | |
| 182 | expect(result.text).toContain('context window exceeded') | |
| 183 | expect(result.sessionId).toBe('th-9') | |
| 184 | }) | |
| 185 | ||
| 186 | it('flags a signed-out CLI from the failure text', async () => { | |
| 187 | const bin = stubCodex({ | |
| 188 | events: [], | |
| 189 | exit: 1, | |
| 190 | stderr: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header', | |
| 191 | lastMessage: '', | |
| 192 | }) | |
| 193 | const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 194 | expect(result.ok).toBe(false) | |
| 195 | expect(result.authFailure).toBe(true) | |
| 196 | }) | |
| 197 | ||
| 198 | it('cancels a run', async () => { | |
| 199 | const bin = stubCodex() | |
| 200 | const run = engine(bin).run({ prompt: 'x', cwd: dir }) | |
| 201 | run.cancel() | |
| 202 | const result = await run.done | |
| 203 | expect(result.ok).toBe(false) | |
| 204 | expect(result.text).toBe('Stopped.') | |
| 205 | }) | |
| 206 | ||
| 207 | describe('probe', () => { | |
| 208 | it('is signed in when codex login status exits 0', async () => { | |
| 209 | const path = join(dir, `codex-login-ok-${counter += 1}`) | |
| 210 | // The real CLI prints this on stderr, not stdout. | |
| 211 | writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n') | |
| 212 | chmodSync(path, 0o755) | |
| 213 | expect(await engine(path).probe()).toEqual({ ok: true, detail: 'Logged in using ChatGPT' }) | |
| 214 | }) | |
| 215 | ||
| 216 | it('is not signed in when it exits non-zero', async () => { | |
| 217 | const path = join(dir, `codex-login-bad-${counter += 1}`) | |
| 218 | writeFileSync(path, '#!/bin/sh\necho "Not logged in" >&2\nexit 1\n') | |
| 219 | chmodSync(path, 0o755) | |
| 220 | const probe = await engine(path).probe() | |
| 221 | expect(probe.ok).toBe(false) | |
| 222 | expect(probe.detail).toContain('Not logged in') | |
| 223 | }) | |
| 224 | ||
| 225 | it('says so when the binary is missing', async () => { | |
| 226 | const probe = await engine(join(dir, 'not-installed')).probe() | |
| 227 | expect(probe.ok).toBe(false) | |
| 228 | expect(probe.detail).toContain('Is Codex installed?') | |
| 229 | }) | |
| 230 | }) | |
| 231 | }) | |
| 232 | ||
| 233 | describe('summarizeItem', () => { | |
| 234 | it('reads whichever descriptive field an item carries', () => { | |
| 235 | expect(summarizeItem({ type: 'command_execution', command: 'bun test' })).toBe('command_execution: bun test') | |
| 236 | expect(summarizeItem({ type: 'web_search', query: 'nfl scores' })).toBe('web_search: nfl scores') | |
| 237 | expect(summarizeItem({ type: 'file_change', path: '/Users/someone/Code/app.ts' })).toBe('file_change: ~/Code/app.ts') | |
| 238 | // Unknown kinds still produce a line, because the set is not closed. | |
| 239 | expect(summarizeItem({ type: 'something_new' })).toBe('something_new') | |
| 240 | }) | |
| 241 | }) | |
| @@ -141,6 +141,20 @@ interface PendingRun { | ||
| 141 | 141 | /** An engine whose runs finish when the test says so. */ |
| 142 | 142 | export class FakeEngine implements Engine { |
| 143 | 143 | runs: PendingRun[] = [] |
| 144 | probes = 0 | |
| 145 | ||
| 146 | // Identity matching the real Claude engine, so a test that asserts on a | |
| 147 | // reply naming the CLI is asserting the text a user would actually get. | |
| 148 | readonly id = 'claude' as const | |
| 149 | readonly label = 'Claude Code' | |
| 150 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' | |
| 151 | ||
| 152 | probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' } | |
| 153 | ||
| 154 | async probe(): Promise<{ ok: boolean, detail: string }> { | |
| 155 | this.probes += 1 | |
| 156 | return this.probeResult | |
| 157 | } | |
| 144 | 158 | |
| 145 | 159 | run(request: EngineRequest): EngineRun { |
| 146 | 160 | let resolve!: (result: EngineResult) => void |
| @@ -107,8 +107,18 @@ describe('classify', () => { | ||
| 107 | 107 | expect(classify({ ...base, chatIdentifier: ME, chatGuid: `iMessage;-;${ME}`, sender: FRIEND }, selfCtx)).toEqual({ accept: false, reason: 'sender mismatch' }) |
| 108 | 108 | }) |
| 109 | 109 | |
| 110 | it('refuses SMS even from an allowed handle, because sender IDs are spoofable', () => { | |
| 111 | // With Text Message Forwarding on, a forwarded SMS is a direct chat whose | |
| 112 | // identifier is the sender's number, so every other check here passes. | |
| 113 | expect(classify({ ...base, service: 'SMS' }, ctx)).toEqual({ accept: false, reason: 'not iMessage' }) | |
| 114 | expect(classify({ ...base, service: 'SMS', chatIdentifier: ME, chatGuid: `SMS;-;${ME}`, isFromMe: true, sender: null }, ctx)) | |
| 115 | .toEqual({ accept: false, reason: 'not iMessage' }) | |
| 116 | }) | |
| 117 | ||
| 110 | 118 | it('fails closed on everything else', () => { |
| 111 | 119 | const rejected: Array<[Partial<IncomingMessage>, string]> = [ |
| 120 | [{ service: 'SMS' }, 'not iMessage'], | |
| 121 | [{ service: 'RCS' }, 'not iMessage'], | |
| 112 | 122 | [{ chatIdentifier: '+15559999999', sender: '+15559999999' }, 'chat not allowed'], |
| 113 | 123 | [{ chatStyle: 43 }, 'group chat'], |
| 114 | 124 | [{ isFromMe: true, sender: null }, 'outgoing message'], |
| @@ -75,8 +75,18 @@ describe('settings', () => { | ||
| 75 | 75 | }) |
| 76 | 76 | |
| 77 | 77 | it('feeds loadConfig without any .env', () => { |
| 78 | const config = loadConfig(settingsEnv({ allowed: ['(555) 000-1111'], model: 'opus', workdir: '/tmp', openAtLogin: true, paused: false }, {})) | |
| 79 | expect(config).toMatchObject({ allowed: ['+15550001111'], model: 'opus', workdir: '/tmp' }) | |
| 78 | const config = loadConfig(settingsEnv({ allowed: ['(555) 000-1111'], engine: 'claude', model: 'opus', workdir: '/tmp', openAtLogin: true, paused: false }, {})) | |
| 79 | expect(config).toMatchObject({ allowed: ['+15550001111'], engine: 'claude', claudeModel: 'opus', workdir: '/tmp' }) | |
| 80 | }) | |
| 81 | ||
| 82 | it('carries the selected engine through to config', () => { | |
| 83 | const settings = { allowed: [], engine: 'codex' as const, model: null, workdir: '/tmp', openAtLogin: true, paused: false } | |
| 84 | expect(loadConfig(settingsEnv(settings, {})).engine).toBe('codex') | |
| 85 | // A settings.json naming an engine this build does not have must not stop | |
| 86 | // the app from starting. | |
| 87 | const path = join(tempDir(), 'settings.json') | |
| 88 | writeSettings({ ...settings, engine: 'gemini' as never }, path) | |
| 89 | expect(readSettings(path).engine).toBe('claude') | |
| 80 | 90 | }) |
| 81 | 91 | |
| 82 | 92 | it('cleans a token pasted across a line wrap', () => { |
| @@ -190,12 +190,15 @@ describe('Uplink', () => { | ||
| 190 | 190 | expect(h.engine.runs[1].request.sessionId).toBeNull() |
| 191 | 191 | }) |
| 192 | 192 | |
| 193 | it('tells the user when the claude CLI is not logged in', async () => { | |
| 193 | it('tells the user which CLI is not logged in, and how to fix it', async () => { | |
| 194 | 194 | const h = await harness() |
| 195 | 195 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) |
| 196 | 196 | await h.uplink.tick() |
| 197 | 197 | h.engine.finish(0, 'Failed to authenticate', { ok: false, authFailure: true }) |
| 198 | 198 | await settle() |
| 199 | // The remedy comes from the engine, so a Codex install is told about Codex | |
| 200 | // rather than about a claude token and a .env the app does not have. | |
| 201 | expect(h.sender.texts()[0]).toContain('Claude Code') | |
| 199 | 202 | expect(h.sender.texts()[0]).toContain('claude setup-token') |
| 200 | 203 | expect(h.store.runs.get(1)?.status).toBe('failed') |
| 201 | 204 | }) |