Skip to content
28 changes: 26 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,20 @@ concurrency:
cancel-in-progress: true

jobs:
verify:
runs-on: ubuntu-latest
# The required check on main is named exactly `verify`. A matrix job reports
# per-OS names instead — `verify (ubuntu-latest)` and so on — which never
# satisfies it, so the matrix runs under its own name and the aggregator at
# the bottom of this file carries the stable one.
platform:
# macOS is where it breaks first: `sockaddr_un.sun_path` is 104 bytes there
# against 108 on Linux, and Electron's userData prefix is far longer, so
# platform-specific failures in the control transport only surface here.
# `fail-fast: false` so a macOS failure still reports the Linux result.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

Expand All @@ -40,3 +52,15 @@ jobs:

- name: Verify (typecheck + lint + test)
run: pnpm verify

# Required by the `protect main` ruleset. `always()` so it still reports when
# a platform leg fails or is cancelled, and fails in that case rather than
# masking it — the required check has to reflect the whole matrix.
verify:
if: always()
needs: platform
runs-on: ubuntu-latest
steps:
- name: Confirm every platform leg passed
if: needs.platform.result != 'success'
run: exit 1
74 changes: 71 additions & 3 deletions apps/desktop/src/main/agent-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,89 @@
// @vitest-environment node
import { execFile, execFileSync } from 'node:child_process'
import { promisify } from 'node:util'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { delegationSettingsSchema } from '@ari/contracts/agent-control'
import type { Session } from '@ari/contracts/session'
import { SessionStore } from '@ari/engine/session-store'
import { DriverRegistry } from '@ari/providers/registry'
import type { AdapterSession } from '@ari/providers/driver'
import { Engine } from './engine'
import { startAgentRuntime } from './agent-runtime'
import { controlEndpoint, SOCKET_ROOT, startAgentRuntime } from './agent-runtime'

const execute = promisify(execFile)
const cliPath = resolve('resources/cli/ari.cjs')

/** `sockaddr_un.sun_path` is char[104] on macOS, NUL terminator included. */
const MACOS_SUN_PATH_BYTES = 104

describe('control endpoint', () => {
// The reporter's path: Electron userData plus the runtime directory.
const macUserData = '/Users/jdholst/Library/Application Support/@ari/desktop/agent-control'

it('overflows sun_path when bound under macOS userData', () => {
expect(Buffer.byteLength(join(macUserData, `${randomUUID()}.sock`))).toBeGreaterThanOrEqual(
MACOS_SUN_PATH_BYTES,
)
})

it('stays within it when bound in a short root', async () => {
const { endpoint } = await controlEndpoint('darwin', tmpdir())
expect(Buffer.byteLength(endpoint)).toBeLessThan(MACOS_SUN_PATH_BYTES)
})

// `controlEndpoint` is exercised above with a caller-supplied root, so this
// pins the root production actually passes. Pointing it back at userData is
// the exact regression that produced the reporter's EINVAL.
it('binds under a shipped root short enough for the macOS budget', () => {
const endpoint = join(SOCKET_ROOT, 'ari-XXXXXX', `${randomUUID()}.sock`)
expect(Buffer.byteLength(endpoint)).toBeLessThan(MACOS_SUN_PATH_BYTES)
})
})

/** Socket directories the runtime owns under the shipped root. */
async function socketDirs(): Promise<string[]> {
return (await readdir(SOCKET_ROOT)).filter((name) => name.startsWith('ari-')).sort()
}

// `close` only exists on the object a successful start returns, so a startup
// that rejected after creating the directory had nothing left to remove it —
// one directory per attempt, for a failure that repeats on every launch.
it('removes the socket directory when startup fails after creating it', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ari-startup-fail-'))
const bin = join(dir, 'agent-control', 'bin')
await mkdir(bin, { recursive: true })
// A directory where the launcher file belongs, so the write — which runs
// after the socket directory exists — is the step that rejects.
await mkdir(join(bin, process.platform === 'win32' ? 'ari.cmd' : 'ari'))
const store = new SessionStore({ rootDir: join(dir, 'sessions') })
const engine = new Engine({
store,
registry: new DriverRegistry(),
publish: () => undefined,
resolveWorkspace: async () => dir,
git: { captureCheckpoint: async () => ({ ok: true, value: null }) },
})
const before = await socketDirs()
await expect(
startAgentRuntime({
engine,
store,
userData: dir,
cliPath,
executable: process.execPath,
version: 'test',
policy: () => delegationSettingsSchema.parse({ approvalMode: 'never' }),
providers: async () => [],
}),
).rejects.toThrow()
expect(await socketDirs()).toEqual(before)
await rm(dir, { recursive: true, force: true })
})

it('runs the shipped CLI through scoped transport, a real isolated worker and exact integration', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ari-e2e-'))
const repo = join(dir, 'repo space')
Expand Down
66 changes: 60 additions & 6 deletions apps/desktop/src/main/agent-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, writeFile, chmod } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile, chmod } from 'node:fs/promises'
import { join, delimiter } from 'node:path'
import { randomUUID } from 'node:crypto'
import { ControlFailure, type DelegationSettings } from '@ari/contracts/agent-control'
Expand Down Expand Up @@ -32,16 +32,61 @@ export interface AgentRuntimeOptions {
baseEnv?: NodeJS.ProcessEnv
}

/**
* Unix socket paths live in the kernel's fixed-size `sockaddr_un.sun_path`:
* 104 bytes on macOS, 108 on Linux, NUL terminator included. libuv fails with
* EINVAL rather than truncating a path that does not fit, and `listen()`
* surfaces that as `listen EINVAL: invalid argument` — which rejects the whole
* runtime, so every dispatch fails and the user cannot send anything.
*
* macOS userData cannot fit one. `/Users/<name>/Library/Application Support/`
* plus `@ari/desktop/agent-control/` is 70 bytes on a 7-character user name —
* and 104 on no user name at all — before the 36-byte UUID and its `.sock`
* suffix are added. POSIX sockets therefore bind in a short private directory
* under /tmp instead, leaving ~60 bytes total. Only the CLI consumes the
* endpoint, and it reads the path from ARI_CONTROL_ENDPOINT. Windows uses a
* named pipe and has no such limit.
*/
export const SOCKET_ROOT = '/tmp'

/**
* Creates this runtime's private socket directory and returns the endpoint the
* control server binds, along with the directory to remove on close (`null` on
* Windows, where the endpoint is a named pipe and there is nothing to clean).
*/
export async function controlEndpoint(
platform: NodeJS.Platform,
socketRoot: string,
): Promise<{ endpoint: string; dir: string | null }> {
if (platform === 'win32') return { endpoint: `\\\\.\\pipe\\ari-${randomUUID()}`, dir: null }
const dir = await mkdtemp(join(socketRoot, 'ari-'))
return { endpoint: join(dir, `${randomUUID()}.sock`), dir }
}

/** Hosts the transport and injects private CLI launchers without changing global PATH. */
export async function startAgentRuntime(options: AgentRuntimeOptions) {
const { endpoint, dir: socketDir } = await controlEndpoint(process.platform, SOCKET_ROOT)
try {
return await startControlTransport(options, endpoint, socketDir)
} catch (error) {
// `close` only exists on the object a successful start returns, so a
// rejection after this point leaves nothing able to remove the directory —
// and because the same failure repeats on every launch, they accumulate.
if (socketDir) await rm(socketDir, { recursive: true, force: true })
Comment thread
j35dev marked this conversation as resolved.
throw error
}
}

/** Builds what the endpoint serves; `close` takes ownership of `socketDir` once this resolves. */
async function startControlTransport(
options: AgentRuntimeOptions,
endpoint: string,
socketDir: string | null,
) {
const { engine, store } = options
const runtimeDir = join(options.userData, 'agent-control')
const bin = join(runtimeDir, 'bin')
await mkdir(bin, { recursive: true, mode: 0o700 })
const endpoint =
process.platform === 'win32'
? `\\\\.\\pipe\\ari-${randomUUID()}`
: join(runtimeDir, `${randomUUID()}.sock`)
const launcher = join(bin, process.platform === 'win32' ? 'ari.cmd' : 'ari')
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
const script =
Expand Down Expand Up @@ -146,7 +191,15 @@ export async function startAgentRuntime(options: AgentRuntimeOptions) {
approvals.cancel(id)
server.revoke(id)
}
await server.listen()
try {
await server.listen()
} catch (error) {
// `listen` binds before it applies the socket mode, so a rejection there
// leaves a listening handle that this function never returns — nothing
// else can close it. The caller still removes the directory.
await server.close().catch(() => undefined)
throw error
}
const unsubscribe = store.subscribe((event) => {
if (event.type === 'turn.settled') approvals.cancel(event.sessionId)
})
Expand Down Expand Up @@ -180,6 +233,7 @@ export async function startAgentRuntime(options: AgentRuntimeOptions) {
unsubscribe()
approvals.close()
await server.close()
if (socketDir) await rm(socketDir, { recursive: true, force: true })
},
environment: (session: Session): NodeJS.ProcessEnv => {
const env = { ...(options.baseEnv ?? process.env) }
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/main/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,57 @@ describe('engine end-to-end with scripted driver', () => {
expect(model.status).toBe('error')
}, 10000)

it('shows a notice in the transcript without failing the turn', async () => {
// A notice is the "this worked, but not the way you asked" channel — a
// refused model pick. Settling the turn as `error` here would report a
// failure that did not happen and play the error sound over a good reply.
function noticingDriver(): Driver {
function makeAdapter(): ProviderAdapter {
async function* start(): AsyncGenerator<AgentEvent> {
yield {
type: 'notice',
message: '"gpt-9" is not offered by this agent, so this turn ran on the default.',
}
yield { type: 'text-delta', text: 'hello' }
yield { type: 'done' }
}
return {
start: () => ({ [Symbol.asyncIterator]: () => start()[Symbol.asyncIterator]() }),
interrupt: () => undefined,
dispose: () => Promise.resolve(),
}
}
return { kind: 'claude', create: () => Promise.resolve(makeAdapter()) }
}

const registry = new DriverRegistry()
registry.register(noticingDriver())
const engine = new Engine({
store,
registry,
publish: (sessionId, event) => published.push({ sessionId, event }),
git: { captureCheckpoint: async () => ({ ok: true, value: null }) },
})
const sessionId = 'sess_notice'
await seedSession(store, sessionId)
await engine.dispatch({ type: 'turn.start', sessionId, text: 'hi' } as Command)
for (let i = 0; i < 150; i++) {
if (published.some((p) => p.sessionId === sessionId && p.event.type === 'turn.settled')) break
if (i === 149) throw new Error('turn never settled')
await new Promise((r) => setTimeout(r, 20))
}

const model = await store.load(sessionId)
expect(model.status).toBe('idle')
const text = (model.messages ?? [])
.flatMap((m) => m.parts)
.filter((p) => p.type === 'text')
.map((p) => (p as { text: string }).text)
.join('')
expect(text).toContain('gpt-9')
expect(text).toContain('hello')
}, 10000)

it('passes the observed provider ref as resumeOf on the next turn only', async () => {
const created: AdapterSession[] = []
function resumingDriver(): Driver {
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,16 @@ export class Engine {
// it instead of re-prompting cold.
await append({ type: 'session.ref.observed', ref: event.ref })
break
case 'notice':
// Shown, but deliberately not recorded as `firstErrorMessage`:
// the turn is answering, just not with what the user picked.
await flush()
await append({
type: 'assistant.parts.appended',
messageId,
parts: [{ type: 'text', text: `\n\n⚠ ${event.message}` }],
})
break
case 'error':
if (firstErrorMessage === null) firstErrorMessage = event.message
await append({
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/main/music-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { MusicEngine, classifyHelperError, errorCodeOf, videoIdFromUrl } from './music-engine'
import { MusicRuntime } from './music-runtime'
import { MusicRuntime, musicRuntimeTargetKey } from './music-runtime'

const TRACK_URL = 'https://www.youtube.com/watch?v=abc123'

Expand Down Expand Up @@ -185,7 +185,14 @@ describe('MusicEngine streaming', () => {

describe('MusicEngine runtime self-heal', () => {
const REMOTE_URL = 'https://example.invalid/music-runtime.json'
const KEY = process.platform === 'win32' ? 'win32-x64' : 'linux-x64'
// Must be the key MusicRuntime actually looks up, which is `${platform}-${arch}`
// including darwin and arm64. A win32/not-win32 branch seeded a linux key on
// macOS runners, so the runtime found no binary and every test here returned
// RUNTIME_MISSING instead of the failure it meant to exercise.
const unsupported = (): never => {
throw new Error(`unsupported test platform: ${process.platform}-${process.arch}`)
}
const KEY = musicRuntimeTargetKey() ?? unsupported()
const BINARY = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
const V1 = Buffer.from('v1-bytes')
const V1_SHA = createHash('sha256').update(V1).digest('hex')
Expand Down
Loading
Loading