diff --git a/DROID-OAUTH-PROXY.md b/DROID-OAUTH-PROXY.md index ec86beb..a7d65bc 100644 --- a/DROID-OAUTH-PROXY.md +++ b/DROID-OAUTH-PROXY.md @@ -131,7 +131,35 @@ delete normalized.top_p The exact built chunk filename may change, e.g. `chunk-5YZRJBCQ.js`; grep for `delete normalized.max_output_tokens`. -### 3. Restart the proxy +### 3. Convert public Responses string input to Codex input items + +Droid manual/auto compaction (`/compact` / `/compress`) uses the public OpenAI Responses shorthand: + +```json +{"model":"gpt-5.5","input":"Please summarize the following conversation: ..."} +``` + +The ChatGPT Codex backend behind OAuth rejects that shorthand with: + +```json +{"detail":"Input must be a list"} +``` + +Patch the same `normalizeCodexResponsesBody` function immediately after `normalized.instructions = instructions;`: + +```js +normalized.instructions = instructions; +if (typeof normalized.input === "string") { + normalized.input = [{ + role: "user", + content: [{ type: "input_text", text: normalized.input }] + }]; +} +``` + +This preserves Droid's public OpenAI provider behavior while sending the list-shaped input format Codex OAuth expects. It is required for Droid compaction to succeed through `provider: "openai"`. + +### 4. Restart the proxy ```bash lsof -tiTCP:10531 -sTCP:LISTEN | xargs kill @@ -145,7 +173,7 @@ Verify: curl -sS http://127.0.0.1:10531/v1/models ``` -### 4. Switch Factory custom models to `provider: "openai"` +### 5. Switch Factory custom models to `provider: "openai"` Update `~/.factory/settings.json` custom models to point at the local proxy with the native OpenAI provider: diff --git a/src/commands/remote.ts b/src/commands/remote.ts index e10f05a..4d9c891 100644 --- a/src/commands/remote.ts +++ b/src/commands/remote.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdtempSync } from 'fs' -import { tmpdir } from 'os' +import { existsSync as realExistsSync, mkdtempSync as realMkdtempSync } from 'fs' +import { tmpdir as realTmpdir } from 'os' import { join } from 'path' import { addRemote as addRemoteEntry, loadRemotes, removeRemote as removeRemoteEntry } from '../remotes' import type { RemoteEntry } from '../remotes' @@ -13,6 +13,26 @@ interface AddRemoteArgs { identityFile?: string } +let existsSyncImpl: typeof realExistsSync = realExistsSync +let mkdtempSyncImpl: typeof realMkdtempSync = realMkdtempSync +let tmpdirImpl: typeof realTmpdir = realTmpdir +let fetchImpl: typeof fetch = fetch +let bunWriteImpl: typeof Bun.write = Bun.write + +export function _setRemoteCommandDepsForTest(deps: { + existsSync?: typeof realExistsSync + mkdtempSync?: typeof realMkdtempSync + tmpdir?: typeof realTmpdir + fetch?: typeof fetch + bunWrite?: typeof Bun.write +} | null): void { + existsSyncImpl = deps?.existsSync ?? realExistsSync + mkdtempSyncImpl = deps?.mkdtempSync ?? realMkdtempSync + tmpdirImpl = deps?.tmpdir ?? realTmpdir + fetchImpl = deps?.fetch ?? fetch + bunWriteImpl = deps?.bunWrite ?? Bun.write +} + function validateAlias(alias: string): void { if (!/^[a-zA-Z0-9_-]+$/.test(alias)) { throw new Error('Remote alias must be alphanumeric with dashes/underscores only.') @@ -66,15 +86,15 @@ export async function addRemote(args: AddRemoteArgs): Promise { const version = require('../../package.json').version as string const url = `https://github.com/twaldin/flt/releases/download/v${version}/${asset}` - const response = await fetch(url) + const response = await fetchImpl(url) if (!response.ok) { throw new Error(`Failed to download ${asset} from ${url}: HTTP ${response.status}`) } - const tempDir = mkdtempSync(join(tmpdir(), 'flt-remote-')) + const tempDir = mkdtempSyncImpl(join(tmpdirImpl(), 'flt-remote-')) const tempFile = join(tempDir, 'flt') const bytes = new Uint8Array(await response.arrayBuffer()) - await Bun.write(tempFile, bytes) + await bunWriteImpl(tempFile, bytes) const mkdirResult = sshExec(remote, 'mkdir -p ~/.flt/bin') if (mkdirResult.status !== 0) { @@ -97,7 +117,7 @@ export async function addRemote(args: AddRemoteArgs): Promise { console.log('Added ~/.flt/bin to PATH on remote (.bashrc + .zshrc). New shell sessions will pick it up.') const skillsDir = join(process.env.HOME || '', '.flt', 'skills') - if (skillsDir && existsSync(skillsDir)) { + if (skillsDir && existsSyncImpl(skillsDir)) { rsyncTo(remote, skillsDir, '~/.flt/skills/', { isDirectory: true }) } else { console.warn(`Warning: local skills directory not found at ${skillsDir}; skipping skills sync.`) diff --git a/src/instructions.ts b/src/instructions.ts index 0d96ee6..ee7681c 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -104,6 +104,7 @@ function buildCommsBlock(parentName: string, workflow?: string): string { function skillsDir(cli: string): string { if (cli === 'claude-code') return '.claude/skills' if (cli === 'opencode') return '.opencode/skills' + if (cli === 'droid') return '.factory/skills' return '.flt/skills' } diff --git a/src/skills.ts b/src/skills.ts index 5989a25..33d30a1 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -263,8 +263,10 @@ export function projectSkills( } else if (cliName === 'opencode') { for (const skill of selected) installAt(skill, join('.opencode', 'skills')) } else { - // codex, gemini, swe-agent, pi — write mirrors + inject list into instruction file - for (const skill of selected) installAt(skill, join('.flt', 'skills')) + // Droid has native project-local skill discovery under .factory/skills. + // Other inject-only CLIs use .flt/skills mirrors plus an instruction-file index. + const relRoot = cliName === 'droid' ? join('.factory', 'skills') : join('.flt', 'skills') + for (const skill of selected) installAt(skill, relRoot) if (adapter.instructionFile) { const filePath = join(workDir, adapter.instructionFile) diff --git a/src/ssh.ts b/src/ssh.ts index 8982edd..d160d00 100644 --- a/src/ssh.ts +++ b/src/ssh.ts @@ -1,5 +1,5 @@ -import { execFileSync } from 'child_process' -import { statSync } from 'fs' +import { execFileSync as realExecFileSync } from 'child_process' +import { statSync as realStatSync } from 'fs' import type { RemoteEntry } from './remotes' export interface SshExecResult { @@ -8,6 +8,17 @@ export interface SshExecResult { status: number } +let execFileSyncImpl: typeof realExecFileSync = realExecFileSync +let statSyncImpl: typeof realStatSync = realStatSync + +export function _setSshDepsForTest(deps: { + execFileSync?: typeof realExecFileSync + statSync?: typeof realStatSync +} | null): void { + execFileSyncImpl = deps?.execFileSync ?? realExecFileSync + statSyncImpl = deps?.statSync ?? realStatSync +} + function renderTarget(remote: RemoteEntry): string { return remote.user ? `${remote.user}@${remote.host}` : remote.host } @@ -36,7 +47,7 @@ export function buildSshArgs(remote: RemoteEntry, extra: string[] = []): string[ export function sshExec(remote: RemoteEntry, command: string, opts?: { input?: string }): SshExecResult { try { - const stdout = execFileSync('ssh', buildSshArgs(remote, [command]), { + const stdout = execFileSyncImpl('ssh', buildSshArgs(remote, [command]), { encoding: 'utf-8', input: opts?.input, stdio: ['pipe', 'pipe', 'pipe'], @@ -54,7 +65,7 @@ export function sshExec(remote: RemoteEntry, command: string, opts?: { input?: s export function sshExecCheck(remote: RemoteEntry, command: string): true | { error: string } { try { - execFileSync('ssh', buildSshArgs(remote, [command]), { + execFileSyncImpl('ssh', buildSshArgs(remote, [command]), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }) @@ -83,7 +94,7 @@ function detectDirectory(localPath: string): boolean { return true } try { - return statSync(localPath).isDirectory() + return statSyncImpl(localPath).isDirectory() } catch { return false } @@ -99,7 +110,7 @@ export function rsyncTo(remote: RemoteEntry, localPath: string, remotePath: stri const sshCommand = ['ssh', ...buildSshOptionArgs(remote)].map(shellEscapeArg).join(' ') - execFileSync('rsync', ['-az', '-e', sshCommand, source, destination], { + execFileSyncImpl('rsync', ['-az', '-e', sshCommand, source, destination], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/tests/unit/remote-cmd.test.ts b/tests/unit/remote-cmd.test.ts index e75fc57..861eec9 100644 --- a/tests/unit/remote-cmd.test.ts +++ b/tests/unit/remote-cmd.test.ts @@ -10,6 +10,9 @@ const mockRemoveRemote = mock((_alias: string) => true) const mockExistsSync = mock((_path: string) => true) const mockMkdtempSync = mock((_prefix: string) => '/tmp/flt-remote-test') +const mockTmpdir = mock(() => '/tmp') +const mockFetch = mock(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })) +const mockBunWrite = mock(async () => 3) mock.module('../../src/ssh', () => ({ sshExecCheck: mockSshExecCheck, @@ -23,16 +26,9 @@ mock.module('../../src/remotes', () => ({ removeRemote: mockRemoveRemote, })) -mock.module('fs', () => ({ - existsSync: mockExistsSync, - mkdtempSync: mockMkdtempSync, -})) - -import { addRemote, listRemotes, removeRemote } from '../../src/commands/remote' +import { _setRemoteCommandDepsForTest, addRemote, listRemotes, removeRemote } from '../../src/commands/remote' describe('remote commands', () => { - const originalFetch = globalThis.fetch - const originalWrite = Bun.write const logSpy = mock((..._args: unknown[]) => {}) const warnSpy = mock((..._args: unknown[]) => {}) @@ -60,9 +56,20 @@ describe('remote commands', () => { mockExistsSync.mockImplementation(() => true) mockMkdtempSync.mockReset() mockMkdtempSync.mockImplementation(() => '/tmp/flt-remote-test') - - globalThis.fetch = mock(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })) as typeof fetch - Bun.write = mock(async () => 3) as typeof Bun.write + mockTmpdir.mockReset() + mockTmpdir.mockImplementation(() => '/tmp') + + mockFetch.mockReset() + mockFetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })) + mockBunWrite.mockReset() + mockBunWrite.mockImplementation(async () => 3) + _setRemoteCommandDepsForTest({ + existsSync: mockExistsSync as typeof import('fs').existsSync, + mkdtempSync: mockMkdtempSync as typeof import('fs').mkdtempSync, + tmpdir: mockTmpdir as typeof import('os').tmpdir, + fetch: mockFetch as typeof fetch, + bunWrite: mockBunWrite as typeof Bun.write, + }) console.log = logSpy as typeof console.log console.warn = warnSpy as typeof console.warn @@ -77,8 +84,8 @@ describe('remote commands', () => { { host: 'example.com', user: 'alice', port: 2200, identityFile: '/tmp/key' }, 'true', ) - expect(globalThis.fetch).toHaveBeenCalled() - expect(Bun.write).toHaveBeenCalledWith('/tmp/flt-remote-test/flt', expect.any(Uint8Array)) + expect(mockFetch).toHaveBeenCalled() + expect(mockBunWrite).toHaveBeenCalledWith('/tmp/flt-remote-test/flt', expect.any(Uint8Array)) expect(mockSshExec).toHaveBeenCalledWith( { host: 'example.com', user: 'alice', port: 2200, identityFile: '/tmp/key' }, 'mkdir -p ~/.flt/bin', @@ -148,8 +155,7 @@ describe('remote commands', () => { }) afterAll(() => { - globalThis.fetch = originalFetch - Bun.write = originalWrite + _setRemoteCommandDepsForTest(null) mock.restore() }) }) diff --git a/tests/unit/skills.test.ts b/tests/unit/skills.test.ts index b930857..920eb24 100644 --- a/tests/unit/skills.test.ts +++ b/tests/unit/skills.test.ts @@ -38,6 +38,17 @@ const codexAdapter: CliAdapter = { detectStatus: () => 'idle', } +const droidAdapter: CliAdapter = { + name: 'droid', + cliCommand: 'droid', + instructionFile: 'AGENTS.md', + submitKeys: ['Enter'], + spawnArgs: () => ['droid'], + detectReady: () => 'ready', + handleDialog: () => null, + detectStatus: () => 'idle', +} + describe('skills', () => { let tempHome: string let workDir: string @@ -176,6 +187,22 @@ describe('skills', () => { }) }) + describe('projectSkills for droid', () => { + it('installs project skills under .factory/skills for Droid native discovery', () => { + makeSkill('my-skill', 'A test skill', 'Do the thing.') + writeFileSync(join(workDir, 'AGENTS.md'), '# Instructions\n') + + const result = projectSkills(workDir, droidAdapter, { requested: ['my-skill'] }) + + expect(result.names).toEqual(['my-skill']) + expect(existsSync(join(workDir, '.factory', 'skills', 'my-skill', 'SKILL.md'))).toBe(true) + expect(existsSync(join(workDir, '.flt', 'skills', 'my-skill', 'SKILL.md'))).toBe(false) + + const content = readFileSync(join(workDir, 'AGENTS.md'), 'utf-8') + expect(content).toContain('- my-skill: A test skill') + }) + }) + describe('cleanupSkills', () => { it('removes managed claude-code skill files after cleanup', () => { makeSkill('my-skill', 'A skill', 'Do the thing.') diff --git a/tests/unit/ssh.test.ts b/tests/unit/ssh.test.ts index df47ff0..1c2ef21 100644 --- a/tests/unit/ssh.test.ts +++ b/tests/unit/ssh.test.ts @@ -4,15 +4,7 @@ import type { RemoteEntry } from '../../src/remotes' const mockExecFileSync = mock((_file: string, _args: string[], _opts?: Record) => 'ok') const mockStatSync = mock((_path: string) => ({ isDirectory: () => false })) -mock.module('child_process', () => ({ - execFileSync: mockExecFileSync, -})) - -mock.module('fs', () => ({ - statSync: mockStatSync, -})) - -import { buildSshArgs, rsyncTo, shellEscapeSingle, sshExec, sshExecCheck } from '../../src/ssh' +import { _setSshDepsForTest, buildSshArgs, rsyncTo, shellEscapeSingle, sshExec, sshExecCheck } from '../../src/ssh' describe('ssh helpers', () => { beforeEach(() => { @@ -20,6 +12,10 @@ describe('ssh helpers', () => { mockExecFileSync.mockImplementation(() => 'ok') mockStatSync.mockReset() mockStatSync.mockImplementation(() => ({ isDirectory: () => false })) + _setSshDepsForTest({ + execFileSync: mockExecFileSync as typeof import('child_process').execFileSync, + statSync: mockStatSync as typeof import('fs').statSync, + }) }) it('buildSshArgs supports host-only', () => { @@ -135,6 +131,7 @@ describe('ssh helpers', () => { }) afterAll(() => { + _setSshDepsForTest(null) mock.restore() }) })