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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions DROID-OAUTH-PROXY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
32 changes: 26 additions & 6 deletions src/commands/remote.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.')
Expand Down Expand Up @@ -66,15 +86,15 @@ export async function addRemote(args: AddRemoteArgs): Promise<void> {

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) {
Expand All @@ -97,7 +117,7 @@ export async function addRemote(args: AddRemoteArgs): Promise<void> {
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.`)
Expand Down
1 change: 1 addition & 0 deletions src/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
6 changes: 4 additions & 2 deletions src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions src/ssh.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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'],
Expand All @@ -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'],
})
Expand Down Expand Up @@ -83,7 +94,7 @@ function detectDirectory(localPath: string): boolean {
return true
}
try {
return statSync(localPath).isDirectory()
return statSyncImpl(localPath).isDirectory()
} catch {
return false
}
Expand All @@ -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'],
})
Expand Down
36 changes: 21 additions & 15 deletions tests/unit/remote-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[]) => {})

Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -148,8 +155,7 @@ describe('remote commands', () => {
})

afterAll(() => {
globalThis.fetch = originalFetch
Bun.write = originalWrite
_setRemoteCommandDepsForTest(null)
mock.restore()
})
})
27 changes: 27 additions & 0 deletions tests/unit/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.')
Expand Down
15 changes: 6 additions & 9 deletions tests/unit/ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,18 @@ import type { RemoteEntry } from '../../src/remotes'
const mockExecFileSync = mock((_file: string, _args: string[], _opts?: Record<string, unknown>) => '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(() => {
mockExecFileSync.mockReset()
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', () => {
Expand Down Expand Up @@ -135,6 +131,7 @@ describe('ssh helpers', () => {
})

afterAll(() => {
_setSshDepsForTest(null)
mock.restore()
})
})