mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(macos): preserve terminal privacy identity across app updates
* test(macos): make launchd fixtures portable across host platforms
* fix(macos): probe protected contents and close handles on denial
* fix(i18n): register terminal permission status labels
* fix(macos): fork the daemon when no stable-bundle job can claim the endpoint
A copy, signature, plist or deadline failure before bootstrap, a bootstrap
launchd refused with the job absent, a job whose daemon exited before
answering, and a foreign endpoint all threw out of the launcher. The app then
lost its persistent daemon and fell back to local PTYs, where main forks one.
These now fall through to the existing fork path, which also adopts a foreign
occupant normally instead of as a degraded endpoint. A timed-out bootstrap or
a job still running/unreadable stays fatal, so no second daemon races it.
* feat(telemetry): split daemon origin events by stable-copy launch method
daemon_adopted, daemon_pty_cwd_denied and daemon_pty_cwd_readable gain a
closed-enum launch_method (stable-copy / app-fork / unknown), read from the
spawner path the daemon recorded, so the update fix is measurable per daemon
even when an old forked daemon is adopted across updates. The reliability
gate is a test manifest with no runtime switch, so there is no gate state to
report; spawner_path_class reports the private copy as 'other' alongside
other non-/Applications installs.
* refactor(telemetry): report the stable copy as a spawner path class
The separate launch_method property duplicated what the recorded spawner
path already says. spawner_path_class gains `stable-copy`, classified from
the same path under the private runtime root, and launch_method, its enum
and classifier are removed from all three .strict() daemon origin events.
A deleted copy still reports `missing`.
* refactor(settings): gate terminal-host Full Disk Access on macOS once
The permissions handler already asks for the terminal-host status only on
macOS, so the status function's own platform check and the pane's
'unsupported' branch could never fire. The off-macOS guarantee is now
tested at the handler.
* fix(macos): keep the fork fallback inside the startup PTY gate
The stable launch could run until 55 s after the launch started, so a late
"unavailable" verdict left the fork fallback's own 10 s readiness wait to
finish after the 60 s gate had already moved the app to local PTYs.
The launcher now gives the stable attempt one handoff deadline: the gate
minus the fork's readiness timeout and the 5 s lease/adapter allowance
(45 s). Copy, signature check and plist conversion share one abort signal
that ends early enough for bootstrap and the job's own readiness wait to
meet that deadline; every command otherwise keeps a fixed timeout. An
unavailable verdict past the deadline is reported instead of forking a
daemon that cannot arrive in time. The per-step budget module is gone (its
deletion landed in the previous commit by a staging slip).
The launch body is now two blocks: preparation (plist, plutil, deadline
check) fails as unavailable after removing the copy and job file, and
bootstrap runs in its own try/finally that always deletes the job file.
Retirement is simpler and claims more orphans: one in-flight collection per
process instead of a per-root map and rotating 20-record scan, and the job
record (written before copying) no longer carries a bundle name because
lsof +D scans the whole runtime directory (measured equal cost, same
result on a live copy). A never-submitted copy is retired once its producer
has exited and its job is missing, so a bootstrap timeout or a crash
mid-copy no longer leaves a copy forever; the open-file check still guards
deletion. Collection now starts from the launcher after the attempt.
* docs(macos): ship the daemon launch rules, not the investigation
The signed investigation harness left the tree in the previous telemetry
commit by a staging slip; its results and pinned link stay in the PR. This
trims the reference doc to why the copy and launchd exist, the fallback
states and the cleanup rule, trims the reliability gate to what its
validator needs, and points AGENTS.md at the reference doc next to the
Windows relocation entry.
* fix(macos): hand off to the fork as soon as the stable job exits
A copied daemon refused at exec (for example by Gatekeeper) never answers,
so every fresh start paid the full 10 s readiness wait before the fork
fallback. The readiness loop now reads the job state every 500 ms and
abandons as soon as the job is stopped or missing; a running or
unreadable job is still waited out and never forked beside.
Also drops the dead forkEntryPath the stable launch never read (its
options type now omits it) and un-exports the handoff constant.
* fix(macos): keep daemon copies out of Spotlight and unregister retired ones
Each runtime copy is a full Orca.app, so Spotlight indexed it as an app
and LaunchServices kept a record for it that outlived the copy. A
registered copy claims orca: links and Markdown/CSV files, and becomes
their default handler whenever /Applications/Orca.app is not registered.
New copies go under runtime-*/app.noindex/, which Spotlight skips and which
stops the record LaunchServices made while the bundle was being copied.
Retirement now runs lsregister -u on every bundle in the copy (both the
app.noindex layout and the earlier runtime-*/Orca.app layout) once lsof
shows it unused, then deletes it. A running copy stays registered,
because Local Network access may be resolved through that record.
Collection and stable-copy telemetry match the runtime-* directory, so
copies made before this change are still adopted, classified and retired.
* docs(macos): record the LaunchServices trade-off and the rollback plan
The reference doc now explains why copies sit in a .noindex folder, why
only retired copies are unregistered, and how to turn the copy launch off:
make launchMacDaemonFromStableBundle return null, and keep the retirement
code until no copy-launched daemon can remain, because the old code never
deletes copies.
A test pins that rollback: with the stable launcher returning null, the
launcher forks and still runs the copy collection.
* fix(macos): keep daemon copies registered so Local Network can resolve them
The `.noindex` folder kept copies out of Spotlight and LaunchServices, but
Local Network access is resolved from the signing identifier through
LaunchServices to the executable's Mach-O UUID. After an update that changes
Electron only the copy carries the running daemon's UUID, so an unregistered
copy risks the same Local Network loss main has today. Copies go back to
`runtime-*/Orca.app`; retirement still unregisters each copy before deleting
it, so deleted copies leave no LaunchServices entry. The remaining trade-off:
while `/Applications/Orca.app` is missing, a running registered copy can be
picked for `orca:` links and documents.
* fix(macos): hide daemon copies from Spotlight and tie the job to Orca
Spotlight listed each runtime copy as a second Orca app. New copies go
back under runtime-*/app.noindex/Orca.app, which Spotlight skips.
Retirement and stable-copy telemetry match the runtime-* directory, so
copies in the earlier runtime-*/Orca.app layout still retire and classify
the same way.
Local Network access identifies the daemon by its code signature and
main-executable UUID, not by a LaunchServices record, so an unregistered
copy keeps it. The launchd job now sets AssociatedBundleIdentifiers to
com.stablyai.orca, which is how a job not installed through SMAppService
names the app it belongs to. Copies are never registered explicitly;
retirement still runs lsregister -u before deleting each one.
* feat(macos): package a signed terminal host helper that runs the daemon on the pinned Node
Contents/Helpers/Orca Terminal Host.app carries the pinned Node, the daemon's
out/main JS and this slice's node-pty, signed inside-out as com.stablyai.orca.
afterPack boots it through one PTY round trip, then fails the build on layout,
Info.plist, macOS floor (NODE_RUNTIME_DARWIN_MINIMUM_OS vs Node's minos),
architecture or signature drift.
* ci(macos): cache and prefetch the pinned Node before packaging
Packaging now fetches the darwin Node from nodejs.org, whose download has no
retry. Each mac workflow caches it with the electron-builder downloads, keyed on
the pin, and warms both darwin targets inside the existing retry wrapper.
* feat(macos): launch the daemon from a copy of the terminal host helper, not the whole app
The stable launch now clones Contents/Helpers/Orca Terminal Host.app (~124 MiB
instead of ~628 MiB) and runs its Node on its daemon entry, without
ELECTRON_RUN_AS_NODE. The copy must still match the running app's designated
requirement. A missing helper falls back to the fork, and macOS below the
helper's Node floor forks without copying.
* docs(macos): describe the terminal host helper, its floor and its identity
* fix(macos): bound the terminal host boot check by its deadline and always clean up
A daemon that stayed silent hung packaging past the 20 s deadline; one that closed the
connection left the hello unsettled so Node exited 0 without cleanup; a malformed line threw
uncaught. Every connect, hello and PTY wait now races the deadline, close/error/parse failures
reject, and the daemon and scratch dir are removed on every path.
* ci(macos): cache the pinned Node on its own key with no restore-keys fallback
Sharing the electron-builder cache let old Node archives accumulate through restore-keys.
Also moves release-mac-build's Electron cache comment back onto its step.
* fix(macos): reject a universal slice for the terminal host and pin its name in one test
packageMacTerminalHost turned an unknown arch into darwin-undefined; it now throws through the
shared mac arch mapping. A test asserts the packager and the desktop agree on the helper bundle
and executable names, replacing a Linux/Windows test that could not fail.
* fix(macos): pass the launchd adoption lease its options object after the main merge
* test(macos): accept a reset as the daemon closing the boot-check connection; keep only passing gate commands
* fix(daemon): bump the terminal daemon protocol to v45 so new macOS tabs start in the terminal host helper
* fix(macos): ship the daemon's required packages in the terminal host and boot it outside the build tree
* style(macos): use a template literal in the terminal host resolution check
* fix(macos): derive the terminal host's packages from the daemon's own module graph
* fix(macos): treat any non-literal daemon load as computed, follow directory loads, and keep nested dependencies
* refactor(macos): move terminal-host telemetry, Settings status and Helper folder strings to a follow-up
Keeps this change to the update-safe terminal host itself. The follow-up restores:
- the `stable-copy` spawner path class in daemon adoption telemetry;
- the per-terminal-host Full Disk Access row in Settings and its daemon RPC;
- writing folder usage descriptions into the app's Electron Helpers for the fork fallback.
The helper still takes its Desktop, Documents and Downloads descriptions from
`macos-folder-usage-descriptions.cjs`, so that constant stays.
* feat(macos): report terminal-host Full Disk Access, classify stable-copy daemons, and give Helpers folder strings
Restores what a32018836b moved out of the terminal host change:
- daemon adoption telemetry classifies a daemon spawned from the private copy as `stable-copy`;
- Settings shows the terminal hosts' own Full Disk Access verdict beside the app's, through a
new `fullDiskAccessStatus` daemon request that older daemons answer as unknown;
- packaging writes the Desktop, Documents and Downloads descriptions into the app's Helper
bundles, which still host terminals when the fork fallback runs.
* fix(i18n): restore en.json to this branch's base, not newer main
The previous commit took the file from a newer main, which pulled unrelated strings into this PR.
* refactor(macos): publish one terminal-host copy per helper build under a fixed launchd label
Name the copy after the helper's CDHash, reuse it when present, and
publish a new one by cloning into a staging dir, verifying it once
against the app's designated requirement and renaming it into place.
One launchd label per profile and protocol replaces per-launch labels,
so job.json, producer pids and per-label collection go away. Retirement
deletes any other copy lsof shows unused. The stable launcher returns
null for every fork fallback and logs its reason.
* fix(packaging): copy terminal-host dependencies from where Node resolves them
A nested package's own dependencies were never walked, so a hoisted
dependency of a nested package was left out of the helper. Resolve each
dependency through the node_modules lookup Node performs from its parent
and copy it to the same relative place, unless a copied package already
carries it.
* fix(packaging): fail release and CI mac builds that cannot boot a terminal-host slice
An arm64 runner without Rosetta skipped the x64 helper's PTY boot check
and shipped the slice untested. Release channels and CI now fail there;
local cross-arch builds still skip with a log line.
* docs(macos): describe the per-build terminal-host copy and its fallback reasons
* docs(macos): keep the codesign command on one line
* test(macos): type the launchd test state without an assertion
* docs(macos): name what the login-session flag gates in the stable launcher
* fix(macos): wait for launchd to drop a booted-out terminal job before reusing its label
* refactor(daemon): keep the health request types in their own module
types.ts hit the line limit once the Full Disk Access status request joined the union.
---------
Co-authored-by: Neil <neil@stably.ai>
926 lines
31 KiB
TypeScript
926 lines
31 KiB
TypeScript
import './mock-descendant-sweep'
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { connect, type Server, type Socket } from 'node:net'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
|
||
import { DaemonServer } from './daemon-server'
|
||
import type { ConnectedDaemonClient } from './daemon-client-connections'
|
||
import { DaemonClient } from './client'
|
||
import { encodeNdjson } from './ndjson'
|
||
import { PROTOCOL_VERSION, type DaemonRequest } from './types'
|
||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||
import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
|
||
import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness'
|
||
|
||
const confirmForegroundProcessMock = vi.fn(async () => 'droid')
|
||
|
||
function createTestDir(): string {
|
||
return mkdtempSync(join(tmpdir(), 'daemon-server-test-'))
|
||
}
|
||
|
||
function createMockSubprocess(): SubprocessHandle & {
|
||
_simulateData: (data: string) => void
|
||
_simulateExit: (code: number) => void
|
||
} {
|
||
let onDataCb: ((data: string) => void) | null = null
|
||
let onExitCb: ((code: number) => void) | null = null
|
||
return {
|
||
pid: 55555,
|
||
getForegroundProcess: vi.fn(() => null),
|
||
confirmForegroundProcess: confirmForegroundProcessMock,
|
||
write: vi.fn(),
|
||
resize: vi.fn(),
|
||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||
terminateOwnedTree: () => 'unavailable' as const,
|
||
forceKill: vi.fn(() => onExitCb?.(137)),
|
||
signal: vi.fn(),
|
||
onData(cb) {
|
||
onDataCb = cb
|
||
},
|
||
onExit(cb) {
|
||
onExitCb = cb
|
||
},
|
||
dispose: vi.fn(),
|
||
_simulateData(data: string) {
|
||
onDataCb?.(data)
|
||
},
|
||
_simulateExit(code: number) {
|
||
onExitCb?.(code)
|
||
}
|
||
}
|
||
}
|
||
|
||
type DaemonServerPrivate = {
|
||
lifecycle: { server: Server | null }
|
||
preparations: { pending: Map<string, Set<unknown>> }
|
||
host: {
|
||
kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void>
|
||
dispose: () => Promise<void>
|
||
}
|
||
connections: { clients: Map<string, ConnectedDaemonClient> }
|
||
requestRouter: {
|
||
route(clientId: string, request: DaemonRequest): Promise<unknown>
|
||
}
|
||
}
|
||
|
||
describe('DaemonServer', () => {
|
||
let dir: string
|
||
let socketPath: string
|
||
let tokenPath: string
|
||
let pidPath: string
|
||
let server: DaemonServer
|
||
let client: DaemonClient
|
||
|
||
beforeEach(() => {
|
||
confirmForegroundProcessMock.mockClear()
|
||
dir = createTestDir()
|
||
socketPath = getDaemonSocketPath(dir)
|
||
tokenPath = join(dir, 'test.token')
|
||
pidPath = getDaemonPidPath(dir)
|
||
})
|
||
|
||
afterEach(async () => {
|
||
client?.disconnect()
|
||
await server?.shutdown()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
})
|
||
|
||
async function startServer(launchNonce?: string, onRpcShutdown?: () => void): Promise<void> {
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
...(launchNonce ? { pidPath, launchNonce } : {}),
|
||
...(onRpcShutdown ? { onRpcShutdown } : {}),
|
||
spawnSubprocess: () => createMockSubprocess()
|
||
})
|
||
await server.start()
|
||
}
|
||
|
||
async function connectClient(): Promise<DaemonClient> {
|
||
client = new DaemonClient({ socketPath, tokenPath })
|
||
await client.ensureConnected()
|
||
return client
|
||
}
|
||
|
||
async function connectRawHello(role: 'control' | 'stream', clientId: string): Promise<Socket> {
|
||
const socket = connect(socketPath)
|
||
await new Promise<void>((resolve) => socket.once('connect', resolve))
|
||
socket.write(
|
||
encodeNdjson({
|
||
type: 'hello',
|
||
version: PROTOCOL_VERSION,
|
||
token: readFileSync(tokenPath, 'utf-8').trim(),
|
||
clientId,
|
||
role
|
||
})
|
||
)
|
||
await new Promise<void>((resolve, reject) => {
|
||
const cleanup = (): void => {
|
||
socket.off('data', onData)
|
||
socket.off('error', onError)
|
||
}
|
||
const onData = (data: Buffer): void => {
|
||
cleanup()
|
||
const parsed = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string }
|
||
if (parsed.ok) {
|
||
resolve()
|
||
return
|
||
}
|
||
reject(new Error(parsed.error ?? 'hello rejected'))
|
||
}
|
||
const onError = (error: Error): void => {
|
||
cleanup()
|
||
reject(error)
|
||
}
|
||
socket.on('data', onData)
|
||
socket.on('error', onError)
|
||
})
|
||
return socket
|
||
}
|
||
|
||
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||
const startedAt = Date.now()
|
||
while (!predicate() && Date.now() - startedAt < timeoutMs) {
|
||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||
}
|
||
expect(predicate()).toBe(true)
|
||
}
|
||
|
||
describe('startup', () => {
|
||
it('creates token file and starts listening', async () => {
|
||
await startServer()
|
||
|
||
const token = readFileSync(tokenPath, 'utf-8')
|
||
expect(token.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('accepts client connections', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
expect(c.isConnected()).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('RPC routing', () => {
|
||
it('handles createOrAttach and returns result', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
const result = await c.request('createOrAttach', {
|
||
sessionId: 'test-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
|
||
expect(result).toMatchObject({
|
||
isNew: true,
|
||
pid: 55555
|
||
})
|
||
await expect(
|
||
c.request('closeStartupQueryAuthority', { sessionId: 'test-session' })
|
||
).resolves.toEqual({ appliedSeq: 0 })
|
||
})
|
||
|
||
it('keeps RPC responsive and creates one subprocess while spawn preparation is pending', async () => {
|
||
let finishPreparation!: () => void
|
||
const preparation = new Promise<void>((resolve) => {
|
||
finishPreparation = resolve
|
||
})
|
||
const preparePtySpawn = vi.fn(() => preparation)
|
||
const spawnSubprocess = vi.fn(() => createMockSubprocess())
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
preparePtySpawn,
|
||
spawnSubprocess
|
||
})
|
||
await server.start()
|
||
const c = await connectClient()
|
||
|
||
const firstCreate = c.request<{ isNew: boolean }>('createOrAttach', {
|
||
sessionId: 'prepared-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
const concurrentCreate = c.request<{ isNew: boolean }>('createOrAttach', {
|
||
sessionId: 'prepared-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
await vi.waitFor(() => expect(preparePtySpawn).toHaveBeenCalledTimes(2))
|
||
|
||
// The old spawnSync probe blocked this ping and all live PTY traffic.
|
||
await expect(c.request('ping', undefined)).resolves.toEqual({ pong: true })
|
||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||
|
||
finishPreparation()
|
||
const results = await Promise.all([firstCreate, concurrentCreate])
|
||
expect(results.map((result) => result.isNew).sort()).toEqual([false, true])
|
||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it.each(['cancelCreateOrAttach', 'kill'] as const)(
|
||
'prevents a pending subprocess after %s and permits later session reuse',
|
||
async (requestType) => {
|
||
let finishPreparation!: () => void
|
||
const preparation = new Promise<void>((resolve) => {
|
||
finishPreparation = resolve
|
||
})
|
||
const preparePtySpawn = vi.fn(() => preparation)
|
||
const spawnSubprocess = vi.fn(() => createMockSubprocess())
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
preparePtySpawn,
|
||
spawnSubprocess
|
||
})
|
||
await server.start()
|
||
const c = await connectClient()
|
||
|
||
const creates = [
|
||
c.request('createOrAttach', {
|
||
sessionId: 'canceled-preparation',
|
||
cols: 80,
|
||
rows: 24
|
||
}),
|
||
c.request('createOrAttach', {
|
||
sessionId: 'canceled-preparation',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
]
|
||
const canceledCreates = Promise.all(
|
||
creates.map((create) =>
|
||
expect(create).rejects.toThrow('Attach canceled for session canceled-preparation')
|
||
)
|
||
)
|
||
await vi.waitFor(() => expect(preparePtySpawn).toHaveBeenCalledTimes(2))
|
||
|
||
const cancelRequest =
|
||
requestType === 'kill'
|
||
? c.request('kill', { sessionId: 'canceled-preparation', immediate: true })
|
||
: c.request('cancelCreateOrAttach', { sessionId: 'canceled-preparation' })
|
||
await expect(cancelRequest).resolves.toEqual(
|
||
requestType === 'kill' ? {} : { canceled: true }
|
||
)
|
||
finishPreparation()
|
||
await canceledCreates
|
||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||
|
||
await expect(
|
||
c.request('createOrAttach', {
|
||
sessionId: 'canceled-preparation',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
).resolves.toMatchObject({ isNew: true })
|
||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||
}
|
||
)
|
||
|
||
it('cancels pending subprocess preparation during daemon shutdown', async () => {
|
||
let finishPreparation!: () => void
|
||
const preparation = new Promise<void>((resolve) => {
|
||
finishPreparation = resolve
|
||
})
|
||
const preparePtySpawn = vi.fn(() => preparation)
|
||
const spawnSubprocess = vi.fn(() => createMockSubprocess())
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
preparePtySpawn,
|
||
spawnSubprocess
|
||
})
|
||
await server.start()
|
||
const c = await connectClient()
|
||
|
||
const create = c.request('createOrAttach', {
|
||
sessionId: 'shutdown-pending',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
const canceledCreate = expect(create).rejects.toThrow(
|
||
'Attach canceled for session shutdown-pending'
|
||
)
|
||
await vi.waitFor(() => expect(preparePtySpawn).toHaveBeenCalledOnce())
|
||
|
||
const shutdown = server.shutdown()
|
||
finishPreparation()
|
||
await canceledCreate
|
||
await shutdown
|
||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('cancels a disconnecting client’s pending preparation to avoid an orphan PTY (F4)', async () => {
|
||
let finishPreparation!: () => void
|
||
const preparation = new Promise<void>((resolve) => {
|
||
finishPreparation = resolve
|
||
})
|
||
const preparePtySpawn = vi.fn(() => preparation)
|
||
const spawnSubprocess = vi.fn(() => createMockSubprocess())
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
preparePtySpawn,
|
||
spawnSubprocess
|
||
})
|
||
await server.start()
|
||
const c = await connectClient()
|
||
|
||
// Hangs in preflight; the control-socket close must abort it before spawn.
|
||
c.request('createOrAttach', {
|
||
sessionId: 'disconnect-pending',
|
||
cols: 80,
|
||
rows: 24
|
||
}).catch(() => {
|
||
/* the disconnect rejects the in-flight request; that's expected */
|
||
})
|
||
await vi.waitFor(() => expect(preparePtySpawn).toHaveBeenCalledOnce())
|
||
|
||
c.disconnect()
|
||
// Wait for the server to process the close (and cancel the prep) before
|
||
// releasing the preflight, else the resumed spawn races ahead of cancellation.
|
||
await vi.waitFor(() =>
|
||
expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0)
|
||
)
|
||
finishPreparation()
|
||
await vi.waitFor(() =>
|
||
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
|
||
)
|
||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('kill with no pending preparation still surfaces SessionNotFoundError (F7)', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
// No preparation was canceled, so the host's not-found verdict must propagate
|
||
// rather than be swallowed by the pending-spawn kill reconciliation.
|
||
await expect(
|
||
c.request('kill', { sessionId: 'never-created', immediate: true })
|
||
).rejects.toThrow('Session not found: never-created')
|
||
})
|
||
|
||
it('persists only an allowlisted launch identity across reattach', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
const first = await c.request('createOrAttach', {
|
||
sessionId: 'agent-session',
|
||
cols: 80,
|
||
rows: 24,
|
||
launchAgent: 'droid'
|
||
})
|
||
expect(first).toMatchObject({ isNew: true, launchAgent: 'droid' })
|
||
|
||
const second = await c.request('createOrAttach', {
|
||
sessionId: 'agent-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
expect(second).toMatchObject({ isNew: false, launchAgent: 'droid' })
|
||
|
||
const unknown = await c.request('createOrAttach', {
|
||
sessionId: 'unknown-agent-session',
|
||
cols: 80,
|
||
rows: 24,
|
||
launchAgent: 'not-an-agent'
|
||
} as never)
|
||
expect(unknown).not.toHaveProperty('launchAgent')
|
||
})
|
||
|
||
it('handles listSessions', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
// Create a session first
|
||
await c.request('createOrAttach', {
|
||
sessionId: 'test-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
|
||
const result = await c.request<{ sessions: unknown[] }>('listSessions', undefined)
|
||
expect(result.sessions).toHaveLength(1)
|
||
})
|
||
|
||
it('handles ping health checks', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
const result = await c.request<{ pong: boolean }>('ping', undefined)
|
||
|
||
expect(result).toEqual({ pong: true })
|
||
})
|
||
|
||
it('replies with an error to unknown request types and keeps serving', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
// Why: downgraded clients can send request types this daemon does not
|
||
// know. Reject gracefully instead of crashing the session server.
|
||
await expect(c.request('definitelyUnknownRequest', undefined)).rejects.toThrow(
|
||
'Unknown request type: definitelyUnknownRequest'
|
||
)
|
||
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
|
||
pong: true
|
||
})
|
||
})
|
||
|
||
it('handles systemResolverHealth', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
const result = await c.request<{ health: unknown }>('systemResolverHealth', undefined)
|
||
|
||
expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health)
|
||
})
|
||
|
||
it('probes Full Disk Access in the owning daemon', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
const result = await c.request<{ status: unknown }>('fullDiskAccessStatus', undefined)
|
||
expect(['granted', 'denied', 'unknown', 'unsupported']).toContain(result.status)
|
||
})
|
||
|
||
it('handles resize', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
await c.request('createOrAttach', {
|
||
sessionId: 'test-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
|
||
const result = await c.request('resize', {
|
||
sessionId: 'test-session',
|
||
cols: 120,
|
||
rows: 40
|
||
})
|
||
|
||
expect(result).toBeDefined()
|
||
})
|
||
|
||
it('does not acknowledge kill until asynchronous teardown completes', async () => {
|
||
await startServer()
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
let finishKill!: () => void
|
||
const teardown = new Promise<void>((resolve) => {
|
||
finishKill = resolve
|
||
})
|
||
const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown)
|
||
|
||
let acknowledged = false
|
||
const routed = daemon.requestRouter
|
||
.route('client-1', {
|
||
id: 'kill-1',
|
||
type: 'kill',
|
||
payload: { sessionId: 'agent-session', immediate: true }
|
||
})
|
||
.then((result) => {
|
||
acknowledged = true
|
||
return result
|
||
})
|
||
|
||
await Promise.resolve()
|
||
expect(kill).toHaveBeenCalledWith('agent-session', { immediate: true })
|
||
expect(acknowledged).toBe(false)
|
||
|
||
finishKill()
|
||
await expect(routed).resolves.toEqual({})
|
||
expect(acknowledged).toBe(true)
|
||
})
|
||
|
||
it('handles getCwd', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
await c.request('createOrAttach', {
|
||
sessionId: 'test-session',
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
|
||
const result = await c.request<{ cwd: string | null }>('getCwd', {
|
||
sessionId: 'test-session'
|
||
})
|
||
|
||
// Mock subprocess doesn't emit OSC-7. The terminal-host fallback then
|
||
// calls resolveProcessCwd(55555); on CI that pid is almost always dead
|
||
// so the result is null, but we accept string too — a recycled pid that
|
||
// happens to match would legitimately return a path and we don't want
|
||
// this test to flake on whatever happens to be running on the host.
|
||
expect(result.cwd === null || typeof result.cwd === 'string').toBe(true)
|
||
})
|
||
|
||
it('awaits fresh foreground confirmation', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
await c.request('createOrAttach', { sessionId: 'test-session', cols: 80, rows: 24 })
|
||
|
||
await expect(
|
||
c.request<{ foregroundProcess: string | null }>('confirmForegroundProcess', {
|
||
sessionId: 'test-session'
|
||
})
|
||
).resolves.toEqual({ foregroundProcess: 'droid' })
|
||
expect(confirmForegroundProcessMock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('returns error for unknown session operations', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
await expect(c.request('write', { sessionId: 'nonexistent', data: 'hi' })).rejects.toThrow(
|
||
'Session not found'
|
||
)
|
||
})
|
||
|
||
it('emits exit when a fire-and-forget write targets a missing session', async () => {
|
||
await startServer()
|
||
const c = await connectClient()
|
||
|
||
const exitEvent = new Promise<unknown>((resolve) => {
|
||
c.onEvent((event) => resolve(event))
|
||
})
|
||
|
||
c.notify('write', { sessionId: 'missing-session', data: 'hi' })
|
||
|
||
await expect(exitEvent).resolves.toMatchObject({
|
||
type: 'event',
|
||
event: 'exit',
|
||
sessionId: 'missing-session',
|
||
payload: { code: -1 }
|
||
})
|
||
})
|
||
|
||
it('bypasses daemon stream batching for output after input', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
let subprocess: ReturnType<typeof createMockSubprocess>
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
spawnSubprocess: () => {
|
||
subprocess = createMockSubprocess()
|
||
return subprocess
|
||
}
|
||
})
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const controlSocket = { destroy: vi.fn() } as unknown as Socket
|
||
const streamSocket = {
|
||
destroyed: false,
|
||
destroy: vi.fn(),
|
||
write: vi.fn()
|
||
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
|
||
|
||
daemon.connections.clients.set('client-1', {
|
||
clientId: 'client-1',
|
||
controlSocket,
|
||
streamSocket,
|
||
authenticatedPairEstablished: true
|
||
})
|
||
|
||
await daemon.requestRouter.route('client-1', {
|
||
id: 'req-1',
|
||
type: 'createOrAttach',
|
||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||
})
|
||
|
||
subprocess!._simulateData('background')
|
||
expect(streamSocket.write).not.toHaveBeenCalled()
|
||
vi.advanceTimersByTime(8)
|
||
expect(streamSocket.write).toHaveBeenCalledTimes(1)
|
||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"')
|
||
|
||
streamSocket.write.mockClear()
|
||
await daemon.requestRouter.route('client-1', {
|
||
id: 'req-2',
|
||
type: 'write',
|
||
payload: { sessionId: 'test-session', data: 'x' }
|
||
})
|
||
|
||
expect(subprocess!.write).toHaveBeenCalledWith('x')
|
||
subprocess!._simulateData('echo')
|
||
expect(streamSocket.write).toHaveBeenCalledTimes(1)
|
||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"echo"')
|
||
vi.advanceTimersByTime(8)
|
||
expect(streamSocket.write).toHaveBeenCalledTimes(1)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('flushes pending batched stream output before the exit event', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
let subprocess: ReturnType<typeof createMockSubprocess>
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
spawnSubprocess: () => {
|
||
subprocess = createMockSubprocess()
|
||
return subprocess
|
||
}
|
||
})
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const controlSocket = { destroy: vi.fn() } as unknown as Socket
|
||
const streamSocket = {
|
||
destroyed: false,
|
||
destroy: vi.fn(),
|
||
write: vi.fn()
|
||
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
|
||
|
||
daemon.connections.clients.set('client-1', {
|
||
clientId: 'client-1',
|
||
controlSocket,
|
||
streamSocket,
|
||
authenticatedPairEstablished: true
|
||
})
|
||
|
||
await daemon.requestRouter.route('client-1', {
|
||
id: 'req-1',
|
||
type: 'createOrAttach',
|
||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||
})
|
||
|
||
subprocess!._simulateData('final-output')
|
||
subprocess!._simulateExit(42)
|
||
|
||
expect(streamSocket.write).toHaveBeenCalledTimes(2)
|
||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"event":"data"')
|
||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"final-output"')
|
||
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain('"event":"exit"')
|
||
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain('"code":42')
|
||
vi.advanceTimersByTime(8)
|
||
expect(streamSocket.write).toHaveBeenCalledTimes(2)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('keeps exit behind final output held by the shallow socket gate', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
let subprocess: ReturnType<typeof createMockSubprocess>
|
||
server = new DaemonServer({
|
||
socketPath,
|
||
tokenPath,
|
||
spawnSubprocess: () => {
|
||
subprocess = createMockSubprocess()
|
||
return subprocess
|
||
}
|
||
})
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const refillCallbacks: (() => void)[] = []
|
||
const controlSocket = { destroy: vi.fn() } as unknown as Socket
|
||
const streamSocket = {
|
||
destroyed: false,
|
||
destroy: vi.fn(),
|
||
writableLength: 128 * 1024,
|
||
write: vi.fn((_line: string, callback?: () => void) => {
|
||
if (callback) {
|
||
refillCallbacks.push(callback)
|
||
}
|
||
return true
|
||
})
|
||
} as unknown as Socket & {
|
||
write: ReturnType<typeof vi.fn>
|
||
writableLength: number
|
||
}
|
||
|
||
daemon.connections.clients.set('client-1', {
|
||
clientId: 'client-1',
|
||
controlSocket,
|
||
streamSocket,
|
||
authenticatedPairEstablished: true
|
||
})
|
||
await daemon.requestRouter.route('client-1', {
|
||
id: 'req-1',
|
||
type: 'createOrAttach',
|
||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||
})
|
||
|
||
const finalOutput = 'final-output'.repeat(1024)
|
||
subprocess!._simulateData(finalOutput)
|
||
subprocess!._simulateExit(42)
|
||
|
||
// Only the refill sentinel may enter the already-deep socket; exit
|
||
// remains queued behind the final data for this session.
|
||
expect(refillCallbacks).toHaveLength(1)
|
||
const beforeRefill = streamSocket.write.mock.calls.map(([line]) => JSON.parse(String(line)))
|
||
expect(beforeRefill).toHaveLength(1)
|
||
expect(beforeRefill[0]).toMatchObject({ event: 'data', payload: { data: '' } })
|
||
|
||
streamSocket.writableLength = 0
|
||
refillCallbacks[0]()
|
||
const delivered = streamSocket.write.mock.calls
|
||
.map(
|
||
([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } }
|
||
)
|
||
.filter((message) => message.payload.data !== '')
|
||
expect(delivered.map((message) => message.event)).toEqual(['data', 'exit'])
|
||
expect(delivered[0]?.payload.data).toBe(finalOutput)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('authentication', () => {
|
||
it('rejects connections with wrong token', async () => {
|
||
await startServer()
|
||
|
||
// Connect with raw socket and send bad token
|
||
const socket = connect(socketPath)
|
||
await new Promise<void>((resolve) => socket.on('connect', resolve))
|
||
|
||
socket.write(
|
||
encodeNdjson({
|
||
type: 'hello',
|
||
version: PROTOCOL_VERSION,
|
||
token: 'wrong-token',
|
||
clientId: 'bad-client',
|
||
role: 'control'
|
||
})
|
||
)
|
||
|
||
const response = await new Promise<string>((resolve) => {
|
||
socket.on('data', (data) => resolve(data.toString()))
|
||
})
|
||
|
||
const parsed = JSON.parse(response.trim())
|
||
expect(parsed.ok).toBe(false)
|
||
socket.destroy()
|
||
})
|
||
})
|
||
|
||
describe('stream socket lifecycle', () => {
|
||
it('clears the tracked stream socket when it closes', async () => {
|
||
await startServer()
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const control = await connectRawHello('control', 'raw-client')
|
||
const stream = await connectRawHello('stream', 'raw-client')
|
||
|
||
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
|
||
|
||
stream.destroy()
|
||
|
||
await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null)
|
||
control.destroy()
|
||
})
|
||
|
||
it('destroys a replaced stream socket for the same client', async () => {
|
||
await startServer()
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const control = await connectRawHello('control', 'raw-client')
|
||
const firstStream = await connectRawHello('stream', 'raw-client')
|
||
let firstClosed = false
|
||
firstStream.once('close', () => {
|
||
firstClosed = true
|
||
})
|
||
|
||
const secondStream = await connectRawHello('stream', 'raw-client')
|
||
|
||
await waitFor(() => firstClosed)
|
||
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
|
||
secondStream.destroy()
|
||
control.destroy()
|
||
})
|
||
|
||
it('destroys previous sockets when a control client id reconnects', async () => {
|
||
await startServer()
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const firstControl = await connectRawHello('control', 'raw-client')
|
||
const firstStream = await connectRawHello('stream', 'raw-client')
|
||
let firstControlClosed = false
|
||
let firstStreamClosed = false
|
||
firstControl.once('close', () => {
|
||
firstControlClosed = true
|
||
})
|
||
firstStream.once('close', () => {
|
||
firstStreamClosed = true
|
||
})
|
||
|
||
const secondControl = await connectRawHello('control', 'raw-client')
|
||
|
||
await waitFor(() => firstControlClosed && firstStreamClosed)
|
||
expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy()
|
||
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull()
|
||
secondControl.destroy()
|
||
})
|
||
|
||
it('destroys orphan stream sockets without a control client', async () => {
|
||
await startServer()
|
||
const daemon = server as unknown as DaemonServerPrivate
|
||
const stream = await connectRawHello('stream', 'missing-client')
|
||
let closed = false
|
||
stream.once('close', () => {
|
||
closed = true
|
||
})
|
||
|
||
await waitFor(() => closed || stream.destroyed)
|
||
expect(daemon.connections.clients.has('missing-client')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('shutdown', () => {
|
||
it('waits for the ordinary shutdown reply write before destroying resources', async () => {
|
||
const onRpcShutdown = vi.fn()
|
||
await startServer(undefined, onRpcShutdown)
|
||
const c = await connectClient()
|
||
const daemon = server as unknown as DaemonServerPrivate & {
|
||
host: { dispose: () => Promise<void> }
|
||
}
|
||
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
|
||
const originalWrite = controlSocket.write.bind(controlSocket)
|
||
let replyFlushed: (() => void) | undefined
|
||
vi.spyOn(controlSocket, 'write').mockImplementation(((
|
||
chunk: string | Uint8Array,
|
||
...args: unknown[]
|
||
) => {
|
||
replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined
|
||
return originalWrite(chunk)
|
||
}) as unknown as Socket['write'])
|
||
const dispose = vi.spyOn(daemon.host, 'dispose')
|
||
|
||
await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({})
|
||
expect(dispose).not.toHaveBeenCalled()
|
||
expect(onRpcShutdown).not.toHaveBeenCalled()
|
||
expect(existsSync(tokenPath)).toBe(true)
|
||
|
||
replyFlushed?.()
|
||
await waitFor(() => onRpcShutdown.mock.calls.length === 1)
|
||
expect(existsSync(tokenPath)).toBe(false)
|
||
expect(dispose).toHaveBeenCalledOnce()
|
||
expect(onRpcShutdown).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('removes only its owned token and PID record', async () => {
|
||
const launchNonce = 'ordinary-shutdown'
|
||
writeFileSync(
|
||
pidPath,
|
||
serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce })
|
||
)
|
||
await startServer(launchNonce)
|
||
|
||
await server.shutdown()
|
||
|
||
expect(existsSync(tokenPath)).toBe(false)
|
||
expect(existsSync(pidPath)).toBe(false)
|
||
})
|
||
|
||
it('preserves token and PID artifacts replaced before ordinary cleanup', async () => {
|
||
await startServer('mine')
|
||
writeFileSync(tokenPath, 'replacement-token')
|
||
writeFileSync(
|
||
pidPath,
|
||
serializeDaemonPidFile({
|
||
pid: process.pid,
|
||
startedAtMs: null,
|
||
launchNonce: 'replacement'
|
||
})
|
||
)
|
||
|
||
await server.shutdown()
|
||
|
||
expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token')
|
||
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({
|
||
pid: process.pid,
|
||
launchNonce: 'replacement'
|
||
})
|
||
})
|
||
|
||
it('stops accepting connections after shutdown', async () => {
|
||
await startServer()
|
||
await server.shutdown()
|
||
|
||
const c = new DaemonClient({ socketPath, tokenPath })
|
||
await expect(c.ensureConnected()).rejects.toThrow()
|
||
})
|
||
|
||
// Runs everywhere: a closed Windows pipe classifies as missing, not connected.
|
||
it('still terminates via the shutdown RPC when disposal cannot prove physical exit', async () => {
|
||
await startServer()
|
||
const daemon = server as unknown as DaemonServerPrivate & {
|
||
host: { dispose: () => Promise<void> }
|
||
}
|
||
// Why: an unreapable child rejects dispose after its exit deadline; the
|
||
// daemon must exit anyway or its replacement flow strands it as an orphan.
|
||
daemon.host.dispose = vi.fn(() =>
|
||
Promise.reject(new Error('Timed out waiting for physical PTY exit'))
|
||
)
|
||
|
||
const c = await connectClient()
|
||
await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({})
|
||
|
||
await waitFor(() => daemon.lifecycle.server === null)
|
||
// Why not existsSync: the dead entry remains for the next publisher to replace.
|
||
expect(await waitForEndpointUnreachable(socketPath)).toBe(true)
|
||
const late = new DaemonClient({ socketPath, tokenPath })
|
||
await expect(late.ensureConnected()).rejects.toThrow()
|
||
})
|
||
})
|
||
})
|