Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
751d58a
fix: wait for OpenCode worker composer before first dispatch
nwparker Oct 2, 2026
648da96
feat(opencode): probe execution-host CLI capabilities
nwparker Oct 2, 2026
8be3e1b
fix(opencode): select plugin default for execution host loader
nwparker Oct 2, 2026
f96b46c
fix(opencode): limit prompt prefill capability to verified release
nwparker Oct 2, 2026
32ac363
feat(opencode): probe launch capabilities on the execution host
nwparker Oct 2, 2026
8f06210
fix(opencode): select plugin loader for the launched host binary
nwparker Oct 2, 2026
c4a6af1
fix(opencode): match WSL probe cwd and declared guest environment
nwparker Oct 2, 2026
9356b67
fix(opencode): preserve launch environment deletion boundaries
nwparker Oct 2, 2026
7dd9e82
wip(opencode): authorize native startup prompt intent at execution owner
nwparker Oct 2, 2026
fc84887
fix(opencode): atomically replace status plugin entrypoints
AhmedNAgy25 Oct 2, 2026
8bbb511
fix(opencode): retain plugin permissions across restrictive umasks
nwparker Oct 2, 2026
ee2de6a
test(opencode): resolve permission fixture from primary cwd
nwparker Oct 2, 2026
d10b63a
feat(opencode): install startup prompt plugin independently of status…
nwparker Oct 2, 2026
24ef5e8
fix(opencode): wait for admitted startup intent and preserve failed-l…
nwparker Oct 2, 2026
fffdb21
fix(opencode): confine overlay manifest cleanup to owned directories
nwparker Oct 2, 2026
7f2ba67
fix: wait for OpenCode worker composer before first dispatch
nwparker Oct 2, 2026
960bb84
feat(opencode): probe execution-host CLI capabilities
nwparker Oct 2, 2026
6b78423
fix(opencode): select plugin default for execution host loader
nwparker Oct 2, 2026
1b5f4c5
fix(opencode): limit prompt prefill capability to verified release
nwparker Oct 2, 2026
b8cc6fc
feat(opencode): probe launch capabilities on the execution host
nwparker Oct 2, 2026
8c3d344
fix(opencode): select plugin loader for the launched host binary
nwparker Oct 2, 2026
944c72c
fix(opencode): match WSL probe cwd and declared guest environment
nwparker Oct 2, 2026
42788e7
fix(opencode): preserve launch environment deletion boundaries
nwparker Oct 2, 2026
087aa5d
wip(opencode): authorize native startup prompt intent at execution owner
nwparker Oct 2, 2026
d2a65a1
fix(opencode): atomically replace status plugin entrypoints
AhmedNAgy25 Oct 2, 2026
8e16df5
fix(opencode): retain plugin permissions across restrictive umasks
nwparker Oct 2, 2026
d9dc629
test(opencode): resolve permission fixture from primary cwd
nwparker Oct 2, 2026
a59e6a7
feat(opencode): install startup prompt plugin independently of status…
nwparker Oct 2, 2026
10a42d7
fix(opencode): wait for admitted startup intent and preserve failed-l…
nwparker Oct 2, 2026
6012c37
fix(opencode): unsubscribe hook settings during async host shutdown
nwparker Oct 2, 2026
deee1bc
Prepare immutable strict worker readiness restack
nwparker Oct 3, 2026
5c690ca
Prepare immutable execution-host OpenCode capabilities restack
nwparker Oct 3, 2026
fef9ce3
Prepare immutable native startup intent restack
nwparker Oct 3, 2026
305d5e1
Prepare immutable owned overlay cleanup restack
nwparker Oct 3, 2026
dad41e5
Prepare immutable strict worker readiness restack
nwparker Oct 3, 2026
08531ab
Prepare immutable execution-host OpenCode capabilities restack
nwparker Oct 3, 2026
5a74e2c
Prepare immutable native startup intent restack
nwparker Oct 3, 2026
88d3329
Prepare immutable owned overlay cleanup restack
nwparker Oct 3, 2026
40d9038
STRICT launch CI contract correction
nwparker Oct 3, 2026
37f0be5
CAPS launch CI contract correction
nwparker Oct 3, 2026
a6d32ed
INTENT launch CI contract correction
nwparker Oct 3, 2026
7c2ca76
Preserve frozen main and reviewed strict OpenCode layer
nwparker Oct 3, 2026
1b9254e
Preserve frozen main and reviewed caps OpenCode layer
nwparker Oct 3, 2026
f30719e
Preserve frozen main and reviewed intent OpenCode layer
nwparker Oct 3, 2026
d43901e
Preserve frozen main and reviewed overlay OpenCode layer
nwparker Oct 3, 2026
2d6636a
fix(opencode): compose reviewed strict launch boundary with current main
nwparker Oct 3, 2026
a158087
fix(opencode): compose reviewed caps launch boundary with current main
nwparker Oct 3, 2026
3824da7
fix(opencode): compose reviewed intent launch boundary with current main
nwparker Oct 3, 2026
234c81e
fix(opencode): compose reviewed overlay launch boundary with current …
nwparker Oct 3, 2026
a62e9ba
fix(opencode): preserve managed account environment in strict launch …
nwparker Oct 3, 2026
0e46eb4
fix(opencode): preserve managed account environment in caps launch bo…
nwparker Oct 3, 2026
e8fbfc1
fix(opencode): preserve managed account environment in intent launch …
nwparker Oct 3, 2026
b1865aa
fix(opencode): preserve managed account environment in overlay launch…
nwparker Oct 3, 2026
4ecbf29
fix(opencode): preserve current Caller main in strict launch boundary
nwparker Oct 3, 2026
c401e91
fix(opencode): preserve current Caller main in caps launch boundary
nwparker Oct 3, 2026
cc9af03
fix(opencode): preserve current Caller main in intent launch boundary
nwparker Oct 3, 2026
a6879b0
fix(opencode): preserve current Caller main in overlay launch boundary
nwparker Oct 3, 2026
ec6686e
Fix OpenCode caps review boundaries on preserved published source
nwparker Oct 3, 2026
5b429f7
Fix OpenCode intent review boundaries on preserved published source
nwparker Oct 3, 2026
732b301
Fix OpenCode overlay review boundaries on preserved published source
nwparker Oct 3, 2026
1dfbd55
Preserve merged STRICT and current main in OpenCode caps correction
nwparker Oct 3, 2026
e5b1548
Preserve merged STRICT and current main in OpenCode intent correction
nwparker Oct 3, 2026
6a67a78
Preserve merged STRICT and current main in OpenCode overlay correction
nwparker Oct 3, 2026
dcec863
Preserve current main with qualified OpenCode caps source
nwparker Oct 3, 2026
aece2eb
Preserve current main with qualified OpenCode intent source
nwparker Oct 3, 2026
832e740
Preserve current main with qualified OpenCode overlay source
nwparker Oct 3, 2026
b94c542
Fix OpenCode caps standalone build boundary
nwparker Oct 3, 2026
3cdf1f4
Fix OpenCode intent standalone build boundary
nwparker Oct 3, 2026
91d00c1
Fix OpenCode overlay standalone build boundary
nwparker Oct 3, 2026
f1f6feb
Preserve current main and qualified OpenCode caps repairs
nwparker Oct 3, 2026
56b1bc7
Preserve current main and qualified OpenCode intent repairs
nwparker Oct 3, 2026
e10caa9
Preserve current main and qualified OpenCode overlay repairs
nwparker Oct 3, 2026
6a83da3
test: initialize Claude prompt state in output retention fixture
nwparker Oct 3, 2026
107d359
Preserve OpenCode caps integration with the reviewed main test fixture
nwparker Oct 3, 2026
3352397
Preserve OpenCode intent integration with the reviewed main test fixture
nwparker Oct 3, 2026
b43f0c9
Preserve OpenCode overlay integration with the reviewed main test fix…
nwparker Oct 3, 2026
d94a3f2
Preserve OpenCode caps integration and current main relay inventory c…
nwparker Oct 3, 2026
d9285ae
Preserve OpenCode intent integration and current main relay inventory…
nwparker Oct 3, 2026
200eb78
Preserve OpenCode overlay integration and current main relay inventor…
nwparker Oct 3, 2026
a77cea7
Preserve qualified OpenCode caps with current merged account selection
nwparker Oct 4, 2026
b43c77d
Preserve qualified OpenCode intent with current merged account selection
nwparker Oct 4, 2026
f45ae0b
Preserve qualified OpenCode overlay with current merged account selec…
nwparker Oct 4, 2026
85efe85
Wait for OpenCode location hydration in intent startup
Oct 4, 2026
d12f6b5
Wait for OpenCode location hydration in overlay startup
Oct 4, 2026
1dc953c
Bind OpenCode startup readiness to the current location in intent sta…
Oct 4, 2026
0dd7371
Bind OpenCode startup readiness to the current location in overlay st…
Oct 4, 2026
e213fe1
Merge current main into the OpenCode caps layer
nwparker Oct 4, 2026
89aa00a
Merge current main into the OpenCode intent layer
nwparker Oct 4, 2026
15f3e7a
Merge current main into the OpenCode overlay layer
nwparker Oct 4, 2026
a754005
Merge current main into OpenCode overlay cleanup guards
nwparker Oct 4, 2026
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
98 changes: 98 additions & 0 deletions src/main/opencode/hook-service-overlay-confinement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { setAppEnvironment } from '../../shared/app-environment'
import { safeRemoveTree } from '../pty/overlay-mirror'
import { OpenCodeHookService } from './hook-service'
import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest'

let root: string
let source: string
let service: OpenCodeHookService

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-'))
source = join(root, 'source')
mkdirSync(join(source, 'plugins'), { recursive: true })
writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}')
vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg'))
setAppEnvironment({
getPath: () => join(root, 'profile'),
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
})
service = new OpenCodeHookService({
pluginFileName: 'orca-test.js',
legacyHooksDir: 'legacy-test',
overlayDir: 'overlay-test',
pluginSource: () => 'export default {}'
})
})

afterEach(() => {
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
})

it.each(['overlay-root', 'source-overlay', 'plugins'] as const)(
'preserves outside files when the owned %s is replaced by a directory link',
(boundary) => {
const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR
if (!overlay) {
throw new Error('Expected an isolated overlay')
}
const outside = join(root, 'outside')
const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside
mkdirSync(join(externalOverlay, 'plugins'), { recursive: true })
const sentinel = join(externalOverlay, 'plugins', 'keep.js')
writeFileSync(sentinel, 'export const keep = true')
writeFileSync(
join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE),
JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] })
)
const replaced =
boundary === 'overlay-root'
? dirname(overlay)
: boundary === 'source-overlay'
? overlay
: join(overlay, 'plugins')
const target = boundary === 'plugins' ? join(outside, 'plugins') : outside
if (boundary === 'plugins') {
writeFileSync(
join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE),
JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] })
)
}
safeRemoveTree(replaced)
symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir')
const before = readdirSync(outside, { recursive: true }).toSorted()

const result = service.buildPtyEnv('pane-2', source)
expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true')
expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before)
expect(result).toEqual({ OPENCODE_CONFIG_DIR: source })
}
)

it('still removes a stale mirrored entry from a real owned overlay', () => {
const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR
if (!overlay) {
throw new Error('Expected an isolated overlay')
}
rmSync(join(source, 'plugins', 'user.js'))
expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay })
expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js'])
})
14 changes: 10 additions & 4 deletions src/main/opencode/hook-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
writeFileSync
} from 'node:fs'
import { createHash } from 'node:crypto'
import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror'
import {
getOpenCode2PluginSource,
getOpenCodeFamilyPluginSource,
Expand Down Expand Up @@ -141,7 +141,13 @@ export class OpenCodeHookService {
}
const overlayDir = this.getSourceOverlayDir(existingConfigDir)
try {
mkdirSync(overlayDir, { recursive: true })
// Owned directories must stay real; a replaced parent redirects both cleanup and writes.
for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) {
mkdirSync(directory, { recursive: true })
if (!isSafeDescendCandidate(lstatSync(directory))) {
return { OPENCODE_CONFIG_DIR: existingConfigDir }
}
}
if (existsSync(existingConfigDir)) {
this.mirrorUserConfig(existingConfigDir, overlayDir)
}
Expand Down Expand Up @@ -230,15 +236,15 @@ export class OpenCodeHookService {

private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void {
for (const entryName of manifest.topLevelEntries) {
safeRemoveTree(join(overlayDir, entryName))
safeRemoveOverlay(join(overlayDir, entryName), overlayDir)
}

const overlayPluginsDir = join(overlayDir, 'plugins')
for (const entryName of manifest.pluginEntries) {
if (entryName === this.pluginFileName) {
continue
}
safeRemoveTree(join(overlayPluginsDir, entryName))
safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir)
}
}

Expand Down
32 changes: 32 additions & 0 deletions src/main/opencode/opencode-overlay-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import {
readOpenCodeOverlayManifest,
OPENCODE_OVERLAY_MANIFEST_FILE
} from './opencode-overlay-manifest'

it('accepts only string manifest entries and tolerates invalid persisted shapes', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-'))
try {
const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE)
for (const text of ['null', '1', '{']) {
writeFileSync(path, text)
expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] })
}
writeFileSync(
path,
JSON.stringify({
topLevelEntries: ['valid', 1, null],
pluginEntries: [{ bad: true }, 'plugin.js']
})
)
expect(readOpenCodeOverlayManifest(root)).toEqual({
topLevelEntries: ['valid'],
pluginEntries: ['plugin.js']
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
29 changes: 28 additions & 1 deletion src/main/pty/overlay-mirror.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import type * as NodePath from 'node:path'
Expand All @@ -19,6 +19,33 @@ afterEach(() => {
})

describe('safeRemoveOverlay', () => {
it('keeps missing owned paths a silent no-op', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-'))
tempRoots.push(root)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing'))
expect(warn).not.toHaveBeenCalled()
})

it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-'))
tempRoots.push(root)
const overlay = join(root, 'overlay')
const outside = join(root, 'outside')
mkdirSync(overlay)
mkdirSync(outside)
const sentinel = join(outside, 'keep.txt')
writeFileSync(sentinel, 'private sentinel')
const linked = join(overlay, 'linked')
symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir')
vi.spyOn(console, 'warn').mockImplementation(() => {})
safeRemoveOverlay(join(linked, 'keep.txt'), overlay)
expect(existsSync(sentinel)).toBe(true)
safeRemoveOverlay(linked, overlay)
expect(existsSync(linked)).toBe(false)
expect(existsSync(sentinel)).toBe(true)
})

it('removes valid overlay children whose names start with dot-dot', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-'))
tempRoots.push(root)
Expand Down
35 changes: 34 additions & 1 deletion src/main/pty/overlay-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void {
}
}

// Why: cleanup must not traverse a symlink parent inside the owned overlay.
function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean {
let current = root
try {
if (lstatSync(current).isSymbolicLink()) {
return false
}
} catch (error) {
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'
}
const segments = relativeTarget.split(sep).filter(Boolean)
for (const [index, segment] of segments.entries()) {
current = join(current, segment)
if (index === segments.length - 1) {
break
}
try {
if (lstatSync(current).isSymbolicLink()) {
return false
}
} catch (error) {
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'
}
}
return true
}

// Why: last-line guard against an overlay-root constant ever being
// mis-resolved. Any caller that points safeRemoveTree at a path outside its
// designated overlay root is refused so a misconfiguration cannot turn into
Expand All @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void
const resolvedRoot = resolve(overlayRoot)
const resolvedTarget = resolve(overlayDir)
const rel = relative(resolvedRoot, resolvedTarget)
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
if (
rel === '' ||
rel === '..' ||
rel.startsWith(`..${sep}`) ||
isAbsolute(rel) ||
!hasSafeOverlayAncestors(resolvedRoot, rel)
) {
console.warn(
`[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}`
)
Expand Down
Loading