mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
* Honor configured shells during worktree setup
* Align setup launch paths with selected Windows shells
* Carry setup shell selection through deferred launches
* Prove Windows setup shell routing at its real adapters
* Ground remote PowerShell proof in the real writer
* Preserve Git Bash across deferred setup launches
* Harden Windows setup runner shell selection
- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
'auto' implementation could route the remote runner to a pwsh.exe the remote
lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
$LASTEXITCODE before $?, so a failing native command surfaces its real code
instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.
* Restore setup-shell scope narrowing over the rebase
The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:
- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures
* Satisfy the changed-code gates for the setup-shell runner
- createWorktreeRunnerScript took 7 positional parameters, tripping the
changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
assert the cmd shell now returned for native Windows worktrees.
* Carry the setup launch shell through observed and issue runners
- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): close counsel P1 gaps for Windows setup shells
Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.
* Convert setup env to MSYS form and harden the bare cmd runner launch
C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.
Co-authored-by: Orca <help@stably.ai>
* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution
Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.
* revert: drop windows-setup-shell doc allowlist and AGENTS link
Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.
* fix(plugins): contain Parcel unsubscribe rejections under Vitest
Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.
* fix(plugins): keep in-process unsubscribe rejection surface
Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { disposeWatcherProcess } from '../ipc/parcel-watcher-process'
|
|
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
|
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
|
|
import {
|
|
PLUGIN_PANEL_ENTRY_MAX_BYTES,
|
|
validateDeclaredPluginArtifacts,
|
|
validatePluginInstallContent
|
|
} from './plugin-artifact-validation'
|
|
import { hashPluginTree } from './plugin-content-hash'
|
|
import { verifyHashAddressedPluginContent } from './plugin-content-integrity'
|
|
import { PluginService } from './plugin-service'
|
|
|
|
const roots: string[] = []
|
|
|
|
async function tempRoot(): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-content-test-'))
|
|
roots.push(root)
|
|
return root
|
|
}
|
|
|
|
type ManifestOverrides = Omit<Partial<PluginManifest>, 'contributes'> & {
|
|
contributes?: Partial<PluginManifest['contributes']>
|
|
}
|
|
|
|
function manifest(overrides: ManifestOverrides = {}): PluginManifest {
|
|
const { contributes, ...manifestOverrides } = overrides
|
|
return pluginManifestSchema.parse({
|
|
manifestVersion: 1,
|
|
id: 'demo',
|
|
publisher: 'orca-samples',
|
|
name: 'Demo',
|
|
version: '1.0.0',
|
|
engines: { orca: '>=1.0.0' },
|
|
pluginApi: 1,
|
|
capabilities: [],
|
|
...manifestOverrides,
|
|
contributes
|
|
})
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
// Why: PluginService may still be releasing in-process Parcel watches; drop
|
|
// the shared vitest watcher state before deleting roots those watches own.
|
|
disposeWatcherProcess()
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe('declared plugin artifacts', () => {
|
|
it('validates every content-pack file and directory before enablement', async () => {
|
|
const root = await tempRoot()
|
|
await Promise.all([
|
|
mkdir(join(root, 'locales')),
|
|
mkdir(join(root, 'recipes')),
|
|
writeFile(join(root, 'agent.json'), '{}')
|
|
])
|
|
await Promise.all([
|
|
writeFile(join(root, 'locales', 'pt-BR.json'), '{}'),
|
|
writeFile(join(root, 'recipes', 'vm.json'), '{}')
|
|
])
|
|
const pluginManifest = manifest({
|
|
contributes: {
|
|
languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }],
|
|
vmRecipes: [{ path: 'recipes/vm.json' }],
|
|
agents: [{ path: 'agent.json' }]
|
|
}
|
|
})
|
|
|
|
await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
})
|
|
|
|
it('requires declared files to exist and be regular files', async () => {
|
|
const root = await tempRoot()
|
|
await mkdir(join(root, 'panel.html'))
|
|
|
|
const result = await validateDeclaredPluginArtifacts(
|
|
root,
|
|
manifest({
|
|
main: 'missing-worker.js',
|
|
contributes: {
|
|
panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
|
|
commands: [],
|
|
events: []
|
|
}
|
|
})
|
|
)
|
|
|
|
expect(result).toMatchObject({ ok: false })
|
|
})
|
|
|
|
it('refuses a panel reached through an escaping directory link or junction', async () => {
|
|
const root = await tempRoot()
|
|
const outsideDir = await tempRoot()
|
|
const userDataPath = await tempRoot()
|
|
await writeFile(join(outsideDir, 'panel.html'), '<h1>outside</h1>')
|
|
await symlink(
|
|
outsideDir,
|
|
join(root, 'escape'),
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
)
|
|
const pluginManifest = manifest({
|
|
contributes: {
|
|
panels: [{ id: 'panel', title: 'Panel', entry: 'escape/panel.html' }],
|
|
commands: [],
|
|
events: []
|
|
}
|
|
})
|
|
await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest))
|
|
|
|
await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({
|
|
ok: false
|
|
})
|
|
|
|
const pluginKey = `${pluginManifest.publisher}.${pluginManifest.id}`
|
|
const service = new PluginService({
|
|
userDataPath,
|
|
hostVersion: '1.4.0',
|
|
isPluginSystemEnabled: () => true,
|
|
getDisabledPlugins: () => [],
|
|
getPluginConsents: () => ({
|
|
[pluginKey]: fingerprintPluginConsent(pluginManifest)
|
|
}),
|
|
getDevPluginPaths: () => [root]
|
|
})
|
|
try {
|
|
await service.initialize()
|
|
await expect(service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull()
|
|
} finally {
|
|
await service.dispose()
|
|
}
|
|
})
|
|
|
|
it('rejects a panel artifact too large to mount safely in a renderer', async () => {
|
|
const root = await tempRoot()
|
|
const panelPath = join(root, 'panel.html')
|
|
await writeFile(panelPath, '')
|
|
await truncate(panelPath, PLUGIN_PANEL_ENTRY_MAX_BYTES + 1)
|
|
const pluginManifest = manifest({
|
|
contributes: {
|
|
panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
|
|
commands: [],
|
|
events: []
|
|
}
|
|
})
|
|
|
|
await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('artifact limit')
|
|
})
|
|
})
|
|
|
|
it('parses VM recipes at the immutable install boundary', async () => {
|
|
const root = await tempRoot()
|
|
await mkdir(join(root, 'recipes'))
|
|
await writeFile(
|
|
join(root, 'recipes', 'invalid.json'),
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
id: 'cloud',
|
|
name: 'Cloud',
|
|
create: 'create',
|
|
suspend: 'suspend'
|
|
})
|
|
)
|
|
const pluginManifest = manifest({
|
|
contributes: { vmRecipes: [{ path: 'recipes/invalid.json' }] }
|
|
})
|
|
|
|
await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('suspend and resume')
|
|
})
|
|
})
|
|
|
|
it('rejects duplicate VM recipe ids at the immutable install boundary', async () => {
|
|
const root = await tempRoot()
|
|
await mkdir(join(root, 'recipes'))
|
|
const recipe = JSON.stringify({
|
|
schemaVersion: 1,
|
|
id: 'cloud',
|
|
name: 'Cloud',
|
|
create: 'create'
|
|
})
|
|
await Promise.all([
|
|
writeFile(join(root, 'recipes', 'one.json'), recipe),
|
|
writeFile(join(root, 'recipes', 'two.json'), recipe)
|
|
])
|
|
const pluginManifest = manifest({
|
|
contributes: {
|
|
vmRecipes: [{ path: 'recipes/one.json' }, { path: 'recipes/two.json' }]
|
|
}
|
|
})
|
|
|
|
await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('duplicate VM recipe id "cloud"')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('hash-addressed plugin content', () => {
|
|
it('uses unambiguous framing for paths and file contents', async () => {
|
|
const first = await tempRoot()
|
|
const second = await tempRoot()
|
|
await writeFile(join(first, 'a'), Buffer.from('x\0b\0y'))
|
|
await Promise.all([writeFile(join(second, 'a'), 'x'), writeFile(join(second, 'b'), 'y')])
|
|
|
|
const [firstHash, secondHash] = await Promise.all([
|
|
hashPluginTree(first),
|
|
hashPluginTree(second)
|
|
])
|
|
|
|
expect(firstHash).toMatchObject({ ok: true })
|
|
expect(secondHash).toMatchObject({ ok: true })
|
|
if (firstHash.ok && secondHash.ok) {
|
|
expect(firstHash.hash).not.toBe(secondHash.hash)
|
|
}
|
|
})
|
|
|
|
it('hashes and bounds nested .git directories as plugin content', async () => {
|
|
const root = await tempRoot()
|
|
const nestedGit = join(root, 'vendor', '.git')
|
|
await mkdir(nestedGit, { recursive: true })
|
|
const entry = join(nestedGit, 'main.mjs')
|
|
await writeFile(entry, 'export default function activate() {}')
|
|
const initial = await hashPluginTree(root)
|
|
await writeFile(entry, 'export default function activate() { throw new Error("changed") }')
|
|
const changed = await hashPluginTree(root)
|
|
|
|
expect(initial).toMatchObject({ ok: true })
|
|
expect(changed).toMatchObject({ ok: true })
|
|
if (initial.ok && changed.ok) {
|
|
expect(changed.hash).not.toBe(initial.hash)
|
|
}
|
|
|
|
await truncate(entry, 50 * 1024 * 1024 + 1)
|
|
await expect(hashPluginTree(root)).resolves.toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('byte limit')
|
|
})
|
|
})
|
|
|
|
it('does not let host locale collation change a content address', async () => {
|
|
const root = await tempRoot()
|
|
await writeFile(join(root, 'alpha.txt'), 'a')
|
|
await writeFile(join(root, 'zulu.txt'), 'z')
|
|
const expected = await hashPluginTree(root)
|
|
vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => -1)
|
|
|
|
const withDifferentCollation = await hashPluginTree(root)
|
|
|
|
expect(withDifferentCollation).toEqual(expected)
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'rejects Windows-reserved tree entries cross-platform',
|
|
async () => {
|
|
const root = await tempRoot()
|
|
await writeFile(join(root, 'CON.txt'), 'reserved')
|
|
|
|
await expect(hashPluginTree(root)).resolves.toMatchObject({ ok: false })
|
|
}
|
|
)
|
|
|
|
it('detects content changed after its address was computed', async () => {
|
|
const root = await tempRoot()
|
|
const entry = join(root, 'panel.html')
|
|
await writeFile(entry, '<h1>original</h1>')
|
|
const initial = await hashPluginTree(root)
|
|
expect(initial.ok).toBe(true)
|
|
if (!initial.ok) {
|
|
return
|
|
}
|
|
expect(initial.hash).toMatch(/^[0-9a-f]{64}$/)
|
|
await expect(
|
|
verifyHashAddressedPluginContent({
|
|
rootDir: root,
|
|
contentHash: initial.hash.slice(0, 32)
|
|
})
|
|
).resolves.toEqual({ ok: true })
|
|
|
|
await writeFile(entry, '<h1>tampered</h1>')
|
|
|
|
await expect(
|
|
verifyHashAddressedPluginContent({ rootDir: root, contentHash: initial.hash })
|
|
).resolves.toMatchObject({ ok: false })
|
|
})
|
|
|
|
it('rejects an oversized sparse file before reading it into memory', async () => {
|
|
const root = await tempRoot()
|
|
const oversized = join(root, 'oversized.bin')
|
|
await writeFile(oversized, '')
|
|
await truncate(oversized, 50 * 1024 * 1024 + 1)
|
|
|
|
await expect(hashPluginTree(root)).resolves.toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('byte limit')
|
|
})
|
|
})
|
|
})
|