Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ token.json
# Test results
test-results/
playwright-report/
test-results-web-shell/
playwright-report-web-shell/
artifacts/
tmp/

Expand Down
38 changes: 38 additions & 0 deletions docs/CONTROL_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,41 @@ Control Surface 与 transport 解耦:

相关架构总规范见 `docs/ARCHITECTURE.md` 与 `docs/LANDING_ARCHITECTURE.md`。

## 7. Remote Worker(SSH-first,Phase 4 v0)

> 目标:不改变 contracts 的前提下,把 Control Surface 通过 SSH tunnel 带到远端。

最小工作流(示例):

1. 先构建一次(保证 `out/main/worker.js` 可用):

```bash
pnpm build
```

2. 在远端机器启动 worker(默认只监听 `127.0.0.1`,需要 token):

```bash
node out/main/worker.js --port 16661 --token <token> --approve-root <workspacePath>
```

3. 本地通过 SSH 建立 tunnel:

```bash
ssh -L 16661:127.0.0.1:16661 <host>
```

4. 本地 CLI 通过 tunnel 调用同一套 contracts:

```bash
opencove ping --endpoint http://127.0.0.1:16661 --token <token>
```

可选(用于验证/调试):

- Worker 启动后可访问 `http://127.0.0.1:<port>/` 打开最小 web shell,通过 `Authorization: Bearer <token>` 调用 `/invoke`。

原则:

- 远端 worker 默认不暴露公网端口;SSH/tunnel 是保守默认路径。
- CLI/Web/Desktop 只作为 client;durable truth 与副作用由 worker owner。
1 change: 1 addition & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/app/main/index.ts'),
worker: resolve(__dirname, 'src/app/worker/index.ts'),
ptyHost: resolve(__dirname, 'src/platform/process/ptyHost/entry.ts'),
},
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "node scripts/test-e2e-with-window-fallback.mjs",
"test:e2e:web-shell": "node scripts/test-e2e-web-shell.mjs",
"lint": "oxlint .",
"lint:fix": "oxlint --fix .",
"opencove": "node src/app/cli/opencove.mjs",
Expand Down
27 changes: 27 additions & 0 deletions playwright.web-shell.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig } from '@playwright/test'

const baseURL = process.env['OPENCOVE_WEB_SHELL_BASE_URL']

export default defineConfig({
testDir: './tests/e2e-web-shell',
testMatch: '**/*.spec.ts',
timeout: 60_000,
expect: {
timeout: 15_000,
},
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-web-shell' }]],
outputDir: './test-results-web-shell',
projects: [
{
name: 'chromium',
use: {
baseURL,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
},
],
})
194 changes: 194 additions & 0 deletions scripts/test-e2e-web-shell.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/usr/bin/env node

import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline'
import { pathToFileURL } from 'node:url'

const PNPM_COMMAND = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'

function isTruthyEnv(rawValue) {
if (!rawValue) {
return false
}

return rawValue === '1' || rawValue.toLowerCase() === 'true'
}

function runCommand(args, env = process.env) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(PNPM_COMMAND, args, {
cwd: process.cwd(),
env,
shell: process.platform === 'win32',
stdio: 'inherit',
windowsHide: true,
})

child.on('error', rejectPromise)
child.on('close', code => {
resolvePromise(typeof code === 'number' ? code : 1)
})
})
}

async function startWorker(options) {
const child = spawn(
process.execPath,
[
options.workerPath,
'--hostname',
'127.0.0.1',
'--port',
'0',
'--user-data',
options.userDataPath,
'--token',
options.token,
'--approve-root',
options.approvedRoot,
],
{
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
},
)

child.stderr?.on('data', chunk => {
process.stderr.write(chunk)
})

const ready = new Promise((resolvePromise, rejectPromise) => {
if (!child.stdout) {
rejectPromise(new Error('[web-shell-e2e] Worker stdout not available'))
return
}

const rl = createInterface({ input: child.stdout })

rl.once('line', line => {
rl.close()

try {
const info = JSON.parse(line)
const hostname = info && typeof info.hostname === 'string' ? info.hostname : null
const port = info && typeof info.port === 'number' ? info.port : null
if (!hostname || !port) {
rejectPromise(new Error('[web-shell-e2e] Worker ready payload is invalid'))
return
}

resolvePromise({ hostname, port })
} catch (error) {
rejectPromise(error)
}
})

child.once('exit', code => {
rl.close()
rejectPromise(new Error(`[web-shell-e2e] Worker exited before ready (code=${code ?? 1})`))
})
})

const info = await ready
return { child, info }
}

async function stopWorker(child) {
if (!child || child.killed) {
return
}

await new Promise(resolvePromise => {
const timeout = setTimeout(() => {
try {
child.kill('SIGKILL')
} catch {
child.kill()
}
}, 3_000)

child.once('exit', () => {
clearTimeout(timeout)
resolvePromise()
})

try {
child.kill('SIGTERM')
} catch {
child.kill()
}
})
}

async function main() {
const forwardedArgs = process.argv.slice(2)

if (!isTruthyEnv(process.env['OPENCOVE_E2E_SKIP_BUILD'])) {
const buildCode = await runCommand(['build'])
if (buildCode !== 0) {
process.exit(buildCode)
}
}

const workerPath = resolve(process.cwd(), 'out', 'main', 'worker.js')
const userDataPath = await mkdtemp(resolve(tmpdir(), 'opencove-web-shell-userdata-'))
const workspaceRoot = await mkdtemp(resolve(tmpdir(), 'opencove-web-shell-workspace-'))
const testFilePath = resolve(workspaceRoot, 'hello.txt')
await writeFile(testFilePath, 'hello from opencove web shell e2e\n', 'utf8')

const token = randomBytes(16).toString('hex')
const testFileUri = pathToFileURL(testFilePath).toString()

let worker = null

let exitCode = 1
try {
const started = await startWorker({
workerPath,
userDataPath,
approvedRoot: workspaceRoot,
token,
})

worker = started.child
const baseUrl = `http://${started.info.hostname}:${started.info.port}`

const testEnv = {
...process.env,
OPENCOVE_WEB_SHELL_BASE_URL: baseUrl,
OPENCOVE_WEB_SHELL_TOKEN: token,
OPENCOVE_WEB_SHELL_TEST_FILE_URI: testFileUri,
}

const code = await runCommand(
[
'exec',
'playwright',
'test',
'--config',
'playwright.web-shell.config.ts',
...forwardedArgs,
],
testEnv,
)

exitCode = code
} finally {
await stopWorker(worker)
await rm(userDataPath, { recursive: true, force: true })
await rm(workspaceRoot, { recursive: true, force: true })
}

process.exit(exitCode)
}

void main().catch(error => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error)
process.stderr.write(`${message}\n`)
process.exit(1)
})
5 changes: 5 additions & 0 deletions src/app/cli/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export function stripGlobalOptions(argv) {
continue
}

if (arg === '--endpoint' || arg === '--token') {
index += 1
continue
}

args.push(arg)
}

Expand Down
Loading
Loading