diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9626ed68406..a20edab4f44 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -31,9 +31,15 @@ jobs: - name: Lint run: pnpm exec oxlint --format github + - name: Check switch exhaustiveness + run: pnpm run lint:switch-exhaustiveness + - name: Check styled scrollbars run: pnpm check:styled-scrollbars + - name: Check quadratic buffer concatenation + run: pnpm run check:quadratic-buffer-concat + - name: Check reliability gate manifest run: pnpm run check:reliability-gates @@ -46,6 +52,17 @@ jobs: - name: Verify skill freshness manifest run: pnpm run verify:skill-bundle-manifest + - name: Verify localization catalog + run: pnpm run verify:localization-catalog + + - name: Verify localization coverage + run: pnpm run verify:localization-coverage + + # Why: project-owned type declarations must live in .ts so tsc + # actually checks them. TypeScript's skipLibCheck: true (inherited + # from @electron-toolkit/tsconfig) silently widens unresolved names + # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature + # past typecheck. See docs/preload-typecheck-hole.md. - name: Guard against project-owned .d.ts in preload/shared run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index b40959e7e03..2a3b4603f36 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -40,5 +40,33 @@ "text": "Idioma", "dynamic": false, "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", + "kind": "object-property:keywords", + "text": "Ghostty", + "dynamic": false, + "count": 2 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", + "kind": "object-property:keywords", + "text": "ghostty", + "dynamic": false, + "count": 2 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "kind": "object-property:keywords", + "text": "Ghostty", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "kind": "object-property:keywords", + "text": "ghostty", + "dynamic": false, + "count": 1 } ] diff --git a/config/scripts/pr-workflow-lint-parity.test.mjs b/config/scripts/pr-workflow-lint-parity.test.mjs new file mode 100644 index 00000000000..648714f244c --- /dev/null +++ b/config/scripts/pr-workflow-lint-parity.test.mjs @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure +// does not mask the rest and oxlint keeps its `--format github` annotations. +// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so +// this gate fails the moment a `lint` step has no counterpart in pr.yml. + +// Flags that only change reporting, so they must not split two otherwise identical commands. +const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter']) +const REPORTING_FLAGS = new Set(['--quiet']) +const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node']) + +function splitCommandChain(command) { + return command + .split(/\n|&&|;/) + .map((part) => part.trim()) + .filter(Boolean) +} + +function canonicalize(command) { + const tokens = command.split(/\s+/) + const canonical = [] + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) { + continue + } + if (REPORTING_FLAGS.has(token)) { + continue + } + if (REPORTING_FLAGS_WITH_VALUE.has(token)) { + index += 1 + continue + } + canonical.push(token) + } + + return canonical.join(' ') +} + +/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */ +function resolveLeafCommands(command, scripts, seen = new Set()) { + const leaves = [] + + for (const part of splitCommandChain(command)) { + const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1] + if (scriptName && scripts[scriptName] && !seen.has(scriptName)) { + leaves.push( + ...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName])) + ) + continue + } + leaves.push(canonicalize(part)) + } + + return leaves +} + +describe('PR workflow lint parity', () => { + it('runs every `pnpm lint` step on pull requests', () => { + const { scripts } = JSON.parse(readFileSync('package.json', 'utf8')) + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + + // Scan every job: which one hosts the lint steps is an organizational + // detail that has already been renamed once (verify -> static_analysis). + const workflowCommands = new Set( + Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .filter((step) => typeof step.run === 'string') + .flatMap((step) => resolveLeafCommands(step.run, scripts)) + ) + + const missing = resolveLeafCommands(scripts.lint, scripts).filter( + (leaf) => !workflowCommands.has(leaf) + ) + + expect( + missing, + `.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` + + 'Add a step for each one so PR CI matches `pnpm lint`.' + ).toEqual([]) + }) +}) diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 989a83c9f77..caeabda3de7 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -27,7 +27,10 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'], - notes: ['For remote runtimes, --path must be an absolute path on the remote server.'], + notes: [ + 'For remote runtimes, --path must be an absolute path on the remote server.', + 'SSH targets are set up through the desktop UI because the desktop client owns SSH connections.' + ], examples: [ 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 007b23f3fef..8486af362eb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7160,6 +7160,67 @@ describe('OrcaRuntimeService', () => { } }) + it('refuses SSH hosts instead of setting the project up on the local machine', async () => { + // Why: both inputs must be paths the pre-guard code would have accepted. An unwritable + // destination fails at mkdir and a non-repo path fails at isGitRepo, which would leave the + // side-effect assertions below unable to observe the local clone/probe they exist to catch. + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-ssh-guard-')) + const existingFolder = join(destination, 'orca') + mkdirSync(existingFolder, { recursive: true }) + execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' }) + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => { + // Why: unreachable while the guard holds; stubbed so a regression records the call + // instead of shelling out to a real network clone. + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 1, null)) + return proc as never + }) + const repos: Record[] = [] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const cloneError = await runtime + .setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw', + url: 'https://example.com/orca.git', + destination + }) + .catch((error: unknown) => error) + const existingFolderError = await runtime + .setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw', + path: existingFolder, + kind: 'git' + }) + .catch((error: unknown) => error) + + // Why: the defect was a silent local clone/probe recorded as remote, not a bad message, + // so the absent side effects are asserted before the wording. Both calls are awaited + // first so a regression reports the corruption rather than stopping at the first throw. + expect(spawnSpy).not.toHaveBeenCalled() + expect(repos).toHaveLength(0) + expect(cloneError).toMatchObject({ + message: expect.stringMatching(/SSH hosts are not supported/) + }) + expect(existingFolderError).toMatchObject({ + message: expect.stringMatching(/SSH hosts are not supported/) + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('adopts public clone repos into host-qualified project setup', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 34004203d41..03df14bebf6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1823,6 +1823,19 @@ function runtimeRepoMatchesExecutionHost( return repo.connectionId == null } +// Why: this runtime only has local git and local fs, so an ssh: host here would clone and +// probe the wrong machine and then register the result as remote. SSH setup is owned by the +// desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to; +// only `local` and `runtime:` legitimately reach these RPCs. +function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void { + if (parseExecutionHostId(hostId)?.kind !== 'ssh') { + return + } + throw new Error( + 'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.' + ) +} + function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } @@ -15309,6 +15322,7 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } + assertProjectHostSetupHostIsSupported(args.hostId) let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId) let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { @@ -15351,6 +15365,8 @@ export class OrcaRuntimeService { } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { + // Why: guard before cloneRepo, which would otherwise clone to the local disk. + assertProjectHostSetupHostIsSupported(args.hostId) const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return await this.setupProjectExistingFolder({ projectId: args.projectId,