mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(ssh): package standalone POSIX helper runtimes
This commit is contained in:
1 parent
34a0a8e81d
commit
d9342fcb5d
77 files changed
+2524
-309
No files matched your search
@@ -0,0 +1,127 @@
|
||||
name: Build SSH helper
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
targets:
|
||||
description: Comma-separated native Node 24 targets; empty builds all four.
|
||||
type: string
|
||||
default: ''
|
||||
release:
|
||||
description: Retain a complete release build when another run starts.
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
targets:
|
||||
description: Linux/macOS x64/arm64 pkg targets; empty builds all four.
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ssh-helper-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !inputs.release }}
|
||||
|
||||
env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matrix: ${{ steps.targets.outputs.matrix }}
|
||||
steps:
|
||||
- name: Validate native targets
|
||||
id: targets
|
||||
env:
|
||||
REQUESTED_TARGETS: ${{ inputs.targets }}
|
||||
run: |
|
||||
node --input-type=module <<'JS'
|
||||
import { appendFileSync } from 'node:fs';
|
||||
const runners = {
|
||||
'node24-linux-x64': 'ubuntu-24.04',
|
||||
'node24-linux-arm64': 'ubuntu-24.04-arm',
|
||||
'node24-macos-arm64': 'macos-latest',
|
||||
'node24-macos-x64': 'macos-15-intel',
|
||||
};
|
||||
const raw = process.env.REQUESTED_TARGETS;
|
||||
const targets = raw ? raw.split(',').map(value => value.trim()) : Object.keys(runners);
|
||||
if (new Set(targets).size !== targets.length || targets.some(target => !Object.hasOwn(runners, target))) throw new Error('Invalid or duplicate SSH helper target');
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(targets.map(target => ({target, runner: runners[target]})))}\n`);
|
||||
JS
|
||||
|
||||
build:
|
||||
needs: plan
|
||||
name: ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pkg-cache
|
||||
key: ssh-pkg-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: ssh-pkg-${{ matrix.target }}-
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Prepare Linux compiler and sandbox
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -yq musl-tools bubblewrap
|
||||
if sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
bwrap --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent -- true
|
||||
- name: Build compatible Linux PTY addon
|
||||
if: runner.os == 'Linux'
|
||||
run: bash scripts/prepare-linux-node-pty.sh
|
||||
- name: Build native release archive
|
||||
env:
|
||||
HELPER_TARGET: ${{ matrix.target }}
|
||||
run: pnpm exec tsx scripts/build-exe-for-ssh-helper.ts --target="$HELPER_TARGET"
|
||||
- name: Verify relocated executable and confinement
|
||||
env:
|
||||
HELPER_TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
archives=(dist-exe/ssh-helper/*.tar.gz)
|
||||
test "${#archives[@]}" -eq 1
|
||||
pnpm exec tsx scripts/verify-ssh-helper-artifact.ts --archive="${archives[0]}" --sandbox=required --report="dist-exe/ssh-helper/$HELPER_TARGET.json"
|
||||
- name: Verify the packaged Landlock backend
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
HELPER_TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
archives=(dist-exe/ssh-helper/*.tar.gz)
|
||||
pnpm exec tsx scripts/verify-ssh-helper-artifact.ts --archive="${archives[0]}" --sandbox=required --backend=landlock-run --report="dist-exe/ssh-helper/$HELPER_TARGET.landlock.json"
|
||||
- name: Verify OpenSSH on glibc 2.28 without Node
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
HELPER_TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
archives=(dist-exe/ssh-helper/*.tar.gz)
|
||||
test "${#archives[@]}" -eq 1
|
||||
pnpm exec tsx scripts/verify-ssh-helper-ssh.ts --archive="${archives[0]}" --report="dist-exe/ssh-helper/$HELPER_TARGET.ssh.json"
|
||||
- name: Upload verified archive and evidence
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ssh-helper-${{ matrix.target }}
|
||||
path: dist-exe/ssh-helper/*
|
||||
if-no-files-found: error
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
options:
|
||||
- larger-runner-benchmark
|
||||
- consolidated-runner-benchmark
|
||||
- ssh-helper
|
||||
|
||||
# New master pushes and manual runs replace obsolete checks in this workflow/ref.
|
||||
# Standby drills share cancellation; use completed runs as readiness evidence.
|
||||
@@ -30,6 +31,13 @@ env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
ssh-helper:
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || (github.event_name == 'workflow_dispatch' && inputs.suite == 'ssh-helper')
|
||||
name: ssh helper / native artifacts
|
||||
uses: ./.github/workflows/build-exe-for-ssh-helper.yml
|
||||
with:
|
||||
targets: ${{ github.event_name == 'workflow_dispatch' && 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64' || 'node24-linux-arm64,node24-macos-arm64,node24-macos-x64' }}
|
||||
|
||||
# These native runtime carriers are post-merge checks; release keeps all targets.
|
||||
python-runtime:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
|
||||
@@ -494,6 +494,13 @@ jobs:
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
|
||||
ssh-helper:
|
||||
if: github.event_name == 'pull_request'
|
||||
name: ssh helper / Linux x64
|
||||
uses: ./.github/workflows/build-exe-for-ssh-helper.yml
|
||||
with:
|
||||
targets: node24-linux-x64
|
||||
|
||||
# Native builds and process tests block pull requests independently of coverage.
|
||||
# Observational checks reuse the successful build and remain non-blocking.
|
||||
# Dependabot PRs are excluded from the self-hosted pool and stay queued for
|
||||
@@ -714,7 +721,7 @@ jobs:
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'ubuntu-latest' }}
|
||||
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests]
|
||||
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, ssh-helper, windows-build, windows-native-tests]
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Publish SSH helper
|
||||
|
||||
# Select an existing dsh-v<version> tag when dispatching. This never publishes npm packages.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: publish-ssh-helper-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.tag.outputs.version }}
|
||||
commit: ${{ steps.tag.outputs.commit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify selected release tag
|
||||
id: tag
|
||||
run: |
|
||||
node --input-type=module <<'JS'
|
||||
import { readFileSync, appendFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
const {version} = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
if (process.env.GITHUB_REF_TYPE !== 'tag' || process.env.GITHUB_REF_NAME !== `dsh-v${version}`) throw new Error('Dispatch from the matching dsh-v<version> tag');
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], {encoding:'utf8'}).trim();
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ncommit=${commit}\n`);
|
||||
JS
|
||||
build:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-exe-for-ssh-helper.yml
|
||||
with:
|
||||
release: true
|
||||
publish:
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: ssh-helper-*
|
||||
path: dist-exe/ssh-helper
|
||||
merge-multiple: true
|
||||
- name: Require complete evidence from the selected source commit
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
|
||||
SOURCE_COMMIT: ${{ needs.validate.outputs.commit }}
|
||||
run: pnpm exec tsx scripts/ssh-helper/release.ts --directory=dist-exe/ssh-helper --version="$RELEASE_VERSION" --commit="$SOURCE_COMMIT"
|
||||
- name: Attach the verified archives to the GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
gh release create "$RELEASE_TAG" --verify-tag --draft --title "$RELEASE_TAG" --notes 'SSH helper executables for Linux and macOS.'
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" dist-exe/ssh-helper/*.tar.gz dist-exe/ssh-helper/SHA256SUMS
|
||||
@@ -30,7 +30,7 @@ it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-
|
||||
await mkdir(cwd)
|
||||
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
|
||||
vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
|
||||
const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd })
|
||||
const runtime = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd })
|
||||
const result = await runtime.run(runtime.resolve({
|
||||
// Bound startup failure before the outer test deadline, leaving time for managed cleanup.
|
||||
timeoutMs: 30_000,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: f86f02fbec949c0212c3c038f47d1fdf50d239fe
|
||||
config-catalog.zh.md: d56d5bdef41e76ce7a41e3d582a1a8afde18766b
|
||||
config-catalog.md: 33bddf183cd14f7aca92b5a21ae5a9141f6a40b7
|
||||
config-catalog.zh.md: a9e8f61b4dfba631e661be3d556161de579ccb26
|
||||
+35
-14
@@ -2194,7 +2194,9 @@ Requires: `fs` · `subprocess` · `sandbox` · `sandboxPolicy`
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment-varying runtime bounds and launch choices. */
|
||||
export interface Config extends LaunchConfig {
|
||||
export interface Config {
|
||||
/** Worker invocation in the subprocess world; omitted selects the local carrier. */
|
||||
launch?: LaunchConfig
|
||||
/** Default elapsed deadline, including nested tool and approval waits. */
|
||||
timeoutMs?: number
|
||||
/** Maximum numeric elapsed budget accepted by resolve. */
|
||||
@@ -2211,16 +2213,23 @@ export interface Config extends LaunchConfig {
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
|
||||
export interface LaunchConfig {
|
||||
/** Executable in the subprocess world; defaults to the current Node executable. */
|
||||
nodeExecutable?: string
|
||||
/** Absolute preinstalled built bootstrap in the execution world. */
|
||||
/** Deployment-owned worker invocation in the subprocess execution world. */
|
||||
export type LaunchConfig = {
|
||||
/** Start a separately installed Node executable and JavaScript bootstrap. */
|
||||
kind: 'node-script'
|
||||
/** Executable name or path resolved by the subprocess provider. */
|
||||
executable: string
|
||||
/** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */
|
||||
bootstrapPath?: string
|
||||
} | {
|
||||
/** Start the private PTC worker embedded in a packaged executable. */
|
||||
kind: 'embedded'
|
||||
/** Packaged executable name or path resolved by the subprocess provider. */
|
||||
executable: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:27`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-pwsh-local"></a>
|
||||
|
||||
@@ -2799,18 +2808,14 @@ Source: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-
|
||||
export interface Config {
|
||||
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
|
||||
host: string
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Explicit script or self-contained executable invocation. */
|
||||
launch: HelperLaunch
|
||||
/** Absolute path to the installed, bundled helper entry. */
|
||||
helper: string
|
||||
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
|
||||
helperHash: string
|
||||
/** Absolute remote default workspace. */
|
||||
workspace: string
|
||||
/** Optional preinstalled built PTC entry, paired with its expected digest. */
|
||||
bootstrapPath?: string
|
||||
/** SHA-256 of bootstrapPath; both fields must be supplied together. */
|
||||
bootstrapHash?: string
|
||||
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
|
||||
requestTimeoutMs?: number
|
||||
/** Maximum JSON payload bytes per helper request or response. */
|
||||
@@ -2820,9 +2825,24 @@ export interface Config {
|
||||
/** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
|
||||
leaseMs?: number
|
||||
}
|
||||
|
||||
/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
|
||||
export type HelperLaunch = {
|
||||
/** Run the installed script with a separately installed Node executable. */
|
||||
kind: 'node-script'
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Absolute remote PTC bootstrap; requires bootstrapHash. */
|
||||
bootstrapPath?: string
|
||||
/** Lowercase SHA-256 of bootstrapPath; requires that path. */
|
||||
bootstrapHash?: string
|
||||
} | {
|
||||
/** Run the helper executable with its embedded Node and PTC worker. */
|
||||
kind: 'executable'
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ssh/ssh/src/index.ts:17`](../packages/ssh/ssh/src/index.ts)
|
||||
Source: [`packages/ssh/ssh/src/index.ts:33`](../packages/ssh/ssh/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-storage-domain"></a>
|
||||
|
||||
@@ -4248,6 +4268,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-session-snapshot` ([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-ssh-helper-runtime` ([`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent-in-process-driver` ([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
|
||||
|
||||
+35
-14
@@ -2196,7 +2196,9 @@ export interface Config {
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment-varying runtime bounds and launch choices. */
|
||||
export interface Config extends LaunchConfig {
|
||||
export interface Config {
|
||||
/** Worker invocation in the subprocess world; omitted selects the local carrier. */
|
||||
launch?: LaunchConfig
|
||||
/** Default elapsed deadline, including nested tool and approval waits. */
|
||||
timeoutMs?: number
|
||||
/** Maximum numeric elapsed budget accepted by resolve. */
|
||||
@@ -2213,16 +2215,23 @@ export interface Config extends LaunchConfig {
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
|
||||
export interface LaunchConfig {
|
||||
/** Executable in the subprocess world; defaults to the current Node executable. */
|
||||
nodeExecutable?: string
|
||||
/** Absolute preinstalled built bootstrap in the execution world. */
|
||||
/** Deployment-owned worker invocation in the subprocess execution world. */
|
||||
export type LaunchConfig = {
|
||||
/** Start a separately installed Node executable and JavaScript bootstrap. */
|
||||
kind: 'node-script'
|
||||
/** Executable name or path resolved by the subprocess provider. */
|
||||
executable: string
|
||||
/** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */
|
||||
bootstrapPath?: string
|
||||
} | {
|
||||
/** Start the private PTC worker embedded in a packaged executable. */
|
||||
kind: 'embedded'
|
||||
/** Packaged executable name or path resolved by the subprocess provider. */
|
||||
executable: string
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:27`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-pwsh-local"></a>
|
||||
|
||||
@@ -2801,18 +2810,14 @@ export interface Config {
|
||||
export interface Config {
|
||||
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
|
||||
host: string
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Explicit script or self-contained executable invocation. */
|
||||
launch: HelperLaunch
|
||||
/** Absolute path to the installed, bundled helper entry. */
|
||||
helper: string
|
||||
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
|
||||
helperHash: string
|
||||
/** Absolute remote default workspace. */
|
||||
workspace: string
|
||||
/** Optional preinstalled built PTC entry, paired with its expected digest. */
|
||||
bootstrapPath?: string
|
||||
/** SHA-256 of bootstrapPath; both fields must be supplied together. */
|
||||
bootstrapHash?: string
|
||||
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
|
||||
requestTimeoutMs?: number
|
||||
/** Maximum JSON payload bytes per helper request or response. */
|
||||
@@ -2822,9 +2827,24 @@ export interface Config {
|
||||
/** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
|
||||
leaseMs?: number
|
||||
}
|
||||
|
||||
/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
|
||||
export type HelperLaunch = {
|
||||
/** Run the installed script with a separately installed Node executable. */
|
||||
kind: 'node-script'
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Absolute remote PTC bootstrap; requires bootstrapHash. */
|
||||
bootstrapPath?: string
|
||||
/** Lowercase SHA-256 of bootstrapPath; requires that path. */
|
||||
bootstrapHash?: string
|
||||
} | {
|
||||
/** Run the helper executable with its embedded Node and PTC worker. */
|
||||
kind: 'executable'
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/ssh/ssh/src/index.ts:17`](../packages/ssh/ssh/src/index.ts)
|
||||
来源: [`packages/ssh/ssh/src/index.ts:33`](../packages/ssh/ssh/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-storage-domain"></a>
|
||||
|
||||
@@ -4250,6 +4270,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-session-snapshot`([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-ssh-helper-runtime`([`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent-in-process-driver`([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout`([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e
|
||||
module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e
|
||||
module-graph.md: 27c593699e5ca7c1d0a1da2610e7a08602c4b8b5
|
||||
module-graph.zh.md: 82b18fa42d5d63fd131e8ca6c966c3dfabdd1c24
|
||||
@@ -376,6 +376,7 @@ flowchart TD
|
||||
pkg_fs_ssh["fs-ssh"]
|
||||
pkg_sandbox_ssh["sandbox-ssh"]
|
||||
pkg_ssh["ssh"]
|
||||
pkg_ssh_helper_runtime["ssh-helper-runtime"]
|
||||
pkg_subprocess_ssh["subprocess-ssh"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
@@ -957,6 +958,7 @@ flowchart TD
|
||||
pkg_ssh --> pkg_session_projection
|
||||
pkg_ssh --> pkg_subprocess
|
||||
pkg_ssh --> pkg_subprocess_local
|
||||
pkg_ssh --> pkg_util_values
|
||||
pkg_tool_terminal --> pkg_agent
|
||||
pkg_tool_terminal --> pkg_jobs
|
||||
pkg_tool_terminal --> pkg_llm
|
||||
@@ -1446,6 +1448,7 @@ flowchart TD
|
||||
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
|
||||
| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — |
|
||||
| [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — |
|
||||
| [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | — |
|
||||
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
|
||||
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
|
||||
@@ -1598,7 +1601,7 @@ flowchart TD
|
||||
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
|
||||
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) |
|
||||
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
|
||||
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -378,6 +378,7 @@ flowchart TD
|
||||
pkg_fs_ssh["fs-ssh"]
|
||||
pkg_sandbox_ssh["sandbox-ssh"]
|
||||
pkg_ssh["ssh"]
|
||||
pkg_ssh_helper_runtime["ssh-helper-runtime"]
|
||||
pkg_subprocess_ssh["subprocess-ssh"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
@@ -959,6 +960,7 @@ flowchart TD
|
||||
pkg_ssh --> pkg_session_projection
|
||||
pkg_ssh --> pkg_subprocess
|
||||
pkg_ssh --> pkg_subprocess_local
|
||||
pkg_ssh --> pkg_util_values
|
||||
pkg_tool_terminal --> pkg_agent
|
||||
pkg_tool_terminal --> pkg_jobs
|
||||
pkg_tool_terminal --> pkg_llm
|
||||
@@ -1448,6 +1450,7 @@ flowchart TD
|
||||
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
|
||||
| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — |
|
||||
| [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — |
|
||||
| [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | — |
|
||||
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
|
||||
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
|
||||
@@ -1600,7 +1603,7 @@ flowchart TD
|
||||
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
|
||||
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) |
|
||||
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
|
||||
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/ssh.md
|
||||
ssh.md: 04480e6ec397490f2e61851c956b9d318c1ef54f
|
||||
ssh.zh.md: 32d7c8b6c2936240b82b02de3a29adeda724171f
|
||||
ssh.md: 440e42c26d8505bc2d961bc8f49b5a7798007c41
|
||||
ssh.zh.md: 862d8402a163e0688789a02a8a3b235e23ed3794
|
||||
+22
-11
@@ -8,7 +8,7 @@ The [SSH provider family](../../packages/ssh/README.md) supplies one remote file
|
||||
|
||||
Filesystem identities, executable lookup, process cwd, sandbox workspace roots and language-server file URLs refer to the SSH host. Providers canonicalize paths where the files exist, preserving filesystem interpretation of `symlink/..`. The policy resolver carries absolute execution-world spelling without trying to resolve remote paths on the Harness host.
|
||||
|
||||
`processPath()` supplies a path usable by the paired subprocess provider. `processPathFromHostPath()` remains unavailable for SSH; installing a remote artifact does not make an arbitrary host path portable. [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.md) therefore takes an explicitly installed, digest-verified remote bootstrap.
|
||||
`processPath()` supplies a path usable by the paired subprocess provider. `processPathFromHostPath()` remains unavailable for SSH; installing a remote artifact does not make an arbitrary host path portable. [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.md) therefore takes the verified remote launch configuration: an installed script bootstrap or the [helper executable’s embedded worker](../../packages/ssh/ssh-helper-runtime/README.md).
|
||||
|
||||
## Transport and trust
|
||||
|
||||
@@ -30,23 +30,36 @@ See the [decision record](../../.agents/notes/implemented/architecture/2026-09-1
|
||||
|
||||
## Connection API
|
||||
|
||||
```ts type-equiv
|
||||
/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
|
||||
type HelperLaunch = {
|
||||
/** Run the installed script with a separately installed Node executable. */
|
||||
kind: 'node-script'
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Absolute remote PTC bootstrap; requires bootstrapHash. */
|
||||
bootstrapPath?: string
|
||||
/** Lowercase SHA-256 of bootstrapPath; requires that path. */
|
||||
bootstrapHash?: string
|
||||
} | {
|
||||
/** Run the helper executable with its embedded Node and PTC worker. */
|
||||
kind: 'executable'
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
|
||||
interface Config {
|
||||
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
|
||||
host: string
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Explicit script or self-contained executable invocation. */
|
||||
launch: HelperLaunch
|
||||
/** Absolute path to the installed, bundled helper entry. */
|
||||
helper: string
|
||||
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
|
||||
helperHash: string
|
||||
/** Absolute remote default workspace. */
|
||||
workspace: string
|
||||
/** Optional preinstalled built PTC entry, paired with its expected digest. */
|
||||
bootstrapPath?: string
|
||||
/** SHA-256 of bootstrapPath; both fields must be supplied together. */
|
||||
bootstrapHash?: string
|
||||
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
|
||||
requestTimeoutMs?: number
|
||||
/** Maximum JSON payload bytes per helper request or response. */
|
||||
@@ -67,10 +80,8 @@ declare class SshConnection extends Service {
|
||||
constructor(ctx: Context, config: Config);
|
||||
/** Hold plugin readiness until the remote identity and helper digest are verified. */
|
||||
async [Service.init](): Promise<void>;
|
||||
/** Verified remote Node executable for the paired PTC runtime. */
|
||||
get nodeExecutable(): string;
|
||||
/** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
|
||||
get bootstrapPath(): string;
|
||||
/** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */
|
||||
get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string };
|
||||
/**
|
||||
* Send a helper operation; cancellation never replays an ambiguous mutation.
|
||||
* @param method - the private helper operation.
|
||||
|
||||
+22
-11
@@ -8,7 +8,7 @@
|
||||
|
||||
文件系统身份、可执行文件查找、进程 cwd、沙箱工作区根目录及语言服务器文件 URL 都指向 SSH 主机。提供方在文件实际存在的位置规范化路径,保留文件系统对 `symlink/..` 的解释。策略解析器保留执行环境中的绝对路径写法,不尝试在 Harness 主机上解析远端路径。
|
||||
|
||||
`processPath()` 提供配套子进程提供方可用的路径。SSH 的 `processPathFromHostPath()` 仍不可用;安装远端产物不意味着任意主机路径可移植。因此 [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md) 使用显式安装并经过摘要验证的远端引导程序。
|
||||
`processPath()` 提供配套子进程提供方可用的路径。SSH 的 `processPathFromHostPath()` 仍不可用;安装远端产物不意味着任意主机路径可移植。因此 [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md) 使用验证后的远端启动配置:已安装的脚本引导程序,或 [helper 可执行文件内嵌的 worker](../../packages/ssh/ssh-helper-runtime/README.zh.md)。
|
||||
|
||||
## 传输与信任
|
||||
|
||||
@@ -30,23 +30,36 @@ headless 通过已挂载的文件系统提供方记录和检查 Session cwd。
|
||||
|
||||
## 连接 API
|
||||
|
||||
```ts type-equiv
|
||||
/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
|
||||
type HelperLaunch = {
|
||||
/** Run the installed script with a separately installed Node executable. */
|
||||
kind: 'node-script'
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Absolute remote PTC bootstrap; requires bootstrapHash. */
|
||||
bootstrapPath?: string
|
||||
/** Lowercase SHA-256 of bootstrapPath; requires that path. */
|
||||
bootstrapHash?: string
|
||||
} | {
|
||||
/** Run the helper executable with its embedded Node and PTC worker. */
|
||||
kind: 'executable'
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
|
||||
interface Config {
|
||||
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
|
||||
host: string
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Explicit script or self-contained executable invocation. */
|
||||
launch: HelperLaunch
|
||||
/** Absolute path to the installed, bundled helper entry. */
|
||||
helper: string
|
||||
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
|
||||
helperHash: string
|
||||
/** Absolute remote default workspace. */
|
||||
workspace: string
|
||||
/** Optional preinstalled built PTC entry, paired with its expected digest. */
|
||||
bootstrapPath?: string
|
||||
/** SHA-256 of bootstrapPath; both fields must be supplied together. */
|
||||
bootstrapHash?: string
|
||||
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
|
||||
requestTimeoutMs?: number
|
||||
/** Maximum JSON payload bytes per helper request or response. */
|
||||
@@ -67,10 +80,8 @@ declare class SshConnection extends Service {
|
||||
constructor(ctx: Context, config: Config);
|
||||
/** Hold plugin readiness until the remote identity and helper digest are verified. */
|
||||
async [Service.init](): Promise<void>;
|
||||
/** Verified remote Node executable for the paired PTC runtime. */
|
||||
get nodeExecutable(): string;
|
||||
/** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
|
||||
get bootstrapPath(): string;
|
||||
/** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */
|
||||
get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string };
|
||||
/**
|
||||
* Send a helper operation; cancellation never replays an ambiguous mutation.
|
||||
* @param method - the private helper operation.
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@yao-pkg/pkg": "6.21.0",
|
||||
"@yarnpkg/cli-dist": "4.17.1",
|
||||
"zod": "^4.4.3",
|
||||
"eslint-plugin-sonarjs": "^4.1.0",
|
||||
"execa": "^10.0.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ptc-runtime/ptc-runtime-node/README.md
|
||||
README.md: 5196d6982c3bf8edda3b8b825e41db83f6994141
|
||||
README.zh.md: 16c9f8fc426b1f2d28eaa039ba259f08088664a8
|
||||
README.md: 398d633ad5719c350abe9df09f5720eb49bf7798
|
||||
README.zh.md: b4b9f24a840b77b84264c19cc99bdc1cd72bfa2c
|
||||
@@ -52,8 +52,7 @@ Configure the provider row after its required services are available:
|
||||
| `maxMessageBytes` | `134,217,728` | Limit for a control frame, outstanding argument bytes and queued control writes |
|
||||
| `maxPendingCalls` | `128` | Maximum simultaneous host binding calls |
|
||||
| `graceMs` | `3,000` | Managed termination and output-drain grace |
|
||||
| `nodeExecutable` | Current Node executable | Executable resolved in the subprocess execution world |
|
||||
| `bootstrapPath` | Package bootstrap | Optional absolute path to a preinstalled built bootstrap in that world |
|
||||
| `launch` | Local carrier | Explicit `{ kind: "node-script", executable, bootstrapPath? }` or `{ kind: "embedded", executable }` in the subprocess world |
|
||||
|
||||
The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-ptc-runtime-node) defines accepted config fields. `resolve(request)` supplies cwd, the numeric or null deadline choice and the execution policy; `run(spec)` accepts those resolved inputs and does not fill missing values.
|
||||
|
||||
@@ -91,7 +90,7 @@ Length-framed JSON travels separately from stdout/stderr. The host bounds frames
|
||||
|
||||
### Source and built bootstraps
|
||||
|
||||
Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built execution uses the packaged `process.js` entry. An execution world that cannot map the host bootstrap requires a preinstalled compatible `bootstrapPath`; a host path is never assumed to name the same remote file.
|
||||
Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built script execution uses `process.js`; a remote script deployment supplies `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -52,8 +52,7 @@ kind: "package-reference"
|
||||
| `maxMessageBytes` | `134,217,728` | 控制帧、未完成参数字节和排队控制写入的上限 |
|
||||
| `maxPendingCalls` | `128` | 同时进行的 Host 绑定调用数量上限 |
|
||||
| `graceMs` | `3,000` | 受管终止与输出排空宽限时间 |
|
||||
| `nodeExecutable` | 当前 Node 可执行文件 | 在子进程执行世界中解析的可执行文件 |
|
||||
| `bootstrapPath` | 包内 bootstrap | 该执行世界中预先安装的构建后 bootstrap 的可选绝对路径 |
|
||||
| `launch` | 本地载体 | 子进程环境中显式的 `{ kind: "node-script", executable, bootstrapPath? }` 或 `{ kind: "embedded", executable }` |
|
||||
|
||||
[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-ptc-runtime-node)定义可接受的配置字段。`resolve(request)` 补全 cwd、数值或 null 截止选择与执行策略;`run(spec)` 接受这些已解析输入,不补缺省值。
|
||||
|
||||
@@ -91,7 +90,7 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与
|
||||
|
||||
### 源代码与构建后 bootstrap
|
||||
|
||||
源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后执行使用包内 `process.js` 入口。无法映射 Host bootstrap 的执行世界需要预先安装兼容的 `bootstrapPath`;不会假设 Host 路径对应同一个远程文件。
|
||||
源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后脚本使用 `process.js`,远端脚本部署提供 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。
|
||||
|
||||
### 源码索引
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ import type {} from '@deepseek-ai/dsh-fs'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { validateBindings } from './bindings.ts'
|
||||
import { JsonChannel } from './channel.ts'
|
||||
import { bootstrapArgs } from './launch.ts'
|
||||
import { bootstrapArgs, resolveLaunch } from './launch.ts'
|
||||
import type { LaunchConfig } from './launch.ts'
|
||||
export type { LaunchConfig } from './launch.ts'
|
||||
import { OutputLedger } from './output-ledger.ts'
|
||||
import { drainOutput } from './output-stream.ts'
|
||||
import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
|
||||
@@ -23,7 +24,9 @@ import { decodePtcJsonWire, encodePtcJsonWire } from './json-wire.ts'
|
||||
import type { ProgramBootData } from './protocol.ts'
|
||||
|
||||
/** Deployment-varying runtime bounds and launch choices. */
|
||||
export interface Config extends LaunchConfig {
|
||||
export interface Config {
|
||||
/** Worker invocation in the subprocess world; omitted selects the local carrier. */
|
||||
launch?: LaunchConfig
|
||||
/** Default elapsed deadline, including nested tool and approval waits. */
|
||||
timeoutMs?: number
|
||||
/** Maximum numeric elapsed budget accepted by resolve. */
|
||||
@@ -40,7 +43,7 @@ export interface Config extends LaunchConfig {
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
|
||||
type ResolvedConfig = Required<Config>
|
||||
interface LiveRun { controller: AbortController; finished: Promise<void> }
|
||||
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
|
||||
const STRIP_SUFFIX = '\n}'
|
||||
@@ -59,8 +62,10 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
maxMessageBytes: z.number().default(134_217_728),
|
||||
maxPendingCalls: z.number().default(128),
|
||||
graceMs: z.number().default(3_000),
|
||||
nodeExecutable: z.string(),
|
||||
bootstrapPath: z.string(),
|
||||
launch: z.union([
|
||||
z.object({ kind: z.const('node-script').required(), executable: z.string().required(), bootstrapPath: z.string() }),
|
||||
z.object({ kind: z.const('embedded').required(), executable: z.string().required() }),
|
||||
]),
|
||||
})
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'process'
|
||||
@@ -73,7 +78,7 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
|
||||
this.config = { ...config, launch: resolveLaunch(config.launch) } as ResolvedConfig
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`ptc-runtime-node: ${key} must be positive and finite`)
|
||||
}
|
||||
@@ -84,8 +89,6 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
if (!Number.isSafeInteger(this.config.maxMessageBytes) || this.config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
|
||||
if (!Number.isSafeInteger(this.config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer')
|
||||
if (!Number.isSafeInteger(this.config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer')
|
||||
if (this.config.nodeExecutable.length === 0) throw new Error('ptc-runtime-node: nodeExecutable must be non-empty')
|
||||
if (this.config.bootstrapPath !== undefined && !isAbsolute(this.config.bootstrapPath)) throw new Error('ptc-runtime-node: bootstrapPath must be absolute')
|
||||
ctx.effect(() => async () => {
|
||||
this.disposed = true
|
||||
const active = [...this.live]
|
||||
@@ -214,13 +217,14 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
})),
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
}
|
||||
const executable = await this.ctx.subprocess.resolveExecutable(this.config.nodeExecutable, undefined, signal)
|
||||
const executable = await this.ctx.subprocess.resolveExecutable(this.config.launch.executable, undefined, signal)
|
||||
// Abort callbacks can settle execution before or during an awaited operation.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (settled) return await result.promise
|
||||
const packaged = 'pkg' in process && this.config.bootstrapPath === undefined
|
||||
const packaged = this.config.launch.kind === 'embedded'
|
||||
const heapFlag = `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`
|
||||
const argv = [executable, ...packaged ? [] : [heapFlag], ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
|
||||
const argv = [executable, ...packaged ? [] : [heapFlag],
|
||||
...bootstrapArgs(this.ctx.fs, this.config.launch, this.config.maxMessageBytes)]
|
||||
confined = policy.mode === 'danger-full-access' ? undefined : await this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement.
|
||||
if (settled) return await result.promise
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
/** Select source or built bootstrap assets in the mounted execution world. */
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
|
||||
export interface LaunchConfig {
|
||||
/** Executable in the subprocess world; defaults to the current Node executable. */
|
||||
nodeExecutable?: string
|
||||
/** Absolute preinstalled built bootstrap in the execution world. */
|
||||
/** Deployment-owned worker invocation in the subprocess execution world. */
|
||||
export type LaunchConfig = {
|
||||
/** Start a separately installed Node executable and JavaScript bootstrap. */
|
||||
kind: 'node-script'
|
||||
/** Executable name or path resolved by the subprocess provider. */
|
||||
executable: string
|
||||
/** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */
|
||||
bootstrapPath?: string
|
||||
} | {
|
||||
/** Start the private PTC worker embedded in a packaged executable. */
|
||||
kind: 'embedded'
|
||||
/** Packaged executable name or path resolved by the subprocess provider. */
|
||||
executable: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the local carrier default or validate an explicit execution-world invocation.
|
||||
* @param config - explicit remote or local worker configuration; omitted selects this carrier.
|
||||
* @returns the worker invocation without guessing from the execution world's paths.
|
||||
*/
|
||||
export function resolveLaunch(config?: LaunchConfig): LaunchConfig {
|
||||
const launch: LaunchConfig = config ?? { kind: 'pkg' in process ? 'embedded' : 'node-script', executable: process.execPath }
|
||||
if (launch.executable.length === 0) throw new Error('ptc-runtime-node: launch.executable must be non-empty')
|
||||
if (launch.kind === 'node-script' && launch.bootstrapPath !== undefined && !isAbsolute(launch.bootstrapPath)) {
|
||||
throw new Error('ptc-runtime-node: launch.bootstrapPath must be absolute')
|
||||
}
|
||||
return launch
|
||||
}
|
||||
|
||||
/**
|
||||
* Select explicit arguments without inheriting host loader or inspector flags.
|
||||
* @param fs - Filesystem mapping host bootstrap assets into the process world.
|
||||
* @param config - Optional preinstalled built bootstrap.
|
||||
* @param config - resolved worker invocation.
|
||||
* @param maxMessageBytes - Validated frame and queued-write limit.
|
||||
* @returns Arguments following the resolved Node executable.
|
||||
*/
|
||||
export function bootstrapArgs(fs: FileSystem, config: LaunchConfig, maxMessageBytes: number): string[] {
|
||||
if (config.bootstrapPath !== undefined) return [config.bootstrapPath, String(maxMessageBytes)]
|
||||
if ('pkg' in process) return [String(maxMessageBytes)]
|
||||
export function bootstrapArgs(fs: Pick<FileSystem, 'processPathFromHostPath'>, config: LaunchConfig, maxMessageBytes: number): string[] {
|
||||
switch (config.kind) {
|
||||
case 'embedded': return [String(maxMessageBytes)]
|
||||
case 'node-script': return scriptBootstrapArgs(fs, config.bootstrapPath, maxMessageBytes)
|
||||
/* v8 ignore next -- Callers supply the resolved, closed LaunchConfig union. */
|
||||
default: return assertNever(config)
|
||||
}
|
||||
}
|
||||
|
||||
function scriptBootstrapArgs(fs: Pick<FileSystem, 'processPathFromHostPath'>, bootstrapPath: string | undefined, maxMessageBytes: number): string[] {
|
||||
if (bootstrapPath !== undefined) return [bootstrapPath, String(maxMessageBytes)]
|
||||
const mapped = (path: string): string => {
|
||||
const result = fs.processPathFromHostPath(path)
|
||||
if (result === undefined) throw new Error(`PTC runtime bootstrap is unavailable in the subprocess execution world: ${path}`)
|
||||
|
||||
@@ -117,8 +117,8 @@ describe('Node runtime host failures', () => {
|
||||
[{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'],
|
||||
[{ maxPendingCalls: 1.5 }, 'maxPendingCalls'],
|
||||
[{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'],
|
||||
[{ nodeExecutable: '' }, 'nodeExecutable'],
|
||||
[{ bootstrapPath: 'relative-bootstrap.js' }, 'bootstrapPath'],
|
||||
[{ launch: { kind: 'node-script', executable: '' } }, 'launch.executable'],
|
||||
[{ launch: { kind: 'node-script', executable: process.execPath, bootstrapPath: 'relative-bootstrap.js' } }, 'bootstrapPath'],
|
||||
])('rejects deployment configuration %j', async (config, field) => {
|
||||
await expect(mountRuntime(new Context(), config)).rejects.toThrow(field)
|
||||
})
|
||||
@@ -496,22 +496,15 @@ describe('Node runtime host failures', () => {
|
||||
expect(env.DSH_TEST_RUNTIME_SECRET).toBeUndefined()
|
||||
})
|
||||
|
||||
it('selects the private packaged bootstrap without leaking ambient environment', async () => {
|
||||
const h = await setup()
|
||||
it('selects the remote embedded bootstrap without leaking ambient environment', async () => {
|
||||
const h = await setup({ launch: { kind: 'embedded', executable: process.execPath } })
|
||||
h.onBoot(() => { h.emit({ type: 'done' }) })
|
||||
const prior = Object.getOwnPropertyDescriptor(process, 'pkg')
|
||||
try {
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
expect((await h.start()).error).toBeUndefined()
|
||||
const spec = h.spawn.mock.calls[0]?.[0]
|
||||
expect(spec?.env?.DSH_PTC_RUNTIME_NODE).toBe('1')
|
||||
expect(Object.hasOwn(spec?.env ?? {}, 'PATH')).toBe(false)
|
||||
expect(spec?.argv).toEqual([process.execPath, '134217728'])
|
||||
expect(Object.fromEntries(Object.entries(spec?.env ?? {}).filter(([, value]) => value !== undefined)))
|
||||
.toEqual({ DSH_PTC_RUNTIME_NODE: '1', NODE_OPTIONS: '--max-old-space-size=512' })
|
||||
} finally {
|
||||
if (prior === undefined) Reflect.deleteProperty(process, 'pkg')
|
||||
else Object.defineProperty(process, 'pkg', prior)
|
||||
}
|
||||
expect((await h.start()).error).toBeUndefined()
|
||||
const spec = h.spawn.mock.calls[0]?.[0]
|
||||
expect(spec?.env?.DSH_PTC_RUNTIME_NODE).toBe('1')
|
||||
expect(Object.hasOwn(spec?.env ?? {}, 'PATH')).toBe(false)
|
||||
expect(spec?.argv).toEqual([process.execPath, '134217728'])
|
||||
expect(Object.fromEntries(Object.entries(spec?.env ?? {}).filter(([, value]) => value !== undefined)))
|
||||
.toEqual({ DSH_PTC_RUNTIME_NODE: '1', NODE_OPTIONS: '--max-old-space-size=512' })
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,35 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { bootstrapArgs } from '../src/launch.ts'
|
||||
import { bootstrapArgs, resolveLaunch } from '../src/launch.ts'
|
||||
|
||||
const unmapped = { processPathFromHostPath: () => undefined }
|
||||
const noMapping = { processPathFromHostPath: () => { throw new Error('must not map a remote worker') } }
|
||||
|
||||
it('uses an explicit installed bootstrap without pretending it maps the host file', () => {
|
||||
const fs = { processPathFromHostPath: () => { throw new Error('must not map') } } as unknown as FileSystem
|
||||
expect(bootstrapArgs(fs, { bootstrapPath: '/remote/process.js' }, 2048)).toEqual(['/remote/process.js', '2048'])
|
||||
expect(bootstrapArgs(noMapping, { kind: 'node-script', executable: '/remote/node', bootstrapPath: '/remote/process.js' }, 2048))
|
||||
.toEqual(['/remote/process.js', '2048'])
|
||||
})
|
||||
|
||||
it('fails when the execution world cannot read the source bootstrap', () => {
|
||||
const fs = { processPathFromHostPath: () => undefined } as unknown as FileSystem
|
||||
expect(() => bootstrapArgs(fs, {}, 2048)).toThrow('unavailable in the subprocess execution world')
|
||||
expect(() => bootstrapArgs(unmapped, { kind: 'node-script', executable: process.execPath }, 2048))
|
||||
.toThrow('unavailable in the subprocess execution world')
|
||||
})
|
||||
|
||||
it('selects the private packaged bootstrap through the existing executable', () => {
|
||||
it('uses a remote embedded worker when the client runs ordinary Node', () => {
|
||||
const launch = resolveLaunch({ kind: 'embedded', executable: '/remote/dsh-ssh-helper' })
|
||||
expect(launch).toEqual({ kind: 'embedded', executable: '/remote/dsh-ssh-helper' })
|
||||
expect(bootstrapArgs(noMapping, launch, 2048)).toEqual(['2048'])
|
||||
})
|
||||
|
||||
it('resolves the local carrier once while preserving an explicit remote script choice', () => {
|
||||
const prior = Object.getOwnPropertyDescriptor(process, 'pkg')
|
||||
try {
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
const fs = { processPathFromHostPath: () => { throw new Error('pkg does not expose a host bootstrap file') } } as unknown as FileSystem
|
||||
expect(bootstrapArgs(fs, {}, 2048)).toEqual(['2048'])
|
||||
expect(resolveLaunch()).toEqual({ kind: 'embedded', executable: process.execPath })
|
||||
const remote = resolveLaunch({ kind: 'node-script', executable: '/remote/node', bootstrapPath: '/remote/process.js' })
|
||||
expect(bootstrapArgs(noMapping, remote, 2048)).toEqual(['/remote/process.js', '2048'])
|
||||
} finally {
|
||||
if (prior === undefined) Reflect.deleteProperty(process, 'pkg')
|
||||
else Object.defineProperty(process, 'pkg', prior)
|
||||
}
|
||||
expect(resolveLaunch()).toEqual({ kind: 'node-script', executable: process.execPath })
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ssh/README.md
|
||||
README.md: 905c17a8e59cd95ded0fc8b299eac83f8530004e
|
||||
README.zh.md: 9833c8c087070a3ce62699a0e4c88f998491f79d
|
||||
README.md: ff7db269993f75d6137ebfdb86edbfd2f3b51223
|
||||
README.zh.md: 0a8944680ea26acdb7923bb68e63e238328852f7
|
||||
@@ -23,6 +23,7 @@ This family runs files, ordinary processes, terminals and sandbox enforcement on
|
||||
| Package | Responsibility | Service |
|
||||
|---|---|---|
|
||||
| [`ssh`](ssh/README.md) | Connection, helper identity and transport lifecycle | `ctx.ssh` |
|
||||
| [`ssh-helper-runtime`](ssh-helper-runtime/README.md) | Private Node-free executable distribution | — |
|
||||
| [`fs-ssh`](fs-ssh/README.md) | Remote file identity, reads and guarded atomic mutations | `ctx.fs` |
|
||||
| [`subprocess-ssh`](subprocess-ssh/README.md) | Executable lookup, processes, control streams and terminals | `ctx.subprocess` |
|
||||
| [`sandbox-ssh`](sandbox-ssh/README.md) | Remote file-effect confinement and enforcement facts | `ctx.sandbox` |
|
||||
|
||||
@@ -23,6 +23,7 @@ kind: "package-group"
|
||||
| 包 | 职责 | 服务 |
|
||||
|---|---|---|
|
||||
| [`ssh`](ssh/README.zh.md) | 连接、辅助程序身份及传输生命周期 | `ctx.ssh` |
|
||||
| [`ssh-helper-runtime`](ssh-helper-runtime/README.zh.md) | 无须安装 Node 的私有可执行文件发行包 | — |
|
||||
| [`fs-ssh`](fs-ssh/README.zh.md) | 远端文件身份、读取及带保护的原子修改 | `ctx.fs` |
|
||||
| [`subprocess-ssh`](subprocess-ssh/README.zh.md) | 可执行文件查找、进程、控制流及终端 | `ctx.subprocess` |
|
||||
| [`sandbox-ssh`](sandbox-ssh/README.zh.md) | 远端文件效果限制及执行信息 | `ctx.sandbox` |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ssh/ssh-helper-runtime/README.md
|
||||
README.md: 4039fd6bf4a8312459d282d06e2f25c2b8a49334
|
||||
README.zh.md: 13058ba86d8b227e0b78a9663fc12b12fae562d7
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
description: "Build and verify SSH helper executables with an embedded Node runtime for Linux and macOS."
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-ssh-helper-runtime
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Run the SSH helper on a remote machine without installing Node or npm packages. Each archive includes the executable, its native resources and integrity metadata. The private carrier supports managed processes, terminals, sandboxing and an embedded PTC worker through the existing SSH providers. Project commands still use the remote machine’s own development tools.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
### Build and installation
|
||||
|
||||
This private workspace produces release archives; it is not a profile plugin or an npm CLI. From a checkout with dependencies installed, build the current native platform:
|
||||
|
||||
```sh
|
||||
pnpm exec tsx scripts/build-exe-for-ssh-helper.ts
|
||||
```
|
||||
|
||||
The builder requires Node 24, a C/C++ compiler and the development headers from that Node installation. Linux additionally needs `musl-gcc`; its node-pty addon must meet the glibc baseline. The [native workflow](../../../.github/workflows/build-exe-for-ssh-helper.yml) prepares the compatible addon before packaging. `--target` selects one native target, `--skip-build` reuses prepared JS/native outputs, and `--dry-run` validates the target and dependency declarations without building.
|
||||
|
||||
| Target | Native archive | Checked deployment baseline |
|
||||
|---|---|---|
|
||||
| `node24-linux-x64` | `linux-x64` | glibc 2.28 |
|
||||
| `node24-linux-arm64` | `linux-arm64` | glibc 2.28 |
|
||||
| `node24-macos-x64` | `macos-x64` | macOS 14 deployment target |
|
||||
| `node24-macos-arm64` | `macos-arm64` | macOS 14 deployment target |
|
||||
|
||||
Outputs live under `dist-exe/ssh-helper/`. Each `dsh-ssh-helper-<version>-<platform>-<arch>.tar.gz` has a companion `.sha256` file. The archive contains `dsh-ssh-helper`, `native/system/`, licenses and `manifest.json`; macOS also includes `dsh-ssh-helper-spawn-helper`. The manifest records the source commit, whether the source tree was dirty, embedded Node version, protocol and every payload file’s digest and permissions.
|
||||
|
||||
Verify the archive checksum, then extract the whole directory into a versioned runtime location outside the project and replaced temporary mounts. Keep the native resources beside the executable. The installation can be read-only; the helper user needs a writable temporary/cache location. Configure [`dsh-ssh`](../ssh/README.md) with `launch: { kind: "executable" }`, the absolute `helper` path and the executable’s SHA-256 from the manifest. The client obtains the embedded PTC invocation through `ctx.ssh.ptcLaunch`.
|
||||
|
||||
### Verification and publication
|
||||
|
||||
The [artifact verifier](../../../scripts/verify-ssh-helper-artifact.ts) accepts `--archive` and an optional `--report` path. It validates and relocates the archive, makes installation files read-only, and runs two concurrent Loader compositions sharing a fresh native cache. Those compositions exercise files, processes, PTY, native flock and PTC through the SSH providers. macOS also runs a worker under a policy denying the checkout and host Node. `--sandbox=required` requires real confinement; Linux’s additional `--backend=landlock-run` check makes the bwrap probe unavailable and requires the packaged Landlock runner.
|
||||
|
||||
The [OpenSSH verifier](../../../scripts/verify-ssh-helper-ssh.ts) accepts a Linux `--archive`. It creates a temporary glibc 2.28 SSH server with no Node installation, mounts only the release and a disposable public key, and exercises the production connection. Its portability lane makes no kernel-confinement claim; the native host lane owns that check. Test identities, host keys, ports and workspaces are private to the run.
|
||||
|
||||
PR CI requires Linux x64; master CI covers Linux arm64 and both macOS architectures. The manually dispatched native workflow defaults to all four targets. The [publication workflow](../../../.github/workflows/publish-ssh-helper.yml) accepts the matching `dsh-v<version>` tag, requires complete same-commit evidence from clean builds, and attaches the tested archives and `SHA256SUMS` to that GitHub release. A missing release is created as a draft. GitHub Actions owns native platform and publication execution; deployment-target inspection alone does not establish execution on the oldest macOS version.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The carrier embeds its closed dependency set with the repository’s patched `pkg --sea` pipeline. Its entry dispatches the SSH helper, managed subprocess runner or PTC worker. Shared [deployment helpers](../../../scripts/executable-packaging.ts) also serve the Python SDK builder. Production deploy restores development installation state before invoking build tools, so pnpm’s automatic dependency check cannot prune them.
|
||||
|
||||
The operating system must execute the Landlock and PTY launchers from real files. A process-local resolution hook points the system package at its co-shipped native directory; node-pty uses its existing executable-relative spawn-helper convention. Binary inspection covers the embedded native modules before packaging and the external resources before publication.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [SSH connection](../ssh/README.md) — authentication, invocation and lifecycle.
|
||||
- [PTC Node runtime](../../ptc-runtime/ptc-runtime-node/README.md) — explicit worker launch and execution limits.
|
||||
- [SSH subsystem](../../../docs/subsystems/ssh.md) — remote execution coordinates.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as this private process carrier registers no model-facing tools or prompt content; its consumers own operation results.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The carrier adds no request-prefix content.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Windows and musl-based Linux are not build targets. Sandbox availability still depends on the target kernel and system tools.
|
||||
- Automatic download, deployment, updates and connection UI are separate from archive production.
|
||||
- The executable accepts private worker invocations, not arbitrary Node CLI arguments. Project `node` commands and nested JavaScript subprocesses need their own Node installation.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
No invariant companion is published. This process entry has no independently observed Cordis state; archive verification and the owning providers enforce its deployment and execution obligations.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
description: "构建并验证内嵌 Node 运行时的 Linux 与 macOS SSH helper 可执行文件。"
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-ssh-helper-runtime
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
远端机器无须安装 Node 或 npm 包即可运行 SSH helper。每个发行包都包含可执行文件、原生资源和完整性元数据。这个私有载体通过既有 SSH 提供方支持托管进程、终端、沙箱和内嵌 PTC worker。项目命令仍使用远端机器自身的开发工具。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [进一步探索](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与延后工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
### 构建与安装
|
||||
|
||||
这个私有工作区生成发行压缩包,不是配置插件或 npm CLI。在已安装依赖的源码目录中构建当前原生平台:
|
||||
|
||||
```sh
|
||||
pnpm exec tsx scripts/build-exe-for-ssh-helper.ts
|
||||
```
|
||||
|
||||
构建器需要 Node 24、C/C++ 编译器和该 Node 安装附带的开发头文件。Linux 还需要 `musl-gcc`,其 node-pty 扩展必须满足 glibc 基线。[原生构建工作流](../../../.github/workflows/build-exe-for-ssh-helper.yml) 在打包前准备兼容扩展。`--target` 选择一个原生目标,`--skip-build` 复用已准备的 JS/原生输出,`--dry-run` 校验目标与依赖声明而不构建。
|
||||
|
||||
| 目标 | 原生发行包 | 检查的部署基线 |
|
||||
|---|---|---|
|
||||
| `node24-linux-x64` | `linux-x64` | glibc 2.28 |
|
||||
| `node24-linux-arm64` | `linux-arm64` | glibc 2.28 |
|
||||
| `node24-macos-x64` | `macos-x64` | macOS 14 部署目标 |
|
||||
| `node24-macos-arm64` | `macos-arm64` | macOS 14 部署目标 |
|
||||
|
||||
产物位于 `dist-exe/ssh-helper/`。每个 `dsh-ssh-helper-<version>-<platform>-<arch>.tar.gz` 都有配套的 `.sha256` 文件。压缩包包含 `dsh-ssh-helper`、`native/system/`、许可证和 `manifest.json`,macOS 还包含 `dsh-ssh-helper-spawn-helper`。清单记录源码提交、源码树是否存在未提交修改、内嵌 Node 版本、协议,以及每个产物文件的摘要和权限。
|
||||
|
||||
验证压缩包摘要后,将整个目录解压到项目及被替换的临时挂载之外的版本化运行时位置。原生资源须与可执行文件保持相对位置。安装目录可以只读,但 helper 用户需要可写的临时/缓存位置。为 [`dsh-ssh`](../ssh/README.zh.md) 配置 `launch: { kind: "executable" }`、绝对 `helper` 路径,以及清单中可执行文件的 SHA-256。客户端通过 `ctx.ssh.ptcLaunch` 获取内嵌 PTC 启动方式。
|
||||
|
||||
### 验证与发布
|
||||
|
||||
[产物验证器](../../../scripts/verify-ssh-helper-artifact.ts) 接受 `--archive` 和可选的 `--report` 路径。它校验并移动解压后的发行包,将安装文件设为只读,再运行两个共享全新原生缓存的并发 Loader 组合。这些组合通过 SSH 提供方验证文件、进程、PTY、原生 flock 和 PTC。macOS 还在拒绝访问源码目录和宿主 Node 的策略下运行 worker。`--sandbox=required` 要求真实隔离;Linux 额外的 `--backend=landlock-run` 检查使 bwrap 探测不可用,并要求使用包内 Landlock 启动器。
|
||||
|
||||
[OpenSSH 验证器](../../../scripts/verify-ssh-helper-ssh.ts) 接受 Linux `--archive`。它创建未安装 Node 的临时 glibc 2.28 SSH 服务器,仅挂载发行包和一次性公钥,并验证生产连接。这个可移植性测试不声明内核隔离能力,该检查由原生主机测试负责。测试身份、主机密钥、端口和工作区均由本次运行独占。
|
||||
|
||||
PR CI 要求 Linux x64 通过,master CI 覆盖 Linux arm64 和两种 macOS 架构。手动触发的原生工作流默认构建全部四个目标。[发布工作流](../../../.github/workflows/publish-ssh-helper.yml) 接受匹配的 `dsh-v<version>` 标签,要求干净源码构建提供同一提交的完整验证证据,并将已测试的压缩包及 `SHA256SUMS` 附加到对应 GitHub Release。Release 不存在时创建草稿。原生平台与发布执行由 GitHub Actions 负责;仅检查部署目标不能证明已在最低 macOS 版本上运行。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
载体通过仓库带补丁的 `pkg --sea` 流程内嵌完整依赖集合。入口分派 SSH helper、托管子进程启动器或 PTC worker。共享的[部署函数](../../../scripts/executable-packaging.ts) 也用于 Python SDK 构建器。生产部署会在调用构建工具前恢复开发依赖安装状态,避免 pnpm 的自动依赖检查将它们删除。
|
||||
|
||||
操作系统必须从真实文件执行 Landlock 和 PTY 启动器。进程内的解析钩子将 system 包指向随包分发的原生目录;node-pty 使用其既有的相对可执行文件定位 spawn-helper 的约定。二进制检查在打包前覆盖内嵌原生模块,并在发布前覆盖外部资源。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
- [SSH 连接](../ssh/README.zh.md) — 鉴权、启动与生命周期。
|
||||
- [PTC Node 运行时](../../ptc-runtime/ptc-runtime-node/README.zh.md) — 显式 worker 启动与执行限制。
|
||||
- [SSH 子系统](../../../docs/subsystems/ssh.zh.md) — 远端执行坐标。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
None,因为这个私有进程载体不注册面向模型的工具或提示内容,操作结果由其消费方负责。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
载体不添加请求前缀内容。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 构建目标不包含 Windows 和基于 musl 的 Linux。沙箱可用性仍取决于目标内核和系统工具。
|
||||
- 自动下载、部署、更新和连接 UI 独立于发行包生产。
|
||||
- 可执行文件接受私有 worker 调用,不接受任意 Node CLI 参数。项目中的 `node` 命令和嵌套 JavaScript 子进程需要单独安装 Node。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文 — 点击展开</summary>
|
||||
|
||||
不发布 invariant 配套入口。这个进程入口没有独立观察的 Cordis 状态;产物校验与所属提供方负责其部署和执行义务。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-ssh-helper-runtime",
|
||||
"description": "Private executable carrier for the POSIX SSH helper and managed workers",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-ssh": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-ssh": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-ssh": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cosmokit": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-http-proxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-ptc-runtime-node": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-ssh": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@deepseek-ai/dsh-win32-process": "workspace:^",
|
||||
"@deepseek-ai/node-addon-system": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Private SEA dispatcher for the SSH helper and its managed process workers. */
|
||||
/* v8 ignore file -- the executable artifact suite runs this entry through pkg's SEA loader. */
|
||||
import { registerHooks } from 'node:module'
|
||||
import { statSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { isSea } from 'node:sea'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
/**
|
||||
* Dispatch the co-shipped helper or worker once from the private SEA bootstrap.
|
||||
* @returns after the selected process entry has completed its cleanup.
|
||||
* @throws when invoked outside SEA or when native resources cannot be loaded.
|
||||
*/
|
||||
export async function runSshHelperRuntime(): Promise<void> {
|
||||
if (!isSea()) throw new Error('SSH helper runtime must run from its packaged executable')
|
||||
|
||||
// The operating system executes Landlock outside the SEA virtual filesystem.
|
||||
const nativeManifest = join(dirname(process.execPath), 'native', 'system', 'package.json')
|
||||
statSync(nativeManifest)
|
||||
const platformPackage = `@deepseek-ai/node-addon-system-${process.platform}-${process.arch}/package.json`
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === platformPackage) return { url: pathToFileURL(nativeManifest).href, shortCircuit: true }
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
|
||||
const selection = process.env.DSH_SUBPROCESS_RUNNER
|
||||
Reflect.deleteProperty(process.env, 'DSH_SUBPROCESS_RUNNER')
|
||||
if (process.env.DSH_PTC_RUNTIME_NODE === '1') {
|
||||
Reflect.deleteProperty(process.env, 'DSH_PTC_RUNTIME_NODE')
|
||||
await import('@deepseek-ai/dsh-ptc-runtime-node/process')
|
||||
} else if (selection !== undefined) {
|
||||
const { runSelectedSubprocessRunner } = await import('@deepseek-ai/dsh-subprocess-local/runner')
|
||||
await runSelectedSubprocessRunner(selection)
|
||||
} else {
|
||||
await import('@deepseek-ai/dsh-ssh/helper')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Test-only transport to a real helper executable, retaining authenticated local stream sockets. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createConnection, type Socket } from 'node:net'
|
||||
import { once } from 'node:events'
|
||||
import assert from 'node:assert/strict'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import { SshRpcPeer, SSH_PROTOCOL_VERSION } from '../../../ssh/src/protocol.ts'
|
||||
import { helloSchema, type SshStreamEndpoint } from '../../../ssh/src/schemas.ts'
|
||||
import { authenticateStream } from '../../../ssh/src/stream-security.ts'
|
||||
|
||||
interface Config { executable: string; helperHash: string; workspace: string; home: string; path: string }
|
||||
|
||||
/** Loader-mounted fixture; only its administrative transport differs from SshConnection. */
|
||||
export default class DirectHelper extends Service {
|
||||
readonly ready: Promise<z.infer<typeof helloSchema>>
|
||||
private readonly rpc: SshRpcPeer
|
||||
private readonly child: ReturnType<typeof spawn>
|
||||
private readonly exited: Promise<void>
|
||||
private readonly sockets = new Set<Socket>()
|
||||
private closing: Promise<void> | undefined
|
||||
private diagnostic = ''
|
||||
private readonly deadline: NodeJS.Timeout
|
||||
|
||||
constructor(ctx: Context, private readonly config: Config) {
|
||||
super(ctx, 'ssh')
|
||||
const child = spawn(config.executable, [], {
|
||||
cwd: config.workspace, stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { HOME: config.home, PATH: config.path, TMPDIR: config.home, NODE_OPTIONS: '--disable-sigusr1', DSH_ARTIFACT_SECRET: 'must-not-reach-program' },
|
||||
})
|
||||
this.child = child
|
||||
this.exited = new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`Artifact helper exited (${code ?? signal}): ${this.diagnostic}`))
|
||||
})
|
||||
})
|
||||
void this.exited.catch(() => {})
|
||||
child.stderr.on('data', (chunk: Buffer) => { this.diagnostic = (this.diagnostic + chunk.toString()).slice(-8192) })
|
||||
this.rpc = new SshRpcPeer(child.stdout, child.stdin, 64 * 1024 * 1024, 128)
|
||||
child.once('error', (error) => { this.rpc.close(error) })
|
||||
this.deadline = setTimeout(() => { child.kill('SIGKILL') }, 90_000)
|
||||
this.ready = this.rpc.request('hello', { protocol: SSH_PROTOCOL_VERSION, workspace: config.workspace, leaseMs: 60_000 }, helloSchema, AbortSignal.timeout(20_000))
|
||||
.then((hello) => {
|
||||
assert.equal(hello.hash, config.helperHash)
|
||||
assert.equal(hello.executable, config.executable)
|
||||
return hello
|
||||
})
|
||||
void this.ready.catch(() => {})
|
||||
ctx.effect(() => () => this.dispose())
|
||||
}
|
||||
|
||||
async [Service.init](): Promise<void> { await this.ready }
|
||||
|
||||
get ptcLaunch(): { kind: 'embedded'; executable: string } { return { kind: 'embedded', executable: this.config.executable } }
|
||||
|
||||
async request<T>(method: string, params: unknown, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
|
||||
return this.rpc.request(method, params, schema, signal ?? AbortSignal.timeout(30_000))
|
||||
}
|
||||
|
||||
async connectStream(endpoint: SshStreamEndpoint): Promise<Socket> {
|
||||
const socket = createConnection({ path: endpoint.path, allowHalfOpen: true })
|
||||
this.own(socket)
|
||||
await once(socket, 'connect')
|
||||
const secure = await authenticateStream(socket, endpoint.capability, 10_000)
|
||||
this.own(secure)
|
||||
return secure
|
||||
}
|
||||
|
||||
private own(socket: Socket): void {
|
||||
this.sockets.add(socket)
|
||||
socket.on('error', () => { socket.destroy() })
|
||||
socket.once('close', () => { this.sockets.delete(socket) })
|
||||
}
|
||||
|
||||
dispose(): Promise<void> { this.closing ??= this.close(); return this.closing }
|
||||
|
||||
private async close(): Promise<void> {
|
||||
try {
|
||||
await this.ready
|
||||
await this.rpc.request('close', {}, z.null(), AbortSignal.timeout(10_000))
|
||||
this.child.stdin?.end()
|
||||
} finally {
|
||||
this.rpc.close()
|
||||
const sockets = [...this.sockets].reverse().map(async (socket) => {
|
||||
const closed = socket.closed ? Promise.resolve() : once(socket, 'close').catch(() => {})
|
||||
socket.destroy()
|
||||
await closed
|
||||
})
|
||||
this.child.stdin?.destroy()
|
||||
try { await this.exited; await Promise.all(sockets) }
|
||||
finally { clearTimeout(this.deadline) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** Test-only Loader consumer exercising files, processes, terminals and PTC through the SSH providers. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { boot } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {} from '@deepseek-ai/dsh-ssh'
|
||||
import type {} from '@deepseek-ai/dsh-fs-ssh'
|
||||
import type {} from '@deepseek-ai/dsh-subprocess-ssh'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-ssh'
|
||||
import type {} from '@deepseek-ai/dsh-ptc-runtime-node'
|
||||
|
||||
const [configPath, reportPath, sandbox] = process.argv.slice(2)
|
||||
if (configPath === undefined || reportPath === undefined || !['required', 'unavailable', 'portable'].includes(sandbox ?? '')) throw new Error('Artifact driver requires config, report, and expected sandbox availability')
|
||||
const ctx = await boot('ssh-helper-artifact', configPath)
|
||||
const checks: string[] = []
|
||||
const cleanupErrors: string[] = []
|
||||
const diagnostics = new Context()
|
||||
diagnostics.logger = ctx.logger
|
||||
diagnostics.logger.exporter({ levels: { default: 2 }, export: ({ type, args }) => {
|
||||
if (type === 'error') cleanupErrors.push(args.map(value => String(value)).join(' '))
|
||||
} })
|
||||
|
||||
async function completed(handle: SubprocessHandle): Promise<string> {
|
||||
const outcome = await handle.done
|
||||
assert.equal(await handle.waitForExit(), true)
|
||||
assert.deepEqual(outcome, { exitCode: 0, signal: null })
|
||||
return handle.collected.stdout?.readFrom(0).text ?? ''
|
||||
}
|
||||
|
||||
try {
|
||||
const hello = await ctx.ssh.ready
|
||||
const root = hello.workspace
|
||||
assert.equal(hello.kind, 'executable')
|
||||
assert.equal(ctx.ssh.ptcLaunch.kind, 'embedded')
|
||||
checks.push('executable-handshake')
|
||||
|
||||
const target = await ctx.fs.resolve(`${root}/sample.txt`)
|
||||
await ctx.fs.writeText(target, 'first\n你好\n')
|
||||
assert.equal(await ctx.fs.readText(target), 'first\n你好\n')
|
||||
assert.equal(Buffer.from(await ctx.fs.readByteRange(target, { offset: 0, length: 5 })).toString(), 'first')
|
||||
assert.equal(Buffer.from(await ctx.fs.readBytes(target, undefined, 1024)).toString(), 'first\n你好\n')
|
||||
await ctx.fs.editText(target, { oldString: 'first', newString: 'second', replaceAll: false })
|
||||
assert.equal(await ctx.fs.readText(target), 'second\n你好\n')
|
||||
let streamed = ''
|
||||
for await (const part of await ctx.fs.streamText(target)) streamed += part
|
||||
assert.equal(streamed, 'second\n你好\n')
|
||||
assert((await ctx.fs.listDir(await ctx.fs.resolve(root))).some(entry => entry.name === 'sample.txt'))
|
||||
await assert.rejects(ctx.fs.writeText(target, 'denied', undefined, undefined, { mode: 'read-only', workspaceRoot: root }))
|
||||
checks.push('guarded-files-and-streams')
|
||||
|
||||
const options = { cwd: root, graceMs: 1000, stdio: { stdin: 'ignore', stdout: { maxBytes: 65_536 }, stderr: { maxBytes: 65_536 } } } as const
|
||||
const ordinary = ctx.subprocess.spawn({ ...options, argv: ['/bin/sh', '-c', 'printf "ordinary"; printf "diagnostic" >&2'] })
|
||||
assert.equal(await completed(ordinary), 'ordinary')
|
||||
assert.equal(ordinary.collected.stderr?.readFrom(0).text, 'diagnostic')
|
||||
checks.push('managed-process-output')
|
||||
if (process.env.DSH_VERIFY_NO_NODE === '1') {
|
||||
assert.equal(await completed(ctx.subprocess.spawn({ ...options, argv: ['/bin/sh', '-c', 'set -e; if command -v node; then exit 90; fi; test ! -e /usr/bin/node; test ! -e /usr/local/bin/node; printf no-node'] })), 'no-node')
|
||||
checks.push('target-has-no-node')
|
||||
}
|
||||
|
||||
const sleeping = ctx.subprocess.spawn({ ...options, stdio: { ...options.stdio, stdout: 'pipe' }, argv: ['/bin/sh', '-c', 'printf ready; exec /bin/sleep 60'] })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stdout = sleeping.stdout!
|
||||
let text = ''
|
||||
const cleanup = (): void => { clearTimeout(timer); stdout.off('data', read); stdout.off('error', failed); stdout.off('end', ended) }
|
||||
const failed = (error: Error): void => { cleanup(); reject(error) }
|
||||
const ended = (): void => { failed(new Error('Sleeping process exited before readiness')) }
|
||||
const read = (bytes: Buffer): void => { text += bytes.toString(); if (text.includes('ready')) { cleanup(); resolve() } }
|
||||
const timer = setTimeout(() => { failed(new Error('Sleeping process did not become ready')) }, 10_000)
|
||||
stdout.on('data', read); stdout.once('error', failed); stdout.once('end', ended)
|
||||
})
|
||||
sleeping.terminate()
|
||||
await sleeping.done
|
||||
assert.equal(await sleeping.waitForExit(), true)
|
||||
sleeping.stdout?.destroy()
|
||||
checks.push('process-cancellation')
|
||||
|
||||
const terminal = await ctx.subprocess.spawnTerminal({ argv: ['/bin/sh'], cwd: root, env: {}, terminalType: 'xterm', cols: 80, rows: 24, graceMs: 1000 })
|
||||
let terminalText = ''
|
||||
const terminalRead = (async () => { for await (const bytes of terminal.output) terminalText += Buffer.from(bytes).toString() })()
|
||||
try {
|
||||
await terminal.resize(100, 35)
|
||||
await terminal.write("stty size; printf 'pty-result:%s\\n' 42; exit\n")
|
||||
assert.equal((await terminal.done).exitCode, 0)
|
||||
await terminalRead
|
||||
assert(terminalText.includes('pty-result:42'))
|
||||
assert(terminalText.includes('35 100'))
|
||||
} finally { await terminal.terminate(); await terminalRead }
|
||||
checks.push('native-pty')
|
||||
|
||||
const runtime = ctx.ptcRuntime
|
||||
const bindings = [{ global: 'tools', functions: { echo: async (input: unknown) => String(input) } }]
|
||||
const policy = { mode: 'danger-full-access', workspaceRoot: root } as const
|
||||
const result = await runtime.run(runtime.resolve({
|
||||
program: 'const fs=await import("node:fs/promises"); await fs.writeFile("ptc.txt","embedded"); console.log("worker-output"); return {value:await tools.echo("bridge"),env:Object.keys(process.env),executable:process.execPath,cwd:process.cwd()};',
|
||||
bindings, cwd: root, sandboxPolicy: policy,
|
||||
}))
|
||||
assert.equal(result.error, undefined, JSON.stringify(result))
|
||||
assert.deepEqual(result.value, { value: 'bridge', env: [], executable: hello.executable, cwd: root })
|
||||
assert(result.logs.join('').includes('worker-output'))
|
||||
assert.equal(await ctx.fs.readText(await ctx.fs.resolve(`${root}/ptc.txt`)), 'embedded')
|
||||
const locked = await runtime.run(runtime.resolve({
|
||||
program: 'const fs=await import("node:fs"); const {tryLockExclusive}=await import("@deepseek-ai/node-addon-system/flock"); const fd=fs.openSync("native-lock","w"); try { await tryLockExclusive(fd); return "locked" } finally { fs.closeSync(fd) }',
|
||||
bindings: [], cwd: root, sandboxPolicy: policy,
|
||||
}))
|
||||
assert.equal(locked.error, undefined, JSON.stringify(locked))
|
||||
assert.equal(locked.value, 'locked')
|
||||
checks.push('native-flock')
|
||||
const timed = await runtime.run(runtime.resolve({ program: 'while(true){}', bindings: [], cwd: root, sandboxPolicy: policy, timeoutMs: 1500 }))
|
||||
assert.equal(timed.error?.kind, 'timeout')
|
||||
checks.push('embedded-ptc-and-deadline')
|
||||
|
||||
if (sandbox === 'required') {
|
||||
const runner = process.env.DSH_EXPECT_SANDBOX_RUNNER
|
||||
if (runner !== undefined) {
|
||||
const invocation = await ctx.sandbox.confine(['/bin/sh', '-c', 'true'], { mode: 'read-only', workspaceRoot: root })
|
||||
assert.equal(basename(invocation.argv[0]!), runner)
|
||||
}
|
||||
const confined = await runtime.run(runtime.resolve({
|
||||
program: 'return await (await import("node:fs/promises")).readFile("ptc.txt","utf8")', bindings: [], cwd: root,
|
||||
sandboxPolicy: { mode: 'read-only', workspaceRoot: root },
|
||||
}))
|
||||
assert.equal(confined.error, undefined, JSON.stringify(confined))
|
||||
assert.equal(confined.value, 'embedded')
|
||||
const denied = await runtime.run(runtime.resolve({
|
||||
program: 'await(await import("node:fs/promises")).writeFile("forbidden.txt","bad")', bindings: [], cwd: root,
|
||||
sandboxPolicy: { mode: 'read-only', workspaceRoot: root },
|
||||
}))
|
||||
assert.equal(denied.sandbox?.denied, true, JSON.stringify(denied))
|
||||
checks.push('sandbox-enforcement')
|
||||
} else if (sandbox === 'unavailable') {
|
||||
await assert.rejects(ctx.sandbox.confine(['/bin/sh', '-c', 'true'], { mode: 'read-only', workspaceRoot: root }), /sandbox|runner/i)
|
||||
checks.push('sandbox-unavailable-refusal')
|
||||
}
|
||||
await writeFile(reportPath, `${JSON.stringify({ checks, platform: hello.platform, nodeVersion: hello.nodeVersion, sandboxLane: sandbox }, null, 2)}\n`)
|
||||
} finally {
|
||||
try { await ctx.fiber.dispose() }
|
||||
finally { await diagnostics.fiber.dispose() }
|
||||
assert.deepEqual(cleanupErrors, [], 'Loader or provider cleanup reported errors')
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# glibc 2.28 target without Node or a checkout; only the release payload is mounted at runtime.
|
||||
FROM almalinux:8.10
|
||||
RUN dnf install -y openssh-server procps-ng shadow-utils passwd && dnf clean all \
|
||||
&& useradd --create-home dsh && passwd -d dsh \
|
||||
&& rm -f /etc/ssh/ssh_host_*key*
|
||||
COPY sshd_config /etc/ssh/sshd_config
|
||||
COPY start.sh /start.sh
|
||||
ENTRYPOINT ["/bin/sh", "/start.sh"]
|
||||
@@ -0,0 +1,13 @@
|
||||
Port 22
|
||||
ListenAddress 0.0.0.0
|
||||
HostKey /etc/ssh/ssh_host_ed25519_key
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PermitRootLogin no
|
||||
UsePAM no
|
||||
AuthenticationMethods publickey
|
||||
AllowUsers dsh
|
||||
AllowTcpForwarding yes
|
||||
AllowStreamLocalForwarding yes
|
||||
PidFile /run/sshd.pid
|
||||
LogLevel VERBOSE
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
ssh-keygen -q -t ed25519 -N '' -f /etc/ssh/ssh_host_ed25519_key
|
||||
install -d -m 700 -o dsh -g dsh /home/dsh/.ssh
|
||||
install -m 600 -o dsh -g dsh /fixture-key.pub /home/dsh/.ssh/authorized_keys
|
||||
install -d -m 700 -o dsh -g dsh /home/dsh/workspace
|
||||
exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../ssh"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess-local"
|
||||
},
|
||||
{
|
||||
"path": "../../ptc-runtime/ptc-runtime-node"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ssh/ssh/README.md
|
||||
README.md: c978538491c3ce8045663b9e675d2141e541cacc
|
||||
README.zh.md: 20c91b199aebdcf308d3e3f6c03f9188315e994a
|
||||
README.md: 3df08097a354c01bd7f2ad3cf928f7c4a79d05f9
|
||||
README.zh.md: acec72885c5fa01cb9de1db881b0a7c72d9b1fa5
|
||||
@@ -31,20 +31,21 @@ Compose this service with [`fs-ssh`](../fs-ssh/README.md), [`subprocess-ssh`](..
|
||||
|
||||
Both endpoints require Linux or macOS. The local `ssh` command must support connection multiplexing and Unix-socket forwarding; the server must permit that forwarding. Configure the alias, credentials and known-host entry before startup: the service enables `BatchMode`, requires strict host-key checking, disables agent forwarding and adds no interactive authentication flow.
|
||||
|
||||
Install the built helper and its matching runtime dependencies on the remote host. Keep Node, helper, bootstrap and their dependencies outside the workspace and writable temporary roots. They must also remain outside a backend’s replaced temporary tree, such as bwrap’s private `/tmp`; the workspace may still be under `/tmp`. Digest verification detects an unexpected installed artifact after helper startup; it does not make writable deployment files safe to execute or authenticate a malicious SSH host.
|
||||
Install an [SSH helper executable archive](../ssh-helper-runtime/README.md), or the built helper script with its matching Node dependencies. Keep the runtime outside the workspace and writable temporary roots. They must also remain outside a backend’s replaced temporary tree, such as bwrap’s private `/tmp`; the workspace may still be under `/tmp`. Digest verification detects an unexpected installed artifact after helper startup; it does not make writable deployment files safe to execute or authenticate a malicious SSH host.
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `host` | required | Existing OpenSSH host alias |
|
||||
| `node`, `helper`, `workspace` | required | Absolute remote Node executable, bundled helper entry and default workspace |
|
||||
| `launch` | required | `{ kind: "executable" }`, or `{ kind: "node-script", node: "/absolute/node" }` |
|
||||
| `helper`, `workspace` | required | Absolute remote helper entry and default workspace |
|
||||
| `helperHash` | required | Lowercase SHA-256 of the installed helper entry |
|
||||
| `bootstrapPath`, `bootstrapHash` | omitted | Paired remote PTC entry and its lowercase SHA-256 |
|
||||
| `launch.bootstrapPath`, `launch.bootstrapHash` | omitted | Paired remote PTC script and digest; only for `node-script` |
|
||||
| `requestTimeoutMs` | `30000` | Connection and administrative-request deadline, from 1 through 2,147,483,647 ms |
|
||||
| `maxFrameBytes` | `67108864` | Per-message JSON payload ceiling, at most 64 MiB |
|
||||
| `maxPending` | `128` | Ordinary outstanding requests; heartbeat and bounded cleanup requests have reserved capacity |
|
||||
| `leaseMs` | `30000` | Helper heartbeat lease, from 3000 to 600000 ms |
|
||||
|
||||
For PTC, configure both bootstrap fields and pass the verified `ctx.ssh.nodeExecutable` and `ctx.ssh.bootstrapPath` to [`NodePtcRuntime`](../../ptc-runtime/ptc-runtime-node/README.md). Basic filesystem and Bash use may omit the pair. The `bootstrapPath` getter refuses an unconfigured PTC deployment.
|
||||
Pass the verified `ctx.ssh.ptcLaunch` as the `launch` configuration of [`NodePtcRuntime`](../../ptc-runtime/ptc-runtime-node/README.md). Executable deployments include their worker. Script deployments require both bootstrap fields for PTC; filesystem and Bash use may omit them. The getter refuses an unconfigured script bootstrap. The client rejects mismatched protocol versions, runtime kinds and helper digests before readiness.
|
||||
|
||||
-----
|
||||
|
||||
@@ -64,7 +65,7 @@ Failed startup and process results release their reservations after native quies
|
||||
|
||||
For terminals opting into shell activity observation, root exit retains the reservation and its remaining work. Activity RPC continues to reach the provider; explicit termination awaits quiescence before releasing endpoints and recording the completed result. Helper connection disposal and lease expiry retain their existing termination authority.
|
||||
|
||||
The helper starts with `--disable-sigusr1`, so a same-user process signal cannot open its Node debugger.
|
||||
The connection disables the helper’s SIGUSR1 debugger activation: the script invocation uses `--disable-sigusr1`, and the executable invocation supplies that flag through `NODE_OPTIONS`.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -31,20 +31,21 @@ kind: "package-reference"
|
||||
|
||||
两端均需运行 Linux 或 macOS。本地 `ssh` 命令必须支持连接复用与 Unix 套接字转发,服务器也必须允许该转发。启动前配置主机别名、凭据与已知主机记录:本服务启用 `BatchMode`、要求严格检查主机密钥、禁用认证代理转发,且不提供交互认证流程。
|
||||
|
||||
在远端主机安装已构建的辅助程序及其匹配的运行依赖。Node、辅助程序、引导程序及其依赖必须位于工作区和可写临时目录之外,也必须位于后端会替换的临时目录树之外,例如 bwrap 的私有 `/tmp`;工作区仍可位于 `/tmp` 下。摘要校验在辅助程序启动后发现非预期的已安装产物;它不能保证可写部署文件的执行安全,也不能认证恶意 SSH 主机。
|
||||
安装 [SSH helper 可执行文件发行包](../ssh-helper-runtime/README.zh.md),或已构建的 helper 脚本及其匹配的 Node 依赖。运行时必须位于工作区和可写临时目录之外,也必须位于后端会替换的临时目录树之外,例如 bwrap 的私有 `/tmp`;工作区仍可位于 `/tmp` 下。摘要校验在辅助程序启动后发现非预期的已安装产物;它不能保证可写部署文件的执行安全,也不能认证恶意 SSH 主机。
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `host` | 必填 | 已有的 OpenSSH 主机别名 |
|
||||
| `node`、`helper`、`workspace` | 必填 | 远端 Node 可执行文件、辅助程序打包入口和默认工作区的绝对路径 |
|
||||
| `launch` | 必填 | `{ kind: "executable" }`,或 `{ kind: "node-script", node: "/absolute/node" }` |
|
||||
| `helper`、`workspace` | 必填 | 远端 helper 入口和默认工作区的绝对路径 |
|
||||
| `helperHash` | 必填 | 已安装辅助程序入口的小写 SHA-256 |
|
||||
| `bootstrapPath`、`bootstrapHash` | 省略 | 成对提供的远端 PTC 入口及其小写 SHA-256 |
|
||||
| `launch.bootstrapPath`、`launch.bootstrapHash` | 省略 | 成对提供的远端 PTC 脚本及摘要;仅用于 `node-script` |
|
||||
| `requestTimeoutMs` | `30000` | 连接与管理请求的截止时限,范围为 1 至 2,147,483,647 毫秒 |
|
||||
| `maxFrameBytes` | `67108864` | 每条 JSON 消息的负载上限,最大为 64 MiB |
|
||||
| `maxPending` | `128` | 普通未完成请求的数量上限;心跳与有界清理请求使用预留容量 |
|
||||
| `leaseMs` | `30000` | 辅助进程心跳租期,范围为 3000 至 600000 毫秒 |
|
||||
|
||||
使用 PTC 时,配置两个引导字段,并将验证后的 `ctx.ssh.nodeExecutable` 与 `ctx.ssh.bootstrapPath` 传给 [`NodePtcRuntime`](../../ptc-runtime/ptc-runtime-node/README.zh.md)。仅使用文件系统和 Bash 时可以省略这对字段。未配置 PTC 部署时,`bootstrapPath` getter 会拒绝访问。
|
||||
将验证后的 `ctx.ssh.ptcLaunch` 作为 [`NodePtcRuntime`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 的 `launch` 配置。可执行文件部署包含 worker;脚本部署使用 PTC 时需要两个引导字段,仅使用文件系统和 Bash 时可以省略。脚本引导未配置时,getter 会拒绝访问。客户端在就绪前拒绝不匹配的协议版本、运行形式和 helper 摘要。
|
||||
|
||||
-----
|
||||
|
||||
@@ -64,7 +65,7 @@ OpenSSH 主连接承载私有管理 RPC。每条程序流使用独立转发的 U
|
||||
|
||||
终端启用 shell 活动观察时,根进程退出会保留 reservation 及其剩余工作。活动 RPC 继续访问 provider;明确终止时先等待进程范围完全停稳,再释放端点并记录完成结果。helper 连接卸载和租约到期仍保留原有的终止权限。
|
||||
|
||||
辅助程序以 `--disable-sigusr1` 启动,因此同用户进程发送的信号无法开启其 Node 调试器。
|
||||
连接会禁用 helper 的 SIGUSR1 调试器激活:脚本调用使用 `--disable-sigusr1`,可执行文件调用通过 `NODE_OPTIONS` 提供该标志。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^"
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
@@ -78,6 +79,7 @@
|
||||
"@deepseek-ai/dsh-sandbox-ssh": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp-stdio": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^"
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Private OpenSSH process entry; the helper module owns request and cleanup behavior. */
|
||||
/* v8 ignore file -- launched through plain Node in SSH acceptance; helper behavior is exercised through its explicit streams. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isSea } from 'node:sea'
|
||||
import { runSshHelper } from './helper.ts'
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -8,7 +9,12 @@ const stop = (): void => { controller.abort(new Error('SSH helper process termin
|
||||
for (const signal of ['SIGTERM', 'SIGHUP', 'SIGINT'] as const) process.once(signal, stop)
|
||||
try {
|
||||
if (process.argv.length !== 2) throw new Error('SSH helper accepts no command arguments')
|
||||
await runSshHelper({ input: process.stdin, output: process.stdout, entryPath: fileURLToPath(import.meta.url), signal: controller.signal })
|
||||
const packaged = isSea()
|
||||
await runSshHelper({
|
||||
input: process.stdin, output: process.stdout,
|
||||
entryPath: packaged ? process.execPath : fileURLToPath(import.meta.url),
|
||||
kind: packaged ? 'executable' : 'node-script', signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
process.stderr.write(`dsh-ssh-sandbox: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 127
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface HelperTransport {
|
||||
output: Writable
|
||||
/** Installed entry whose digest is compared by the client. */
|
||||
entryPath: string
|
||||
/** Whether the installed entry includes its own Node and worker runtime. */
|
||||
kind: 'node-script' | 'executable'
|
||||
/** Process termination joins the same cleanup as transport loss. */
|
||||
signal: AbortSignal
|
||||
}
|
||||
@@ -97,13 +99,14 @@ export async function runSshHelper(transport: HelperTransport): Promise<void> {
|
||||
protocol: z.literal(SSH_PROTOCOL_VERSION), workspace: remotePath, leaseMs: z.number().int().min(3000).max(600_000),
|
||||
bootstrapPath: remotePath.optional(),
|
||||
}).strict().parse(raw)
|
||||
if (transport.kind === 'executable' && input.bootstrapPath !== undefined) throw new Error('Executable SSH helper uses its embedded PTC bootstrap')
|
||||
workspace = ctx.fs.processPath(await ctx.fs.resolve(input.workspace, { signal }))
|
||||
leaseMs = input.leaseMs
|
||||
initialized = true
|
||||
touchLease()
|
||||
return {
|
||||
protocol: SSH_PROTOCOL_VERSION, hash: createHash('sha256').update(readFileSync(transport.entryPath)).digest('hex'),
|
||||
platform: process.platform, nodeVersion: process.version, node: process.execPath, root, workspace,
|
||||
kind: transport.kind, platform: process.platform, nodeVersion: process.version, executable: process.execPath, root, workspace,
|
||||
...(input.bootstrapPath === undefined ? {} : { bootstrapHash: createHash('sha256').update(readFileSync(input.bootstrapPath)).digest('hex') }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,40 @@ import { createConnection, type Socket } from 'node:net'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import schema from '@deepseek-ai/schemastery'
|
||||
import { z } from 'zod'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { SshRpcPeer, SSH_PROTOCOL_VERSION } from './protocol.ts'
|
||||
import { helloSchema, type SshStreamEndpoint } from './schemas.ts'
|
||||
import { authenticateStream } from './stream-security.ts'
|
||||
|
||||
type Hello = z.infer<typeof helloSchema>
|
||||
|
||||
/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
|
||||
export type HelperLaunch = {
|
||||
/** Run the installed script with a separately installed Node executable. */
|
||||
kind: 'node-script'
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Absolute remote PTC bootstrap; requires bootstrapHash. */
|
||||
bootstrapPath?: string
|
||||
/** Lowercase SHA-256 of bootstrapPath; requires that path. */
|
||||
bootstrapHash?: string
|
||||
} | {
|
||||
/** Run the helper executable with its embedded Node and PTC worker. */
|
||||
kind: 'executable'
|
||||
}
|
||||
|
||||
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
|
||||
export interface Config {
|
||||
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
|
||||
host: string
|
||||
/** Absolute remote Node executable. */
|
||||
node: string
|
||||
/** Explicit script or self-contained executable invocation. */
|
||||
launch: HelperLaunch
|
||||
/** Absolute path to the installed, bundled helper entry. */
|
||||
helper: string
|
||||
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
|
||||
helperHash: string
|
||||
/** Absolute remote default workspace. */
|
||||
workspace: string
|
||||
/** Optional preinstalled built PTC entry, paired with its expected digest. */
|
||||
bootstrapPath?: string
|
||||
/** SHA-256 of bootstrapPath; both fields must be supplied together. */
|
||||
bootstrapHash?: string
|
||||
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
|
||||
requestTimeoutMs?: number
|
||||
/** Maximum JSON payload bytes per helper request or response. */
|
||||
@@ -46,9 +58,12 @@ declare module '@deepseek-ai/cordis' {
|
||||
/** One non-reconnecting SSH session; loss invalidates all active operations. */
|
||||
export class SshConnection extends Service {
|
||||
static Config: schema<Config> = schema.object({
|
||||
host: schema.string().required(), node: schema.string().required(), helper: schema.string().required(),
|
||||
host: schema.string().required(), helper: schema.string().required(),
|
||||
launch: schema.union([
|
||||
schema.object({ kind: schema.const('node-script').required(), node: schema.string().required(), bootstrapPath: schema.string(), bootstrapHash: schema.string() }),
|
||||
schema.object({ kind: schema.const('executable').required() }),
|
||||
]).required(),
|
||||
helperHash: schema.string().required(), workspace: schema.string().required(),
|
||||
bootstrapPath: schema.string(), bootstrapHash: schema.string(),
|
||||
requestTimeoutMs: schema.number().default(30_000), maxFrameBytes: schema.number().default(64 * 1024 * 1024),
|
||||
maxPending: schema.number().default(128), leaseMs: schema.number().default(30_000),
|
||||
})
|
||||
@@ -67,7 +82,7 @@ export class SshConnection extends Service {
|
||||
private failure: Error | undefined
|
||||
private sockets = new Set<Socket>()
|
||||
private nextSocket = 0
|
||||
private readonly config: Required<Omit<Config, 'bootstrapPath' | 'bootstrapHash'>> & Pick<Config, 'bootstrapPath' | 'bootstrapHash'>
|
||||
private readonly config: Required<Config>
|
||||
private remote: Hello | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -75,13 +90,17 @@ export class SshConnection extends Service {
|
||||
if (process.platform !== 'linux' && process.platform !== 'darwin') throw new Error('SSH runtime requires a POSIX client')
|
||||
this.config = z.object({
|
||||
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.@-]*$/),
|
||||
node: z.string().startsWith('/'), helper: z.string().startsWith('/'), helperHash: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
launch: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('node-script'), node: z.string().startsWith('/'),
|
||||
bootstrapPath: z.string().startsWith('/').optional(), bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
||||
}).strict().refine(value => (value.bootstrapPath === undefined) === (value.bootstrapHash === undefined), 'bootstrapPath and bootstrapHash must be paired'),
|
||||
z.object({ kind: z.literal('executable') }).strict(),
|
||||
]),
|
||||
helper: z.string().startsWith('/'), helperHash: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
workspace: z.string().startsWith('/'), requestTimeoutMs: z.number().int().positive().max(2_147_483_647),
|
||||
bootstrapPath: z.string().startsWith('/').optional(), bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
||||
maxFrameBytes: z.number().int().positive().max(64 * 1024 * 1024), maxPending: z.number().int().positive().max(128),
|
||||
leaseMs: z.number().int().min(3000).max(600_000),
|
||||
}).refine(value => (value.bootstrapPath === undefined) === (value.bootstrapHash === undefined), 'bootstrapPath and bootstrapHash must be paired')
|
||||
.parse(config) as typeof this.config
|
||||
}).strict().parse(config) as Required<Config>
|
||||
this.ready = this.start()
|
||||
// Startup uses Node I/O, local validation, and Error-valued RPC failures.
|
||||
void this.ready.catch((error: unknown) => { this.fail(error as Error) })
|
||||
@@ -91,16 +110,18 @@ export class SshConnection extends Service {
|
||||
/** Hold plugin readiness until the remote identity and helper digest are verified. */
|
||||
async [Service.init](): Promise<void> { await this.ready }
|
||||
|
||||
/** Verified remote Node executable for the paired PTC runtime. */
|
||||
get nodeExecutable(): string {
|
||||
/** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */
|
||||
get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string } {
|
||||
if (this.remote === undefined) throw new Error('SSH helper is not ready')
|
||||
return this.remote.node
|
||||
}
|
||||
|
||||
/** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
|
||||
get bootstrapPath(): string {
|
||||
if (this.remote === undefined || this.config.bootstrapPath === undefined) throw new Error('SSH PTC requires a verified bootstrapPath and bootstrapHash')
|
||||
return this.config.bootstrapPath
|
||||
const launch = this.config.launch
|
||||
switch (launch.kind) {
|
||||
case 'executable': return { kind: 'embedded', executable: this.remote.executable }
|
||||
case 'node-script':
|
||||
if (launch.bootstrapPath === undefined) throw new Error('SSH PTC requires a verified bootstrapPath and bootstrapHash')
|
||||
return { kind: 'node-script', executable: this.remote.executable, bootstrapPath: launch.bootstrapPath }
|
||||
/* v8 ignore next -- Config parsing admits only these two launch kinds. */
|
||||
default: return assertNever(launch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +280,22 @@ export class SshConnection extends Service {
|
||||
this.directory = await mkdtemp('/tmp/dsh-ssh-')
|
||||
if (this.closed) throw new Error('SSH connection closed before startup')
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
|
||||
const command = [this.config.node, '--disable-sigusr1', this.config.helper].map(quote).join(' ')
|
||||
const launch = this.config.launch
|
||||
let command: string
|
||||
let bootstrapPath: string | undefined
|
||||
let bootstrapHash: string | undefined
|
||||
switch (launch.kind) {
|
||||
case 'node-script':
|
||||
command = [launch.node, '--disable-sigusr1', this.config.helper].map(quote).join(' ')
|
||||
bootstrapPath = launch.bootstrapPath
|
||||
bootstrapHash = launch.bootstrapHash
|
||||
break
|
||||
case 'executable':
|
||||
command = `NODE_OPTIONS='--disable-sigusr1' ${quote(this.config.helper)}`
|
||||
break
|
||||
/* v8 ignore next -- Config parsing admits only these two launch kinds. */
|
||||
default: return assertNever(launch)
|
||||
}
|
||||
const child = spawn('ssh', [
|
||||
'-T', '-M', '-S', this.controlPath(), '-o', 'ControlPersist=no', '-o', 'BatchMode=yes',
|
||||
'-o', 'StrictHostKeyChecking=yes', '-o', 'ForwardAgent=no', '-o', 'ClearAllForwardings=yes',
|
||||
@@ -275,10 +311,11 @@ export class SshConnection extends Service {
|
||||
rpc.once('closed', (error) => { this.fail(error as Error) })
|
||||
const hello = await rpc.request('hello', {
|
||||
protocol: SSH_PROTOCOL_VERSION, workspace: this.config.workspace, leaseMs: this.config.leaseMs,
|
||||
...(this.config.bootstrapPath === undefined ? {} : { bootstrapPath: this.config.bootstrapPath }),
|
||||
...(bootstrapPath === undefined ? {} : { bootstrapPath }),
|
||||
}, helloSchema, AbortSignal.timeout(this.config.requestTimeoutMs))
|
||||
if (hello.hash !== this.config.helperHash) throw new Error('SSH helper digest differs from the configured artifact')
|
||||
if (hello.bootstrapHash !== this.config.bootstrapHash) throw new Error('SSH PTC bootstrap digest differs from the configured artifact')
|
||||
if (hello.kind !== launch.kind) throw new Error('SSH helper runtime differs from the configured launch kind')
|
||||
if (hello.bootstrapHash !== bootstrapHash) throw new Error('SSH PTC bootstrap digest differs from the configured artifact')
|
||||
this.remote = hello
|
||||
let heartbeatPending: Promise<unknown> | undefined
|
||||
this.heartbeat = setInterval(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Wire version shared by the installed helper and client package. */
|
||||
export const SSH_PROTOCOL_VERSION = 1
|
||||
export const SSH_PROTOCOL_VERSION = 2
|
||||
|
||||
/** Maximum prepared or running process handles owned by one helper. */
|
||||
export const SSH_MAX_PROCESS_HANDLES = 128
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Strict JSON validation for SSH helper requests and remote observations. */
|
||||
import { z } from 'zod'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { SSH_PROTOCOL_VERSION } from './protocol.ts'
|
||||
|
||||
/** Identity of one prepared or running process in its owning SSH helper. */
|
||||
export type SshProcessId = Branded<'SshProcessId'>
|
||||
@@ -54,7 +55,12 @@ export const spawnSchema = z.object({
|
||||
}).strict().optional(),
|
||||
}).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution')
|
||||
/** Connection handshake binds sockets and workspace to one helper process. */
|
||||
export const helloSchema = z.object({ protocol: z.literal(1), hash: z.string().regex(/^[0-9a-f]{64}$/), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(), node: remotePath, root: remotePath, workspace: remotePath, bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional() }).strict()
|
||||
export const helloSchema = z.object({
|
||||
protocol: z.literal(SSH_PROTOCOL_VERSION), hash: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
kind: z.enum(['node-script', 'executable']), platform: z.enum(['linux', 'darwin']),
|
||||
nodeVersion: z.string(), executable: remotePath, root: remotePath, workspace: remotePath,
|
||||
bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
||||
}).strict()
|
||||
/** A prepared process publishes its sockets before target code may execute. */
|
||||
export const streamEndpointSchema = z.object({ path: remotePath, capability: z.string().regex(/^[0-9a-f]{64}$/) }).strict()
|
||||
/** A stream capability reaches only the authenticated SSH client and its helper. */
|
||||
|
||||
@@ -68,7 +68,7 @@ async function setup(phase: 'connect' | 'authenticate', pauseControl?: 'forward'
|
||||
if (method === 'heartbeat') { heartbeatCalls++; return holdHeartbeat ? heartbeat.promise : null }
|
||||
if (method === 'ordinary') { ordinaryEntered.resolve(undefined); return ordinary.promise }
|
||||
if (method !== 'hello') throw new Error(`unexpected helper operation: ${method}`)
|
||||
return { protocol: 1, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', node: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
|
||||
return { protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
|
||||
})
|
||||
const rawSocket = new PendingSocket()
|
||||
const secureSocket = new PendingSocket()
|
||||
@@ -110,7 +110,7 @@ async function setup(phase: 'connect' | 'authenticate', pauseControl?: 'forward'
|
||||
const socket = phase === 'connect' ? rawSocket : secureSocket
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SshConnection, {
|
||||
host: 'hermetic-test', node: '/usr/bin/node', helper: '/opt/dsh/helper.js', helperHash: 'a'.repeat(64), workspace: '/workspace',
|
||||
host: 'hermetic-test', launch: { kind: 'node-script', node: '/usr/bin/node' }, helper: '/opt/dsh/helper.js', helperHash: 'a'.repeat(64), workspace: '/workspace',
|
||||
requestTimeoutMs: 10_000, maxFrameBytes: 4096, maxPending: holdHeartbeat ? 1 : 8, leaseMs: 30_000,
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
|
||||
+4
-4
@@ -10,16 +10,16 @@ import { SshRpcPeer } from '../../src/protocol.ts'
|
||||
import { helloSchema, type SshStreamEndpoint } from '../../src/schemas.ts'
|
||||
import { authenticateStream } from '../../src/stream-security.ts'
|
||||
|
||||
export async function createHelperHarness(handshake = true, leaseMs = 30_000) {
|
||||
export async function createHelperHarness(handshake = true, leaseMs = 30_000, kind: 'node-script' | 'executable' = 'node-script') {
|
||||
const root = await realpath(await mkdtemp('/tmp/dsh-ssh-rpc-'))
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
const controller = new AbortController()
|
||||
const serving = runSshHelper({ input, output, entryPath: fileURLToPath(new URL('../../src/helper-entry.ts', import.meta.url)), signal: controller.signal })
|
||||
const serving = runSshHelper({ input, output, kind, entryPath: fileURLToPath(new URL('../../src/helper-entry.ts', import.meta.url)), signal: controller.signal })
|
||||
void serving.catch(() => {})
|
||||
const client = new SshRpcPeer(output, input, 64 * 1024 * 1024, 128)
|
||||
const sockets = new Set<Socket>()
|
||||
const hello = () => client.request('hello', { protocol: 1, workspace: root, leaseMs }, helloSchema)
|
||||
const hello = () => client.request('hello', { protocol: 2, workspace: root, leaseMs }, helloSchema)
|
||||
let closing: Promise<void> | undefined
|
||||
const close = () => {
|
||||
closing ??= (async () => {
|
||||
@@ -44,7 +44,7 @@ export async function createHelperHarness(handshake = true, leaseMs = 30_000) {
|
||||
let facts: z.infer<typeof helloSchema> | undefined
|
||||
try { if (handshake) facts = await hello() } catch (error) { await close(); throw error }
|
||||
const connection = {
|
||||
ready: Promise.resolve(facts), nodeExecutable: process.execPath,
|
||||
ready: Promise.resolve(facts),
|
||||
request: <T>(method: string, params: unknown, schema: z.ZodType<T>, signal?: AbortSignal) =>
|
||||
client.request(method, params, schema, signal),
|
||||
connectStream, dispose: close,
|
||||
|
||||
@@ -22,11 +22,11 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou
|
||||
const controller = new AbortController()
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
try {
|
||||
await expect(runSshHelper({ input, output, entryPath, signal: controller.signal })).rejects.toThrow('POSIX host')
|
||||
await expect(runSshHelper({ input, output, kind: 'node-script', entryPath, signal: controller.signal })).rejects.toThrow('POSIX host')
|
||||
} finally { platform.mockRestore() }
|
||||
controller.abort(new Error('startup cancelled'))
|
||||
try {
|
||||
await expect(runSshHelper({ input, output, entryPath, signal: controller.signal })).rejects.toThrow('startup cancelled')
|
||||
await expect(runSshHelper({ input, output, kind: 'node-script', entryPath, signal: controller.signal })).rejects.toThrow('startup cancelled')
|
||||
} finally { input.destroy(); output.destroy() }
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
const controller = new AbortController()
|
||||
const serving = runSshHelper({ input, output, entryPath, signal: controller.signal })
|
||||
const serving = runSshHelper({ input, output, kind: 'node-script', entryPath, signal: controller.signal })
|
||||
controller.abort()
|
||||
try {
|
||||
await serving
|
||||
@@ -47,17 +47,26 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou
|
||||
const test = await createHelperHarness(false)
|
||||
try {
|
||||
for (const params of [
|
||||
{ protocol: 2, workspace: test.root, leaseMs: 3000 },
|
||||
{ protocol: 1, workspace: 'relative', leaseMs: 3000 },
|
||||
{ protocol: 1, workspace: test.root, leaseMs: 2999 },
|
||||
{ protocol: 1, workspace: test.root, leaseMs: 3000 },
|
||||
{ protocol: 2, workspace: 'relative', leaseMs: 3000 },
|
||||
{ protocol: 2, workspace: test.root, leaseMs: 2999 },
|
||||
]) await expect(test.client.request('hello', params, helloSchema)).rejects.toThrow()
|
||||
const bootstrapPath = `${test.root}/bootstrap.js`
|
||||
await writeFile(bootstrapPath, 'export const identity = "test"\n')
|
||||
const facts = await test.client.request('hello', { protocol: 1, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema)
|
||||
const facts = await test.client.request('hello', { protocol: 2, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema)
|
||||
expect(facts.bootstrapHash).toBe(createHash('sha256').update(await readFile(bootstrapPath)).digest('hex'))
|
||||
} finally { await test.close() }
|
||||
})
|
||||
|
||||
it('refuses an external bootstrap for an executable helper', async () => {
|
||||
const test = await createHelperHarness(false, 30_000, 'executable')
|
||||
try {
|
||||
await expect(test.client.request('hello', { protocol: 2, workspace: test.root, leaseMs: 3000, bootstrapPath: '/external/process.js' }, helloSchema))
|
||||
.rejects.toThrow('embedded PTC bootstrap')
|
||||
expect((await test.hello()).kind).toBe('executable')
|
||||
} finally { await test.close() }
|
||||
})
|
||||
|
||||
it('expires the client lease and joins open text streams and reserved sockets', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
let test: Awaited<ReturnType<typeof createHelperHarness>> | undefined
|
||||
|
||||
@@ -27,6 +27,7 @@ const enabled = configPath !== undefined && process.platform !== 'win32'
|
||||
|
||||
async function setup() {
|
||||
const config = JSON.parse(readFileSync(configPath as string, 'utf8')) as Config
|
||||
if (config.launch.kind !== 'node-script') throw new Error('live.e2e requires a Node script deployment; executable acceptance is owned by ssh-helper-runtime')
|
||||
const ctx = new Context()
|
||||
const projection = ctx.plugin(SessionProjectionRegistry)
|
||||
const policy = ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: config.workspace })
|
||||
@@ -55,7 +56,7 @@ async function processResult(handle: SubprocessHandle) {
|
||||
async function removeOwned(test: Awaited<ReturnType<typeof setup>>) {
|
||||
try {
|
||||
const handle = test.ctx.subprocess.spawn({
|
||||
argv: [test.hello.node, '-e', 'require("node:fs").rmSync(process.argv[1],{recursive:true,force:true})', test.root],
|
||||
argv: [test.hello.executable, '-e', 'require("node:fs").rmSync(process.argv[1],{recursive:true,force:true})', test.root],
|
||||
cwd: test.hello.workspace, stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 500,
|
||||
})
|
||||
await processResult(handle)
|
||||
@@ -67,13 +68,13 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
const test = await setup()
|
||||
try {
|
||||
const prepared = await test.ctx.ssh.request('process.prepare', {
|
||||
argv: [test.hello.node, '-e', 'process.stdout.write("legitimate-target")'], cwd: test.root, graceMs: 500,
|
||||
argv: [test.hello.executable, '-e', 'process.stdout.write("legitimate-target")'], cwd: test.root, graceMs: 500,
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' },
|
||||
}, preparedSchema)
|
||||
const endpoint = prepared.streams.control!
|
||||
const attack = "let connected=false,secure=false;const s=require('node:tls').connect({path:process.argv[1],ciphers:'PSK-AES256-GCM-SHA384',minVersion:'TLSv1.2',maxVersion:'TLSv1.2',pskCallback:()=>({identity:'dsh-stream',psk:Buffer.alloc(32)}),checkServerIdentity:()=>undefined});s.on('connect',()=>{connected=true});s.on('secureConnect',()=>{secure=true});s.on('error',()=>{});s.on('close',()=>process.stdout.write(JSON.stringify({connected,secure})));"
|
||||
const attacker = test.ctx.subprocess.spawn({
|
||||
argv: (await test.ctx.sandbox.confine([test.hello.node, '-e', attack, endpoint.path], {
|
||||
argv: (await test.ctx.sandbox.confine([test.hello.executable, '-e', attack, endpoint.path], {
|
||||
mode: 'read-only', workspaceRoot: test.root,
|
||||
})).argv,
|
||||
cwd: test.root, stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 500,
|
||||
@@ -124,7 +125,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
const createLink = 'const fs=require(\'node:fs\');const root=process.argv[1];fs.mkdirSync(root+\'/physical/child\',{recursive:true});fs.mkdirSync(root+\'/lexical\');fs.symlinkSync(root+\'/physical/child\',root+\'/lexical/link\');'
|
||||
await processResult(ctx.subprocess.spawn({
|
||||
argv: [hello.node, '-e', createLink, root], cwd: root,
|
||||
argv: [hello.executable, '-e', createLink, root], cwd: root,
|
||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 500,
|
||||
}))
|
||||
const resolved = await ctx.fs.resolve('file.txt', { cwd: `${root}/lexical/link/..` })
|
||||
@@ -139,7 +140,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
const test = await setup()
|
||||
try {
|
||||
const code = 'const fs=require(\'node:fs\');const s=new(require(\'node:net\').Socket)({fd:7,readable:true,writable:true,allowHalfOpen:true});let a=[];s.on(\'data\',b=>a.push(b));s.on(\'end\',()=>{let denied;try{fs.writeFileSync(\'forbidden\',\'bad\')}catch(e){denied=e.code}process.stdout.write(JSON.stringify({pid:process.pid,denied,cgroup:process.platform===\'linux\'?fs.readFileSync(\'/proc/self/cgroup\',\'utf8\'):\'\'}));process.stderr.write(\'stderr\');s.end(Buffer.concat(a))});'
|
||||
const argv = (await test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'read-only', workspaceRoot: test.root })).argv
|
||||
const argv = (await test.ctx.sandbox.confine([test.hello.executable, '-e', code], { mode: 'read-only', workspaceRoot: test.root })).argv
|
||||
const handle = test.ctx.subprocess.spawn({
|
||||
argv, cwd: test.root,
|
||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 }, control: 'pipe' }, graceMs: 1000,
|
||||
@@ -167,7 +168,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
try {
|
||||
const code = 'const s=new(require(\'node:net\').Socket)({fd:7,readable:true,writable:true});const b=Buffer.alloc(65536,120);let n=0;function pump(){while(n<10000){n++;if(!process.stdout.write(b))return}}process.stdout.on(\'drain\',pump);s.on(\'data\',v=>s.write(v));pump();setInterval(()=>{},1000);'
|
||||
handle = test.ctx.subprocess.spawn({
|
||||
argv: (await test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'workspace-write', workspaceRoot: test.root })).argv,
|
||||
argv: (await test.ctx.sandbox.confine([test.hello.executable, '-e', code], { mode: 'workspace-write', workspaceRoot: test.root })).argv,
|
||||
cwd: test.root, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 }, control: 'pipe' }, graceMs: 500,
|
||||
})
|
||||
const reply = once(handle.control!, 'data')
|
||||
@@ -215,7 +216,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
try {
|
||||
const code = 'const fs=require(\'node:fs\');require(\'node:child_process\').spawn(process.execPath,[\'-e\',\'setInterval(()=>{},1000)\'],{detached:true,stdio:\'ignore\'}).unref();fs.appendFileSync(\'launches\',\'one\\n\');process.stdout.write(fs.readFileSync(\'/proc/self/cgroup\',\'utf8\'));setInterval(()=>{},1000);'
|
||||
const handle = victim.ctx.subprocess.spawn({
|
||||
argv: [victim.hello.node, '-e', code], cwd: victim.root,
|
||||
argv: [victim.hello.executable, '-e', code], cwd: victim.root,
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4096 } }, graceMs: 500,
|
||||
})
|
||||
const [bytes] = await once(handle.stdout!, 'data') as [Buffer]
|
||||
@@ -238,7 +239,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
await victimConnection.dispose()
|
||||
try {
|
||||
await processResult(observer.ctx.subprocess.spawn({
|
||||
argv: [observer.hello.node, '-e', 'require("node:fs").rmSync(process.argv[1],{recursive:true,force:true})', victim.root],
|
||||
argv: [observer.hello.executable, '-e', 'require("node:fs").rmSync(process.argv[1],{recursive:true,force:true})', victim.root],
|
||||
cwd: observer.root, stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 500,
|
||||
}))
|
||||
} finally { await removeOwned(observer) }
|
||||
@@ -248,7 +249,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
it.skipIf(bootstrap === undefined)('runs PTC remotely with empty environment, bindings, denial and a bounded hot loop', async () => {
|
||||
const test = await setup()
|
||||
const fiber = test.ctx.plugin(NodePtcRuntime, {
|
||||
nodeExecutable: test.ctx.ssh.nodeExecutable, bootstrapPath: test.ctx.ssh.bootstrapPath,
|
||||
launch: test.ctx.ssh.ptcLaunch,
|
||||
})
|
||||
try {
|
||||
await fiber
|
||||
@@ -281,7 +282,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
await test.ctx.fs.writeText(await test.ctx.fs.resolve(`${test.root}/tsconfig.json`), '{"compilerOptions":{"strict":true}}')
|
||||
await test.ctx.fs.writeText(await test.ctx.fs.resolve(`${test.root}/source.ts`), 'export const answer: number = 42\nexport const doubled = answer * 2\n')
|
||||
stdio = test.ctx.plugin(LspStdio, { servers: { remoteTypescript: {
|
||||
command: test.hello.node, args: [languageServer as string, '--stdio'], extensionToLanguage: { '.ts': 'typescript' },
|
||||
command: test.hello.executable, args: [languageServer as string, '--stdio'], extensionToLanguage: { '.ts': 'typescript' },
|
||||
} } })
|
||||
await stdio
|
||||
const request = { filePath: 'source.ts', workspaceRoot: test.root, position: { line: 1, character: 24 } }
|
||||
|
||||
@@ -52,12 +52,12 @@ class Child extends EventEmitter {
|
||||
}
|
||||
|
||||
const config: Config = {
|
||||
host: 'test-alias', node: '/remote/node', helper: '/remote/helper.js', helperHash: 'a'.repeat(64), workspace: '/remote/workspace',
|
||||
host: 'test-alias', launch: { kind: 'node-script', node: '/remote/node' }, helper: '/remote/helper.js', helperHash: 'a'.repeat(64), workspace: '/remote/workspace',
|
||||
requestTimeoutMs: 1000, maxFrameBytes: 4096, maxPending: 8, leaseMs: 30_000,
|
||||
}
|
||||
const hello = {
|
||||
protocol: 1, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0',
|
||||
node: '/canonical/node', root: '/tmp/remote-helper', workspace: '/canonical/workspace',
|
||||
protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0',
|
||||
kind: 'node-script', executable: '/canonical/node', root: '/tmp/remote-helper', workspace: '/canonical/workspace',
|
||||
}
|
||||
|
||||
function setup(options: {
|
||||
@@ -133,11 +133,11 @@ function setup(options: {
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('SSH connection startup', () => {
|
||||
it.each([
|
||||
{ host: '-option' }, { host: 'alias; command' }, { node: 'relative' }, { helperHash: 'bad' },
|
||||
{ bootstrapPath: '/remote/bootstrap.js' }, { bootstrapHash: 'b'.repeat(64) },
|
||||
{ host: '-option' }, { host: 'alias; command' }, { launch: { kind: 'node-script', node: 'relative' } }, { helperHash: 'bad' },
|
||||
{ launch: { kind: 'node-script', node: '/remote/node', bootstrapPath: '/remote/bootstrap.js' } }, { launch: { kind: 'node-script', node: '/remote/node', bootstrapHash: 'b'.repeat(64) } },
|
||||
{ requestTimeoutMs: 0 }, { requestTimeoutMs: 2_147_483_648 },
|
||||
{ maxFrameBytes: 64 * 1024 * 1024 + 1 }, { maxPending: 129 }, { leaseMs: 2999 },
|
||||
])('rejects invalid deployment configuration before SSH starts: %j', (invalid) => {
|
||||
] satisfies Partial<Config>[])('rejects invalid deployment configuration before SSH starts: %j', (invalid) => {
|
||||
expect(() => setup({ config: invalid })).toThrow()
|
||||
expect(transport.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -150,37 +150,47 @@ describe.skipIf(process.platform === 'win32')('SSH connection startup', () => {
|
||||
})
|
||||
|
||||
it('publishes only verified remote coordinates and quotes the configured executable paths', async () => {
|
||||
const test = setup({ holdHello: true, config: { helper: "/remote/helper's file.js", bootstrapPath: '/remote/process.js', bootstrapHash: 'b'.repeat(64) }, hello: { bootstrapHash: 'b'.repeat(64) } })
|
||||
expect(() => test.service.nodeExecutable).toThrow('not ready')
|
||||
expect(() => test.service.bootstrapPath).toThrow('verified bootstrapPath')
|
||||
const test = setup({ holdHello: true, config: { helper: "/remote/helper's file.js", launch: { kind: 'node-script', node: '/remote/node', bootstrapPath: '/remote/process.js', bootstrapHash: 'b'.repeat(64) } }, hello: { bootstrapHash: 'b'.repeat(64) } })
|
||||
expect(() => test.service.ptcLaunch).toThrow('not ready')
|
||||
await test.helloEntered
|
||||
test.releaseHello()
|
||||
await test.service[Service.init]()
|
||||
expect(test.service.nodeExecutable).toBe('/canonical/node')
|
||||
expect(test.service.bootstrapPath).toBe('/remote/process.js')
|
||||
expect(test.service.ptcLaunch).toEqual({ kind: 'node-script', executable: '/canonical/node', bootstrapPath: '/remote/process.js' })
|
||||
const argv = transport.spawn.mock.calls[0]?.[1] as string[]
|
||||
expect(argv).toContain('StrictHostKeyChecking=yes')
|
||||
expect(argv).toContain('ForwardAgent=no')
|
||||
expect(argv.at(-1)).toBe("'/remote/node' '--disable-sigusr1' '/remote/helper'\\''s file.js'")
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 1, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' })
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 2, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' })
|
||||
})
|
||||
|
||||
it('permits filesystem-only deployments and refuses an unconfigured PTC bootstrap getter', async () => {
|
||||
const test = setup()
|
||||
await test.service.ready
|
||||
expect(test.service.nodeExecutable).toBe('/canonical/node')
|
||||
expect(() => test.service.bootstrapPath).toThrow('verified bootstrapPath')
|
||||
expect(() => test.service.ptcLaunch).toThrow('verified bootstrapPath')
|
||||
expect(await test.service.request('echo', { value: 42 }, z.object({ value: z.number() }))).toEqual({ value: 42 })
|
||||
})
|
||||
|
||||
it('launches an executable directly and publishes its embedded PTC worker', async () => {
|
||||
const test = setup({
|
||||
config: { launch: { kind: 'executable' }, helper: '/remote/helper with spaces' },
|
||||
hello: { kind: 'executable', executable: '/canonical/dsh-ssh-helper' },
|
||||
})
|
||||
await test.service.ready
|
||||
expect(test.service.ptcLaunch).toEqual({ kind: 'embedded', executable: '/canonical/dsh-ssh-helper' })
|
||||
const argv = transport.spawn.mock.calls[0]?.[1] as string[]
|
||||
expect(argv.at(-1)).toBe("NODE_OPTIONS='--disable-sigusr1' '/remote/helper with spaces'")
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 2, workspace: '/remote/workspace', leaseMs: 30_000 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ hello: { hash: 'c'.repeat(64) }, error: 'helper digest' },
|
||||
{ hello: { bootstrapHash: 'c'.repeat(64) }, error: 'bootstrap digest' },
|
||||
{ hello: { protocol: 2 }, error: 'Invalid' },
|
||||
{ hello: { protocol: 1 }, error: 'Invalid' },
|
||||
{ hello: { kind: 'executable' }, error: 'launch kind' },
|
||||
])('refuses a mismatched helper identity before readiness: $error', async ({ hello, error }) => {
|
||||
const test = setup({ hello })
|
||||
await expect(test.service.ready).rejects.toThrow(error)
|
||||
expect(() => test.service.nodeExecutable).toThrow('not ready')
|
||||
expect(() => test.service.ptcLaunch).toThrow('not ready')
|
||||
await expect(test.service.request('echo', {}, z.unknown())).rejects.toThrow()
|
||||
})
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+115
@@ -181,6 +181,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^4.1.8
|
||||
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
apps/cli:
|
||||
dependencies:
|
||||
@@ -11535,6 +11538,118 @@ importers:
|
||||
'@deepseek-ai/dsh-subprocess-ssh':
|
||||
specifier: workspace:^
|
||||
version: link:../subprocess-ssh
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/values
|
||||
|
||||
packages/ssh/ssh-helper-runtime:
|
||||
dependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/cosmokit':
|
||||
specifier: link:../../../vendor/cosmokit
|
||||
version: link:../../../vendor/cosmokit
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-fs':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/fs
|
||||
'@deepseek-ai/dsh-fs-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/fs-local
|
||||
'@deepseek-ai/dsh-fs-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/fs-sandbox
|
||||
'@deepseek-ai/dsh-http-proxy':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/http-proxy
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@deepseek-ai/dsh-lazy-require':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/lazy-require
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-ptc-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../ptc-runtime/ptc-runtime
|
||||
'@deepseek-ai/dsh-ptc-runtime-node':
|
||||
specifier: workspace:^
|
||||
version: link:../../ptc-runtime/ptc-runtime-node
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
'@deepseek-ai/dsh-sandbox-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-local
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-policy
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-windows-acl
|
||||
'@deepseek-ai/dsh-scope':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/scope
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-ssh':
|
||||
specifier: workspace:^
|
||||
version: link:../ssh
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess-local
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
'@deepseek-ai/dsh-util-crypto':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/crypto
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/values
|
||||
'@deepseek-ai/dsh-win32-process':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/win32-process
|
||||
'@deepseek-ai/node-addon-system':
|
||||
specifier: workspace:^
|
||||
version: link:../../../native/system/packages/entry
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-app-boot':
|
||||
specifier: workspace:^
|
||||
version: link:../../boot/app-boot
|
||||
'@deepseek-ai/dsh-fs-ssh':
|
||||
specifier: workspace:^
|
||||
version: link:../fs-ssh
|
||||
'@deepseek-ai/dsh-sandbox-ssh':
|
||||
specifier: workspace:^
|
||||
version: link:../sandbox-ssh
|
||||
'@deepseek-ai/dsh-subprocess-ssh':
|
||||
specifier: workspace:^
|
||||
version: link:../subprocess-ssh
|
||||
|
||||
packages/ssh/subprocess-ssh:
|
||||
dependencies:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './executable-native-pty.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { materializeStagedLinks, pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './executable-native-pty.ts'
|
||||
import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
|
||||
import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts'
|
||||
|
||||
@@ -222,29 +223,6 @@ class BuildCli {
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmInvocation(args: string[]): [command: string, args: string[]] {
|
||||
const entrypoint = process.env.npm_execpath?.trim()
|
||||
if (entrypoint !== undefined && entrypoint !== '') {
|
||||
const extension = extname(entrypoint).toLowerCase()
|
||||
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
||||
return [process.execPath, [entrypoint, ...args]]
|
||||
}
|
||||
if (extension !== '.cmd') return [entrypoint, args]
|
||||
}
|
||||
const home = process.env.PNPM_HOME?.trim()
|
||||
if (home !== undefined && home !== '') {
|
||||
const packageBin = resolve(home, '..', 'pnpm', 'bin')
|
||||
for (const filename of ['pnpm.mjs', 'pnpm.cjs']) {
|
||||
const candidate = resolve(packageBin, filename)
|
||||
if (existsSync(candidate)) return [process.execPath, [candidate, ...args]]
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.')
|
||||
}
|
||||
return ['pnpm', args]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a command for logs and errors, quoting arguments with spaces.
|
||||
* @param command - the executable.
|
||||
@@ -289,7 +267,7 @@ class SingleExeBuild {
|
||||
}
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
|
||||
else await rm(this.staging, { recursive: true, force: true })
|
||||
await this.runPnpm('deploy', [
|
||||
try { await this.runPnpm('deploy', [
|
||||
'--filter',
|
||||
DEPLOY_ROOT_PACKAGE,
|
||||
'deploy',
|
||||
@@ -301,7 +279,10 @@ class SingleExeBuild {
|
||||
'--config.auto-install-peers=false',
|
||||
'--config.link-workspace-packages=true',
|
||||
this.staging,
|
||||
])
|
||||
]) } finally {
|
||||
// Legacy deploy records production-only workspace state; restore the development installation before exec.
|
||||
await this.runPnpm('restore development dependencies', ['install', '--offline', '--frozen-lockfile', '--prod=false', '--ignore-scripts'])
|
||||
}
|
||||
await this.restoreLegacyHoists()
|
||||
await this.materializeStagedLinks()
|
||||
if (this.cli.dryRun) {
|
||||
@@ -322,38 +303,8 @@ class SingleExeBuild {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy')
|
||||
return
|
||||
}
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES)
|
||||
const restored: string[] = []
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
|
||||
const destination = join(this.staging, 'node_modules', dependency)
|
||||
if (existsSync(destination)) continue
|
||||
const source = join(sourceNodeModules, dependency)
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
`build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
|
||||
)
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
restored.push(dependency)
|
||||
}
|
||||
const stillMissing = Object.keys(manifest.dependencies ?? {})
|
||||
.filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency)))
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
|
||||
}
|
||||
if (restored.length > 0) {
|
||||
console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
|
||||
}
|
||||
const restored = await restoreLegacyHoists(this.staging, resolve(root, DEPLOY_SOURCE_NODE_MODULES))
|
||||
if (restored.length > 0) console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
|
||||
}
|
||||
|
||||
/** Replace deploy-time package links with files and reject any remaining link. */
|
||||
@@ -362,41 +313,7 @@ class SingleExeBuild {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links')
|
||||
return
|
||||
}
|
||||
const nodeModules = join(this.staging, 'node_modules')
|
||||
let remaining = await this.findSymlink(nodeModules)
|
||||
while (remaining !== undefined) {
|
||||
const segments = remaining.slice(nodeModules.length + 1).split(sep)
|
||||
const binIndex = segments.lastIndexOf('.bin')
|
||||
if (binIndex >= 0) {
|
||||
await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
|
||||
remaining = await this.findSymlink(nodeModules)
|
||||
continue
|
||||
}
|
||||
const destination = remaining
|
||||
const source = await realpath(destination)
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
remaining = await this.findSymlink(nodeModules)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the first symbolic link below a directory, if one exists. */
|
||||
private async findSymlink(directory: string): Promise<string | undefined> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) return path
|
||||
if (metadata.isDirectory()) {
|
||||
const nested = await this.findSymlink(path)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
await materializeStagedLinks(join(this.staging, 'node_modules'))
|
||||
}
|
||||
|
||||
/** Add the executable entry and pkg assets to the staged manifest. */
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/** Build a relocatable, native-host SSH helper archive with its private Node workers. */
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { build } from 'tsdown'
|
||||
import { create as createTar } from 'tar'
|
||||
import { verifyRuntimeClosure } from './verify-runtime-closure.ts'
|
||||
import { materializeStagedLinks, pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts'
|
||||
import { resolveLinuxNodePtyAddon } from './executable-native-pty.ts'
|
||||
import { artifactManifestSchema, fileDigest, payloadFiles, SSH_HELPER_TARGETS, verifyPayload, type SshHelperTarget } from './ssh-helper/artifact.ts'
|
||||
import { probeHelper } from './ssh-helper/probe.ts'
|
||||
import { verifyNativePayload } from './ssh-helper/native.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const closureDirectory = 'packages/ssh/ssh-helper-runtime'
|
||||
const closureName = '@deepseek-ai/dsh-ssh-helper-runtime'
|
||||
const assets = ['package.json', 'lib/**/*.js', ...['js', 'cjs', 'mjs', 'json', 'node', 'so', 'so.*', 'dylib', 'wasm', 'sh', 'bash'].map(extension => `node_modules/**/*.${extension}`)]
|
||||
|
||||
interface Manifest { name: string; version: string; license?: string; dependencies?: Record<string, string> }
|
||||
|
||||
async function manifest(path: string): Promise<Manifest> { return JSON.parse(await readFile(path, 'utf8')) as Manifest }
|
||||
|
||||
async function run(command: string, args: string[]): Promise<void> {
|
||||
console.log(`ssh-helper build: ${[command, ...args].join(' ')}`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, stdio: 'inherit', env: { ...process.env, CI: 'true' } })
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`SSH helper build command failed (${code ?? signal}): ${command}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function pnpm(...args: string[]): Promise<void> { const [command, argv] = pnpmInvocation(args); await run(command, argv) }
|
||||
|
||||
async function compileRuntime(dependencies: Readonly<Record<string, string>>): Promise<void> {
|
||||
await pnpm('exec', 'tsx', 'native/system/scripts/build.ts')
|
||||
await pnpm('exec', 'tsc', '-b', closureDirectory, 'native/system/packages/entry')
|
||||
const names = new Set([closureName, ...Object.keys(dependencies)])
|
||||
for (const path of globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }).sort()) {
|
||||
if (!names.has((await manifest(join(root, path))).name)) continue
|
||||
const cwd = dirname(join(root, path))
|
||||
const config = join(cwd, 'tsdown.config.ts')
|
||||
if (existsSync(config)) await build({ cwd, config })
|
||||
else await build({ cwd, config: false, entry: ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareNative(staging: string, product: string, platform: string, arch: 'x64' | 'arm64'): Promise<void> {
|
||||
const pty = join(staging, 'node_modules', 'node-pty')
|
||||
await rm(join(pty, 'build'), { recursive: true, force: true })
|
||||
for (const entry of await readdir(join(pty, 'prebuilds'))) {
|
||||
if (entry !== `${platform}-${arch}`) await rm(join(pty, 'prebuilds', entry), { recursive: true, force: true })
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
const addon = resolveLinuxNodePtyAddon(join(root, 'packages/subprocess/subprocess-local/node_modules/node-pty'), arch)
|
||||
await mkdir(join(pty, 'build/Release'), { recursive: true })
|
||||
await copyFile(addon, join(pty, 'build/Release/pty.node'))
|
||||
} else {
|
||||
const destination = join(product, 'dsh-ssh-helper-spawn-helper')
|
||||
await copyFile(join(pty, 'prebuilds', `darwin-${arch}`, 'spawn-helper'), destination)
|
||||
await chmod(destination, 0o755)
|
||||
}
|
||||
const source = join(root, 'native/system/packages', `${platform}-${arch}`)
|
||||
const destination = join(product, 'native/system')
|
||||
await mkdir(destination, { recursive: true })
|
||||
await copyFile(join(source, 'package.json'), join(destination, 'package.json'))
|
||||
await cp(join(source, 'bin'), join(destination, 'bin'), { recursive: true })
|
||||
// The Linux carrier targets glibc; no unused musl addon travels in this archive.
|
||||
if (platform === 'linux') await rm(join(destination, 'bin/musl'), { recursive: true, force: true })
|
||||
const binaries = platform === 'linux' ? ['bin/landlock-run', 'bin/glibc/system.node'] : ['bin/system.node']
|
||||
for (const binary of binaries) {
|
||||
if (!(await lstat(join(destination, binary))).isFile()) throw new Error(`Missing SSH native resource: ${binary}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLicenses(staging: string, product: string): Promise<void> {
|
||||
const destination = join(product, 'LICENSES')
|
||||
await mkdir(destination, { recursive: true })
|
||||
const inventory: { name: string; version: string; license?: string }[] = []
|
||||
for (const path of globSync(['node_modules/*/package.json', 'node_modules/@*/*/package.json'], { cwd: staging }).sort()) {
|
||||
const directory = dirname(join(staging, path))
|
||||
const data = await manifest(join(staging, path))
|
||||
inventory.push({ name: data.name, version: data.version, ...(data.license === undefined ? {} : { license: data.license }) })
|
||||
const licenses = (await readdir(directory)).filter(name => /^(?:licen[cs]e|copying|notice)(?:[.-]|$)/i.test(name))
|
||||
for (const name of licenses) {
|
||||
const source = join(directory, name)
|
||||
if (!(await lstat(source)).isFile()) continue
|
||||
const output = join(destination, data.name.replaceAll('/', '__'), name)
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
await copyFile(source, output)
|
||||
}
|
||||
}
|
||||
await copyFile(join(root, 'LICENSE'), join(destination, 'deepseek-harness-MIT.txt'))
|
||||
await copyFile(join(root, 'native/system/LICENSE'), join(destination, 'node-addon-system-BSD.txt'))
|
||||
await writeFile(join(destination, 'packages.json'), `${JSON.stringify(inventory, null, 2)}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selected native target and retain only its complete release archive.
|
||||
* @param argv - command-line arguments, excluding the interpreter and script.
|
||||
*/
|
||||
export async function buildSshHelper(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({ args: argv, options: {
|
||||
target: { type: 'string' }, output: { type: 'string' },
|
||||
'skip-build': { type: 'boolean', default: false }, 'dry-run': { type: 'boolean', default: false },
|
||||
} })
|
||||
const host = `node24-${process.platform === 'darwin' ? 'macos' : process.platform}-${process.arch}`
|
||||
const selected = values.target ?? host
|
||||
if (!SSH_HELPER_TARGETS.some(target => target === selected)) throw new Error(`Unsupported SSH helper target: ${selected}`)
|
||||
if (!values['dry-run'] && selected !== host) throw new Error(`SSH helper target ${selected} requires its native host; current host is ${host}`)
|
||||
const target = selected as SshHelperTarget
|
||||
const definition = await manifest(join(root, closureDirectory, 'package.json'))
|
||||
const closure = await verifyRuntimeClosure(root, `${closureDirectory}/package.json`, false)
|
||||
if (closure.failures.length > 0) throw new Error(`SSH helper runtime dependencies are incomplete:\n${closure.failures.join('\n')}`)
|
||||
const output = resolve(root, values.output ?? 'dist-exe/ssh-helper')
|
||||
if (values['dry-run']) {
|
||||
console.log(`SSH helper ${target}: ${values['skip-build'] ? 'reuse built modules' : 'compile runtime'} -> deploy ${closureName} -> pkg --sea -> handshake -> ${output}`)
|
||||
return
|
||||
}
|
||||
if (!values['skip-build']) await compileRuntime(definition.dependencies ?? {})
|
||||
await mkdir(join(root, '.dsh-build'), { recursive: true })
|
||||
const temporary = await mkdtemp(join(root, '.dsh-build/ssh-helper-'))
|
||||
try {
|
||||
const staging = join(temporary, 'staging')
|
||||
const product = join(temporary, 'dsh-ssh-helper')
|
||||
await mkdir(product)
|
||||
try {
|
||||
await pnpm('--filter', closureName, 'deploy', '--legacy', '--prod', '--config.allow-unused-patches=true', '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', staging)
|
||||
} finally {
|
||||
// Legacy deploy records production-only workspace state; pnpm's next exec otherwise prunes build tools.
|
||||
await pnpm('install', '--offline', '--frozen-lockfile', '--prod=false', '--ignore-scripts')
|
||||
}
|
||||
await restoreLegacyHoists(staging, join(root, closureDirectory, 'node_modules'))
|
||||
await materializeStagedLinks(join(staging, 'node_modules'))
|
||||
await prepareNative(staging, product, process.platform, process.arch as 'x64' | 'arm64')
|
||||
await verifyNativePayload(join(staging, 'node_modules'), target)
|
||||
const stagedManifest = await manifest(join(staging, 'package.json'))
|
||||
await writeFile(join(staging, 'runtime-bootstrap.mjs'), 'import { runSshHelperRuntime } from "./lib/index.js";\nawait runSshHelperRuntime();\n')
|
||||
await writeFile(join(staging, 'package.json'), `${JSON.stringify({ ...stagedManifest,
|
||||
bin: 'runtime-bootstrap.mjs', pkg: { assets, ignore: ['node_modules/@deepseek-ai/node-addon-system-*/**/*'] },
|
||||
}, null, 2)}\n`)
|
||||
const executable = join(product, 'dsh-ssh-helper')
|
||||
await pnpm('exec', 'pkg', staging, '--sea', '--targets', target, '--output', executable)
|
||||
await chmod(executable, 0o755)
|
||||
await verifyNativePayload(product, target)
|
||||
await copyLicenses(staging, product)
|
||||
const hello = await probeHelper(executable, product)
|
||||
if (hello.kind !== 'executable' || hello.executable !== executable) throw new Error('Packaged SSH helper did not report its physical executable')
|
||||
const metadata = artifactManifestSchema.parse({
|
||||
format: 1, version: definition.version, target,
|
||||
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(),
|
||||
dirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() !== '',
|
||||
protocol: hello.protocol, nodeVersion: hello.nodeVersion,
|
||||
files: await Promise.all((await payloadFiles(product)).map(async path => ({
|
||||
path, sha256: await fileDigest(join(product, path)), mode: (await lstat(join(product, path))).mode & 0o777,
|
||||
}))),
|
||||
})
|
||||
await writeFile(join(product, 'manifest.json'), `${JSON.stringify(metadata, null, 2)}\n`)
|
||||
await verifyPayload(product)
|
||||
await mkdir(output, { recursive: true })
|
||||
const archive = join(output, `dsh-ssh-helper-${definition.version}-${target.slice('node24-'.length)}.tar.gz`)
|
||||
await createTar({ cwd: temporary, file: archive, gzip: true, portable: true }, ['dsh-ssh-helper'])
|
||||
await writeFile(`${archive}.sha256`, `${await fileDigest(archive)} ${basename(archive)}\n`)
|
||||
console.log(`SSH helper archive: ${archive}`)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await buildSshHelper(process.argv.slice(2))
|
||||
@@ -324,6 +324,8 @@ export function checkExperimentalManifest(
|
||||
}
|
||||
|
||||
function isReleaseMemberDirectory(dir: string): boolean {
|
||||
// The SSH carrier is distributed as executable archives, never as an npm application.
|
||||
if (dir === 'packages/ssh/ssh-helper-runtime') return false
|
||||
return standardReleaseMemberDirectory.test(dir) || isPublicExperimentalPackageDirectory(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ describe('CI workflow', () => {
|
||||
})
|
||||
.map(([name]) => name)
|
||||
.sort()
|
||||
expect(pushReachable).toEqual(['python-runtime', 'serial-linux-selfhosted', 'serial-windows', 'windows'])
|
||||
expect(pushReachable).toEqual(['python-runtime', 'serial-linux-selfhosted', 'serial-windows', 'ssh-helper', 'windows'])
|
||||
|
||||
// Manual benchmarks retain their bounded fan-out.
|
||||
for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark']) {
|
||||
@@ -607,6 +607,58 @@ describe('CI workflow', () => {
|
||||
expect(aggregate.needs).toContain('python-runtime')
|
||||
})
|
||||
|
||||
it('requires SSH helper acceptance before publication and PR aggregation', () => {
|
||||
const ci = loadWorkflow('.github/workflows/ci.yml')
|
||||
expect(workflowJob(ci, 'ssh-helper')).toMatchObject({
|
||||
uses: './.github/workflows/build-exe-for-ssh-helper.yml', with: { targets: 'node24-linux-x64' },
|
||||
})
|
||||
expect(workflowJob(ci, 'all-checks-passed').needs).toContain('ssh-helper')
|
||||
const master = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), 'ssh-helper')
|
||||
expect(master.if).toContain("inputs.suite == 'ssh-helper'")
|
||||
if (!isRecord(master.with)) throw new Error('SSH master caller must select native targets')
|
||||
expect(evaluateRunsOn(master.with.targets, { github: { event_name: 'push' } })).toBe('node24-linux-arm64,node24-macos-arm64,node24-macos-x64')
|
||||
expect(evaluateRunsOn(master.with.targets, { github: { event_name: 'workflow_dispatch' } })).toBe('node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64')
|
||||
const workflow = loadWorkflow('.github/workflows/build-exe-for-ssh-helper.yml')
|
||||
const build = workflowJob(workflow, 'build')
|
||||
if (!Array.isArray(build.steps)) throw new Error('SSH build must define its verification steps')
|
||||
const steps = build.steps.filter(isRecord)
|
||||
const artifact = steps.findIndex(step => String(step.run).includes('scripts/verify-ssh-helper-artifact.ts'))
|
||||
const transport = steps.findIndex(step => String(step.run).includes('scripts/verify-ssh-helper-ssh.ts'))
|
||||
const landlock = steps.findIndex(step => String(step.run).includes('--backend=landlock-run'))
|
||||
const upload = steps.findIndex(step => String(step.uses).startsWith('actions/upload-artifact@'))
|
||||
expect(artifact).toBeGreaterThan(-1)
|
||||
expect(landlock).toBeGreaterThan(artifact)
|
||||
expect(transport).toBeGreaterThan(artifact)
|
||||
expect(upload).toBeGreaterThan(transport)
|
||||
for (const index of [artifact, transport, landlock, upload]) expect(steps[index]).not.toHaveProperty('continue-on-error', true)
|
||||
const publish = loadWorkflow('.github/workflows/publish-ssh-helper.yml')
|
||||
expect(publish.permissions).toEqual({ contents: 'read' })
|
||||
expect(workflowJob(publish, 'build')).toMatchObject({ needs: 'validate', with: { release: true } })
|
||||
expect(workflowJob(publish, 'publish')).toMatchObject({ needs: ['validate', 'build'], permissions: { contents: 'write' } })
|
||||
})
|
||||
|
||||
it('validates the SSH native matrix before allocating runners', () => {
|
||||
const plan = workflowJob(loadWorkflow('.github/workflows/build-exe-for-ssh-helper.yml'), 'plan')
|
||||
if (!Array.isArray(plan.steps)) throw new Error('SSH matrix plan has no steps')
|
||||
const step: unknown = plan.steps.find(value => isRecord(value) && value.id === 'targets')
|
||||
if (!isRecord(step) || typeof step.run !== 'string') throw new Error('SSH matrix plan has no script')
|
||||
const source = step.run.split("<<'JS'\n")[1]?.split('\nJS')[0]?.replace(/^import .*;\n/gm, '')
|
||||
if (source === undefined) throw new Error('SSH matrix plan must be an executable Node script')
|
||||
const evaluate = (requested: string): unknown => {
|
||||
let output = ''
|
||||
runInNewContext(source, {
|
||||
process: { env: { REQUESTED_TARGETS: requested, GITHUB_OUTPUT: 'output' } },
|
||||
appendFileSync: (_path: string, text: string) => { output += text },
|
||||
}, { timeout: 1000 })
|
||||
return JSON.parse(output.slice('matrix='.length))
|
||||
}
|
||||
expect(evaluate('')).toHaveLength(4)
|
||||
expect(evaluate('node24-linux-arm64')).toEqual([{ target: 'node24-linux-arm64', runner: 'ubuntu-24.04-arm' }])
|
||||
for (const invalid of ['node24-linux-arm64,node24-linux-arm64', 'node24-win-x64', 'node24-linux-x64,', ' ']) {
|
||||
expect(() => evaluate(invalid)).toThrow('Invalid or duplicate')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every Vitest project process-isolated on native Windows', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/sandbox/sandbox-windows-acl': 'Windows ACL sandbox library consumed by sandbox-local.',
|
||||
'packages/sdk/client': 'Client-process library; the spawned runtime owns plugin behavior.',
|
||||
'packages/sdk/protocol': 'Wire-protocol library with type declarations only.',
|
||||
'packages/ssh/ssh-helper-runtime': 'Private executable entry consumed by the packaging pipeline; no Cordis plugin export.',
|
||||
'packages/session/session-format': 'Pure Session format planning, codec dispatch, and lossless JSON library.',
|
||||
'packages/session/session-format-catalog': 'Generated build-static Session format inventory with no plugin registration.',
|
||||
'packages/session/session-format-v0-to-v1': 'Pure released-v0 codec and adjacent migration library.',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Resolve the native node-pty input used by the Python SDK runtime builder. */
|
||||
/** Resolve the native node-pty input used by the SDK and SSH executable builders. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -18,7 +18,7 @@ export function resolveLinuxNodePtyAddon(
|
||||
const prebuilt = join(packageDirectory, 'prebuilds', `linux-${arch}`, 'pty.node')
|
||||
if (existsSync(prebuilt)) return prebuilt
|
||||
throw new Error(
|
||||
`build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`,
|
||||
`executable-native-pty: node-pty addon is absent from both ${built} and ${prebuilt}.`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function resolveWindowsNodePtyAddons(
|
||||
]
|
||||
const missing = addons.filter(path => !existsSync(path))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: Windows node-pty addons are missing: ${missing.join(', ')}.`)
|
||||
throw new Error(`executable-native-pty: Windows node-pty addons are missing: ${missing.join(', ')}.`)
|
||||
}
|
||||
return addons
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { materializeStagedLinks, restoreLegacyHoists } from './executable-packaging.ts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-exe-deploy-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const source = join(root, 'source')
|
||||
const staging = join(root, 'staging')
|
||||
await mkdir(source)
|
||||
await mkdir(join(staging, 'node_modules'), { recursive: true })
|
||||
return { root, source, staging, modules: join(staging, 'node_modules') }
|
||||
}
|
||||
|
||||
it('restores omitted direct peers without copying their nested module instances', async () => {
|
||||
const test = await fixture()
|
||||
await writeFile(join(test.staging, 'package.json'), JSON.stringify({ dependencies: { peer: 'workspace:^' } }))
|
||||
await mkdir(join(test.source, 'peer/node_modules/shadow'), { recursive: true })
|
||||
await writeFile(join(test.source, 'peer/index.js'), 'export const identity = 1\n')
|
||||
await writeFile(join(test.source, 'peer/node_modules/shadow/index.js'), 'shadow')
|
||||
expect(await restoreLegacyHoists(test.staging, test.source)).toEqual(['peer'])
|
||||
expect(await readFile(join(test.modules, 'peer/index.js'), 'utf8')).toBe('export const identity = 1\n')
|
||||
await expect(lstat(join(test.modules, 'peer/node_modules'))).rejects.toThrow('ENOENT')
|
||||
expect(await restoreLegacyHoists(test.staging, test.source)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a missing dependency instead of packaging an incomplete graph', async () => {
|
||||
const test = await fixture()
|
||||
await writeFile(join(test.staging, 'package.json'), JSON.stringify({ dependencies: { missing: 'workspace:^' } }))
|
||||
await expect(restoreLegacyHoists(test.staging, test.source)).rejects.toThrow('absent from both')
|
||||
})
|
||||
|
||||
it('materializes a package junction without deleting its source and drops linked command directories', async () => {
|
||||
const test = await fixture()
|
||||
await mkdir(join(test.source, 'peer'))
|
||||
await writeFile(join(test.source, 'peer/index.js'), 'intact')
|
||||
await symlink(join(test.source, 'peer'), join(test.modules, 'peer'), 'junction')
|
||||
await symlink(join(test.source, 'peer'), join(test.modules, '.bin'), 'junction')
|
||||
await materializeStagedLinks(test.modules)
|
||||
expect((await lstat(join(test.modules, 'peer'))).isSymbolicLink()).toBe(false)
|
||||
expect(await readFile(join(test.modules, 'peer/index.js'), 'utf8')).toBe('intact')
|
||||
expect(await readFile(join(test.source, 'peer/index.js'), 'utf8')).toBe('intact')
|
||||
await expect(lstat(join(test.modules, '.bin'))).rejects.toThrow('ENOENT')
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Shared production-deploy preparation for the SDK and SSH executable carriers. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { cp, lstat, mkdir, readFile, readdir, realpath, rm, unlink } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path'
|
||||
|
||||
/**
|
||||
* Resolve pnpm without invoking a command shell, including Windows shims.
|
||||
* @param args - pnpm arguments.
|
||||
* @returns the executable and its argument vector.
|
||||
*/
|
||||
export function pnpmInvocation(args: string[]): [command: string, args: string[]] {
|
||||
const entrypoint = process.env.npm_execpath?.trim()
|
||||
if (entrypoint !== undefined && entrypoint !== '') {
|
||||
const extension = extname(entrypoint).toLowerCase()
|
||||
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
||||
return [process.execPath, [entrypoint, ...args]]
|
||||
}
|
||||
if (extension !== '.cmd') return [entrypoint, args]
|
||||
}
|
||||
const home = process.env.PNPM_HOME?.trim()
|
||||
if (home !== undefined && home !== '') {
|
||||
const packageBin = resolve(home, '..', 'pnpm', 'bin')
|
||||
for (const filename of ['pnpm.mjs', 'pnpm.cjs']) {
|
||||
const candidate = resolve(packageBin, filename)
|
||||
if (existsSync(candidate)) return [process.execPath, [candidate, ...args]]
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('executable-packaging: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.')
|
||||
}
|
||||
return ['pnpm', args]
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore direct dependencies omitted by pnpm's legacy hoister, excluding nested module trees.
|
||||
* @param staging - deployed runtime root.
|
||||
* @param sourceNodeModules - deploy source's installed dependencies.
|
||||
* @returns names restored from the source installation.
|
||||
*/
|
||||
export async function restoreLegacyHoists(staging: string, sourceNodeModules: string): Promise<string[]> {
|
||||
const manifestPath = join(staging, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
const restored: string[] = []
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
|
||||
const destination = join(staging, 'node_modules', dependency)
|
||||
if (existsSync(destination)) continue
|
||||
const source = join(sourceNodeModules, dependency)
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
`executable-packaging: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
|
||||
)
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
restored.push(dependency)
|
||||
}
|
||||
const stillMissing = Object.keys(manifest.dependencies ?? {})
|
||||
.filter(dependency => !existsSync(join(staging, 'node_modules', dependency)))
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(`executable-packaging: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize deployed package links and remove command shims before embedding files.
|
||||
* @param nodeModules - deployed dependency directory, owned by the current build.
|
||||
*/
|
||||
export async function materializeStagedLinks(nodeModules: string): Promise<void> {
|
||||
let remaining = await findSymlink(nodeModules)
|
||||
while (remaining !== undefined) {
|
||||
const segments = remaining.slice(nodeModules.length + 1).split(sep)
|
||||
const binIndex = segments.lastIndexOf('.bin')
|
||||
if (binIndex >= 0) {
|
||||
const directory = join(nodeModules, ...segments.slice(0, binIndex + 1))
|
||||
if ((await lstat(directory)).isSymbolicLink()) await unlink(directory)
|
||||
else await rm(directory, { recursive: true, force: true })
|
||||
remaining = await findSymlink(nodeModules)
|
||||
continue
|
||||
}
|
||||
const destination = remaining
|
||||
const source = await realpath(destination)
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await unlink(destination)
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
remaining = await findSymlink(nodeModules)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the first symbolic link below a directory, if one exists. */
|
||||
async function findSymlink(directory: string): Promise<string | undefined> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) return path
|
||||
if (metadata.isDirectory()) {
|
||||
const nested = await findSymlink(path)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1534,11 +1534,6 @@
|
||||
"aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1,
|
||||
"be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1
|
||||
},
|
||||
"packages/ptc-runtime/ptc-runtime-node/tests/launch.spec.ts": {
|
||||
"57f598f462c77fae2b147b07c2e1228199c53dfd43652021af57712b4860732a": 1,
|
||||
"9ddf7336f94d8ff4be8fe0ae7977c1846e6e92e7ea66180f8e2e6f8902b3c915": 1,
|
||||
"e02136d8ab1f1fb84c6e772bbc863ceb3a0a3801f6eb158d5b9be074baa0885c": 1
|
||||
},
|
||||
"packages/ptc-runtime/ptc-runtime-node/tests/output-json.spec.ts": {
|
||||
"8c4c486bbc27559714f4e0aaf376c72c49ac9157539b03c6c12853b8d3fc6e74": 1
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Generate node-gyp's Makefile on the runner, then compile against glibc 2.28.
|
||||
case "$(uname -m)" in
|
||||
x86_64) image=quay.io/pypa/manylinux_2_28_x86_64 ;;
|
||||
aarch64) image=quay.io/pypa/manylinux_2_28_aarch64 ;;
|
||||
*) echo 'node-pty release preparation requires native Linux x64 or arm64' >&2; exit 1 ;;
|
||||
esac
|
||||
addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)"
|
||||
pnpm_setup_root="$(realpath "$(dirname "$(dirname "$PNPM_HOME")")")"
|
||||
(cd "$addon_dir" && npm_config_build_from_source=true pnpm run install)
|
||||
addon="$addon_dir/build/Release/pty.node"
|
||||
test -f "$addon_dir/build/Makefile"
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$PWD:$PWD" \
|
||||
-v "$HOME/.cache/node-gyp:$HOME/.cache/node-gyp:ro" \
|
||||
-v "$pnpm_setup_root:$pnpm_setup_root:ro" \
|
||||
-w "$addon_dir" "$image" \
|
||||
bash -euxo pipefail -c 'rm -rf build/Release && make -C build -j2 BUILDTYPE=Release'
|
||||
test -f "$addon"
|
||||
readelf --version-info "$addon"
|
||||
@@ -0,0 +1,117 @@
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { create as createTar } from 'tar'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { artifactManifestSchema, fileDigest, payloadFiles, SSH_HELPER_TARGETS, verifyPayload } from './artifact.ts'
|
||||
import { macosDeploymentVersions } from './native.ts'
|
||||
import { extractHelper } from '../verify-ssh-helper-artifact.ts'
|
||||
import { prepareSshRelease } from './release.ts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-helper-archive-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const payload = join(root, 'dsh-ssh-helper')
|
||||
await mkdir(join(payload, 'native/system'), { recursive: true })
|
||||
await writeFile(join(payload, 'dsh-ssh-helper'), 'fixture executable')
|
||||
await writeFile(join(payload, 'native/system/package.json'), '{}')
|
||||
const metadata = {
|
||||
format: 1, version: '1.0.0', commit: 'a'.repeat(40), dirty: false,
|
||||
target: 'node24-linux-x64', protocol: 2, nodeVersion: 'v24.1.0',
|
||||
files: await Promise.all((await payloadFiles(payload)).map(async path => ({
|
||||
path, sha256: await fileDigest(join(payload, path)), mode: (await lstat(join(payload, path))).mode & 0o777,
|
||||
}))),
|
||||
}
|
||||
const save = async () => { await writeFile(join(payload, 'manifest.json'), JSON.stringify(metadata)) }
|
||||
await save()
|
||||
return { root, payload, metadata, save }
|
||||
}
|
||||
|
||||
it('validates the exact unpacked inventory and rejects altered bytes', async () => {
|
||||
const test = await fixture()
|
||||
expect(await verifyPayload(test.payload)).toEqual(test.metadata)
|
||||
await writeFile(join(test.payload, 'dsh-ssh-helper'), 'changed executable')
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('digest differs')
|
||||
})
|
||||
|
||||
it('rejects missing, undeclared and duplicate payload files', async () => {
|
||||
const test = await fixture()
|
||||
await writeFile(join(test.payload, 'undeclared'), '')
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('inventory')
|
||||
await rm(join(test.payload, 'undeclared'))
|
||||
test.metadata.files.push(test.metadata.files[0]!)
|
||||
await test.save()
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('inventory')
|
||||
test.metadata.files.pop()
|
||||
await test.save()
|
||||
await rm(join(test.payload, 'native/system/package.json'))
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('inventory')
|
||||
})
|
||||
|
||||
it.each(['../outside', '/absolute', 'a/../b', 'a\\b', 'a//b'])('rejects unsafe manifest path %s', async (path) => {
|
||||
const test = await fixture()
|
||||
expect(() => artifactManifestSchema.parse({ ...test.metadata, files: [{ ...test.metadata.files[0], path }] })).toThrow('payload path')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects links before extracting an archive', async () => {
|
||||
const test = await fixture()
|
||||
await symlink('/etc/passwd', join(test.payload, 'outside'))
|
||||
await expect(payloadFiles(test.payload)).rejects.toThrow('symbolic link')
|
||||
const archive = join(test.root, 'linked.tar.gz')
|
||||
await createTar({ cwd: test.root, file: archive, gzip: true }, ['dsh-ssh-helper'])
|
||||
const extracted = join(test.root, 'unpack')
|
||||
await mkdir(extracted)
|
||||
await expect(extractHelper(archive, extracted)).rejects.toThrow('Invalid SSH helper archive entry')
|
||||
})
|
||||
|
||||
it('extracts and verifies a portable archive in a new directory', async () => {
|
||||
const test = await fixture()
|
||||
const archive = join(test.root, 'helper.tar.gz')
|
||||
await createTar({ cwd: test.root, file: archive, gzip: true, portable: true }, ['dsh-ssh-helper'])
|
||||
const extracted = join(test.root, 'relocated')
|
||||
await mkdir(extracted)
|
||||
const directory = await extractHelper(archive, extracted)
|
||||
expect(dirname(directory)).toBe(extracted)
|
||||
expect(await verifyPayload(directory)).toEqual(test.metadata)
|
||||
expect(await readFile(join(directory, 'dsh-ssh-helper'), 'utf8')).toBe('fixture executable')
|
||||
})
|
||||
|
||||
it('reads minimum macOS versions without accepting a linked library version as the OS version', () => {
|
||||
expect(macosDeploymentVersions('Load command 0\ncmd LC_VERSION_MIN_MACOSX\nversion 11.0\nLoad command 1\ncmd LC_LOAD_DYLIB\ncurrent version 1311.100.3\nLoad command 2\ncmd LC_BUILD_VERSION\nminos 14.0\nsdk 15.1\n'))
|
||||
.toEqual(['11.0', '14.0'])
|
||||
expect(macosDeploymentVersions('Load command 0\ncmd LC_LOAD_DYLIB\ncurrent version 14.0\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes only a complete clean-source matrix with native and no-Node evidence', async () => {
|
||||
const test = await fixture()
|
||||
const archives = join(test.root, 'release')
|
||||
await mkdir(archives)
|
||||
const checks = ['executable-handshake', 'guarded-files-and-streams', 'managed-process-output', 'process-cancellation', 'native-pty', 'native-flock', 'embedded-ptc-and-deadline', 'sandbox-enforcement']
|
||||
for (const target of SSH_HELPER_TARGETS) {
|
||||
const parent = join(test.root, target)
|
||||
await mkdir(parent)
|
||||
const payload = join(parent, 'dsh-ssh-helper')
|
||||
await cp(test.payload, payload, { recursive: true })
|
||||
const manifest = { ...test.metadata, target }
|
||||
await writeFile(join(payload, 'manifest.json'), JSON.stringify(manifest))
|
||||
const archive = join(archives, `dsh-ssh-helper-1.0.0-${target.slice('node24-'.length)}.tar.gz`)
|
||||
await createTar({ cwd: parent, file: archive, gzip: true, portable: true }, ['dsh-ssh-helper'])
|
||||
await writeFile(`${archive}.sha256`, `${await fileDigest(archive)} archive\n`)
|
||||
const runtime = { checks, nodeVersion: 'v24.1.0', platform: target.includes('linux') ? 'linux' : 'darwin', sandboxLane: 'required' }
|
||||
await writeFile(join(archives, `${target}.json`), JSON.stringify({ manifest, runtime, restrictedWorker: true, readonlyInstall: true, concurrentColdStarts: 2 }))
|
||||
if (target.includes('linux')) {
|
||||
await writeFile(join(archives, `${target}.ssh.json`), JSON.stringify({ manifest, runtime: { ...runtime, checks: [...checks, 'target-has-no-node'], sandboxLane: 'portable' }, glibc: 'glibc 2.28' }))
|
||||
await writeFile(join(archives, `${target}.landlock.json`), JSON.stringify({ manifest, runtime, expectedBackend: 'landlock-run' }))
|
||||
}
|
||||
}
|
||||
expect(await prepareSshRelease(archives, '1.0.0', test.metadata.commit)).toHaveLength(5)
|
||||
expect((await readFile(join(archives, 'SHA256SUMS'), 'utf8')).trim().split('\n')).toHaveLength(4)
|
||||
await expect(prepareSshRelease(archives, '1.0.0', 'b'.repeat(40))).rejects.toThrow()
|
||||
const report = join(archives, 'node24-linux-x64.ssh.json')
|
||||
const evidence = JSON.parse(await readFile(report, 'utf8')) as { runtime: { checks: string[] } }
|
||||
evidence.runtime.checks = evidence.runtime.checks.filter(check => check !== 'target-has-no-node')
|
||||
await writeFile(report, JSON.stringify(evidence))
|
||||
await expect(prepareSshRelease(archives, '1.0.0', test.metadata.commit)).rejects.toThrow('lacks target-has-no-node')
|
||||
await rm(report)
|
||||
await expect(prepareSshRelease(archives, '1.0.0', test.metadata.commit)).rejects.toThrow('ENOENT')
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Versioned archive metadata and file integrity checks for SSH helper releases. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { lstat, readFile, readdir } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Native target identifiers accepted by the builder and release workflow. */
|
||||
export const SSH_HELPER_TARGETS = ['node24-linux-x64', 'node24-linux-arm64', 'node24-macos-x64', 'node24-macos-arm64'] as const
|
||||
/** One qualified OS and architecture for a bundled Node 24 helper. */
|
||||
export type SshHelperTarget = typeof SSH_HELPER_TARGETS[number]
|
||||
|
||||
const payloadPath = z.string().min(1).refine(value =>
|
||||
!value.startsWith('/') && !value.includes('\\') && !value.includes('\0')
|
||||
&& value.split('/').every(part => part !== '' && part !== '.' && part !== '..'), 'invalid payload path')
|
||||
|
||||
/** Parser for metadata supplied beside an executable, including every runtime resource. */
|
||||
export const artifactManifestSchema = z.object({
|
||||
format: z.literal(1),
|
||||
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/),
|
||||
commit: z.string().regex(/^[a-f0-9]{40}$/),
|
||||
dirty: z.boolean(),
|
||||
target: z.enum(SSH_HELPER_TARGETS),
|
||||
protocol: z.number().int().positive(),
|
||||
nodeVersion: z.string().regex(/^v24\./),
|
||||
files: z.array(z.object({
|
||||
path: payloadPath, sha256: z.string().regex(/^[a-f0-9]{64}$/), mode: z.number().int().min(0).max(0o777),
|
||||
}).strict()).min(1),
|
||||
}).strict()
|
||||
|
||||
/** Results written by the isolated Loader consumer after its assertions succeed. */
|
||||
export const runtimeReportSchema = z.object({
|
||||
checks: z.array(z.string()), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(),
|
||||
sandboxLane: z.enum(['required', 'unavailable', 'portable']),
|
||||
}).strict()
|
||||
|
||||
/** Parsed release metadata; files excludes manifest.json to avoid a self-referential digest. */
|
||||
export type ArtifactManifest = z.infer<typeof artifactManifestSchema>
|
||||
|
||||
/**
|
||||
* Enumerate an owned payload without following links outside its directory.
|
||||
* @param root - unpacked payload directory.
|
||||
* @returns sorted POSIX relative file paths, excluding manifest.json.
|
||||
*/
|
||||
export async function payloadFiles(root: string): Promise<string[]> {
|
||||
const paths: string[] = []
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
for (const name of await readdir(directory)) {
|
||||
const path = join(directory, name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) throw new Error(`SSH helper payload contains a symbolic link: ${path}`)
|
||||
if (metadata.isDirectory()) await visit(path)
|
||||
else if (metadata.isFile()) paths.push(relative(root, path).split(sep).join('/'))
|
||||
else throw new Error(`SSH helper payload contains a non-regular file: ${path}`)
|
||||
}
|
||||
}
|
||||
await visit(root)
|
||||
return paths.filter(path => path !== 'manifest.json').sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the exact file bytes used for installation or publication.
|
||||
* @param path - regular file path.
|
||||
* @returns lowercase SHA-256.
|
||||
*/
|
||||
export async function fileDigest(path: string): Promise<string> {
|
||||
return createHash('sha256').update(await readFile(path)).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all payload bytes and permissions before running an unpacked release.
|
||||
* @param directory - unpacked payload root.
|
||||
* @returns the validated metadata; missing, duplicate, extra, or altered files reject.
|
||||
*/
|
||||
export async function verifyPayload(directory: string): Promise<ArtifactManifest> {
|
||||
const manifest = artifactManifestSchema.parse(JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')))
|
||||
const actual = await payloadFiles(directory)
|
||||
const declared = manifest.files.map(file => file.path).sort()
|
||||
if (new Set(declared).size !== declared.length || JSON.stringify(actual) !== JSON.stringify(declared)) {
|
||||
throw new Error('SSH helper payload file inventory differs from manifest.json')
|
||||
}
|
||||
if (!declared.includes('dsh-ssh-helper') || !declared.includes('native/system/package.json')) throw new Error('SSH helper payload omits required runtime files')
|
||||
for (const file of manifest.files) {
|
||||
const path = join(directory, file.path)
|
||||
if (await fileDigest(path) !== file.sha256) throw new Error(`SSH helper payload digest differs: ${file.path}`)
|
||||
if (((await lstat(path)).mode & 0o777) !== file.mode) throw new Error(`SSH helper payload permissions differ: ${file.path}`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Inspect every native payload, including modules embedded in the executable. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { payloadFiles, type SshHelperTarget } from './artifact.ts'
|
||||
|
||||
function newer(version: string, ceiling: number[]): boolean {
|
||||
const components = version.split('.').map(Number)
|
||||
for (let index = 0; index < Math.max(components.length, ceiling.length); index += 1) {
|
||||
const difference = (components[index] ?? 0) - (ceiling[index] ?? 0)
|
||||
if (difference !== 0) return difference > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Read deployment versions without confusing linked-library versions with OS requirements.
|
||||
* @param output - otool load-command text, possibly containing multiple architecture slices.
|
||||
* @returns every minimum macOS version declared by the binary.
|
||||
*/
|
||||
export function macosDeploymentVersions(output: string): string[] {
|
||||
let command: string | undefined
|
||||
const versions: string[] = []
|
||||
for (const line of output.split('\n')) {
|
||||
const text = line.trim()
|
||||
if (/^Load command \d+$/.test(text)) command = undefined
|
||||
else if (text.startsWith('cmd ')) command = text.slice(4)
|
||||
else {
|
||||
const match = /^(minos|version)\s+(\d+(?:\.\d+)+)$/.exec(text)
|
||||
if (match !== null && ((command === 'LC_BUILD_VERSION' && match[1] === 'minos') || (command === 'LC_VERSION_MIN_MACOSX' && match[1] === 'version'))) versions.push(match[2] as string)
|
||||
}
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect all ELF or Mach-O files for the requested architecture and deployment baseline.
|
||||
* @param directory - symlink-free staging or payload directory.
|
||||
* @param target - build target whose ABI must match every native file.
|
||||
* @returns relative paths of inspected native files.
|
||||
*/
|
||||
export async function verifyNativePayload(directory: string, target: SshHelperTarget): Promise<string[]> {
|
||||
const inspected: string[] = []
|
||||
const linux = target.includes('-linux-')
|
||||
const arch = target.endsWith('-arm64') ? 'arm64' : 'x64'
|
||||
for (const path of await payloadFiles(directory)) {
|
||||
const filename = join(directory, path)
|
||||
const file = await open(filename, 'r')
|
||||
const header = Buffer.alloc(4)
|
||||
try { await file.read(header, 0, 4, 0) } finally { await file.close() }
|
||||
const magic = header.readUInt32BE()
|
||||
const elf = magic === 0x7f454c46
|
||||
const macho = [0xcffaedfe, 0xcefaedfe, 0xfeedfacf, 0xfeedface, 0xcafebabe, 0xbebafeca, 0xcafebabf].includes(magic)
|
||||
if (!elf && !macho) continue
|
||||
if (linux !== elf) throw new Error(`Native file does not belong to ${target}: ${path}`)
|
||||
if (elf) {
|
||||
const elfHeader = execFileSync('readelf', ['--file-header', filename], { encoding: 'utf8' })
|
||||
const expected = arch === 'arm64' ? /Machine:\s+AArch64/ : /Machine:\s+Advanced Micro Devices X86-64/
|
||||
if (!expected.test(elfHeader)) throw new Error(`ELF architecture differs from ${target}: ${path}`)
|
||||
const versions = execFileSync('readelf', ['--version-info', filename], { encoding: 'utf8' })
|
||||
for (const match of versions.matchAll(/\bGLIBC_(\d+(?:\.\d+)+)/g)) {
|
||||
if (newer(match[1] as string, [2, 28])) throw new Error(`${path} requires GLIBC_${match[1]}, exceeding 2.28`)
|
||||
}
|
||||
} else {
|
||||
execFileSync('lipo', [filename, '-verify_arch', arch === 'arm64' ? 'arm64' : 'x86_64'], { stdio: 'pipe' })
|
||||
const loads = execFileSync('otool', ['-l', filename], { encoding: 'utf8' })
|
||||
const versions = macosDeploymentVersions(loads)
|
||||
if (versions.length === 0) throw new Error(`Mach-O has no deployment version: ${path}`)
|
||||
if (versions.some(version => newer(version, [14, 0]))) throw new Error(`${path} requires macOS newer than 14.0`)
|
||||
}
|
||||
inspected.push(path)
|
||||
}
|
||||
return inspected
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Short handshake against an actual executable, with bounded process ownership. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { z } from 'zod'
|
||||
import { SshRpcPeer, SSH_PROTOCOL_VERSION } from '../../packages/ssh/ssh/src/protocol.ts'
|
||||
import { helloSchema } from '../../packages/ssh/ssh/src/schemas.ts'
|
||||
|
||||
/**
|
||||
* Run a packaged helper and read its runtime version through the private wire protocol.
|
||||
* @param executable - physical executable under test.
|
||||
* @param workspace - existing directory supplied for the handshake.
|
||||
* @returns the validated handshake after the helper has exited.
|
||||
*/
|
||||
export async function probeHelper(executable: string, workspace: string): Promise<z.infer<typeof helloSchema>> {
|
||||
const child = spawn(executable, [], { cwd: workspace, env: { ...process.env, NODE_OPTIONS: '--disable-sigusr1' }, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let diagnostic = ''
|
||||
child.stderr.on('data', (chunk: Buffer) => { diagnostic = (diagnostic + chunk.toString()).slice(-8192) })
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`SSH helper probe exited (${code ?? signal}): ${diagnostic}`))
|
||||
})
|
||||
})
|
||||
void closed.catch(() => {})
|
||||
const rpc = new SshRpcPeer(child.stdout, child.stdin, 64 * 1024 * 1024, 128)
|
||||
child.once('error', (error) => { rpc.close(error) })
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 30_000)
|
||||
try {
|
||||
const hello = await rpc.request('hello', { protocol: SSH_PROTOCOL_VERSION, workspace, leaseMs: 30_000 }, helloSchema, AbortSignal.timeout(20_000))
|
||||
await rpc.request('close', {}, z.null(), AbortSignal.timeout(5000))
|
||||
child.stdin.end()
|
||||
await closed
|
||||
return hello
|
||||
} catch (error) {
|
||||
child.kill('SIGTERM')
|
||||
await closed.catch(() => {})
|
||||
throw new Error(`SSH helper handshake failed: ${diagnostic}`, { cause: error })
|
||||
} finally {
|
||||
clearTimeout(force)
|
||||
rpc.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Require four complete, same-commit executable reports before publishing release assets. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { z } from 'zod'
|
||||
import { artifactManifestSchema, fileDigest, runtimeReportSchema, SSH_HELPER_TARGETS, verifyPayload, type ArtifactManifest } from './artifact.ts'
|
||||
import { extractHelper } from '../verify-ssh-helper-artifact.ts'
|
||||
|
||||
const reportSchema = z.object({
|
||||
manifest: artifactManifestSchema,
|
||||
runtime: runtimeReportSchema,
|
||||
restrictedWorker: z.boolean().optional(), expectedBackend: z.string().nullable().optional(), glibc: z.string().optional(),
|
||||
readonlyInstall: z.boolean().optional(), concurrentColdStarts: z.number().optional(),
|
||||
}).strict()
|
||||
const coreChecks = ['executable-handshake', 'guarded-files-and-streams', 'managed-process-output', 'process-cancellation', 'native-pty', 'native-flock', 'embedded-ptc-and-deadline']
|
||||
|
||||
async function report(path: string, manifest: ArtifactManifest, checks: readonly string[]) {
|
||||
const evidence = reportSchema.parse(JSON.parse(await readFile(path, 'utf8')))
|
||||
assert.deepEqual(evidence.manifest, manifest, `Report metadata differs: ${path}`)
|
||||
assert.equal(evidence.runtime.nodeVersion, manifest.nodeVersion)
|
||||
assert.equal(evidence.runtime.platform, manifest.target.includes('-linux-') ? 'linux' : 'darwin')
|
||||
for (const check of [...coreChecks, ...checks]) assert(evidence.runtime.checks.includes(check), `Report ${path} lacks ${check}`)
|
||||
return evidence
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate archives and their required native and SSH evidence, then write the release checksum list.
|
||||
* @param directory - downloaded artifacts from one successful four-target workflow run.
|
||||
* @param version - version selected by the release tag.
|
||||
* @param commit - immutable source commit checked out by every build job.
|
||||
* @returns the exact archive and checksum files permitted for upload.
|
||||
*/
|
||||
export async function prepareSshRelease(directory: string, version: string, commit: string): Promise<string[]> {
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'dsh-helper-release-'))
|
||||
const archives: string[] = []
|
||||
const sums: string[] = []
|
||||
try {
|
||||
for (const target of SSH_HELPER_TARGETS) {
|
||||
const archive = join(directory, `dsh-ssh-helper-${version}-${target.slice('node24-'.length)}.tar.gz`)
|
||||
const digest = await fileDigest(archive)
|
||||
assert.equal(digest, (await readFile(`${archive}.sha256`, 'utf8')).split(/\s/)[0])
|
||||
const destination = join(temporary, target)
|
||||
await mkdir(destination)
|
||||
const manifest = await verifyPayload(await extractHelper(archive, destination))
|
||||
assert.equal(manifest.target, target)
|
||||
assert.equal(manifest.version, version)
|
||||
assert.equal(manifest.commit, commit)
|
||||
assert.equal(manifest.dirty, false, 'Release artifacts must come from a clean source checkout')
|
||||
const native = await report(join(directory, `${target}.json`), manifest, ['sandbox-enforcement'])
|
||||
assert.equal(native.runtime.sandboxLane, 'required')
|
||||
assert.equal(native.readonlyInstall, true)
|
||||
assert.equal(native.concurrentColdStarts, 2)
|
||||
if (target.includes('-macos-')) assert.equal(native.restrictedWorker, true)
|
||||
else {
|
||||
const ssh = await report(join(directory, `${target}.ssh.json`), manifest, ['target-has-no-node'])
|
||||
assert.equal(ssh.glibc, 'glibc 2.28')
|
||||
const landlock = await report(join(directory, `${target}.landlock.json`), manifest, ['sandbox-enforcement'])
|
||||
assert.equal(landlock.expectedBackend, 'landlock-run')
|
||||
assert.equal(landlock.runtime.sandboxLane, 'required')
|
||||
}
|
||||
archives.push(archive)
|
||||
sums.push(`${digest} ${basename(archive)}`)
|
||||
}
|
||||
const checksums = join(directory, 'SHA256SUMS')
|
||||
await writeFile(checksums, `${sums.join('\n')}\n`)
|
||||
return [...archives, checksums]
|
||||
} finally { await rm(temporary, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({ options: { directory: { type: 'string' }, version: { type: 'string' }, commit: { type: 'string' } } })
|
||||
if (values.directory === undefined || values.version === undefined || values.commit === undefined) throw new Error('Release verification requires --directory, --version and --commit')
|
||||
console.log((await prepareSshRelease(resolve(values.directory), values.version, values.commit)).join('\n'))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Run the embedded worker while macOS denies access to the checkout and host Node. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { Duplex, type Readable, type Writable } from 'node:stream'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { JsonChannel } from '../../packages/ptc-runtime/ptc-runtime-node/src/channel.ts'
|
||||
import { decodePtcJsonWire } from '../../packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts'
|
||||
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '../../packages/subprocess/subprocess/src/control.ts'
|
||||
|
||||
/**
|
||||
* Execute a PTC program with the host runtime and source files inaccessible.
|
||||
* @param executable - relocated helper executable.
|
||||
* @param workspace - isolated program working directory.
|
||||
* @param home - empty helper home and native-resource cache.
|
||||
* @param checkout - real source checkout path denied by Seatbelt.
|
||||
* @param hostNode - real host Node path denied by Seatbelt.
|
||||
*/
|
||||
export async function probeRestrictedWorker(
|
||||
executable: string, workspace: string, home: string, checkout: string, hostNode: string,
|
||||
): Promise<void> {
|
||||
const profile = `(version 1)(allow default)${[checkout, hostNode].map(path => `(deny file-read* (subpath ${JSON.stringify(path)}))(deny process-exec (subpath ${JSON.stringify(path)}))`).join('')}`
|
||||
const child = spawn('/usr/bin/sandbox-exec', ['-p', profile, executable, '1048576'], {
|
||||
cwd: workspace, env: { HOME: home, TMPDIR: home, PATH: '/usr/bin:/bin', DSH_PTC_RUNTIME_NODE: '1', [SUBPROCESS_CONTROL_ENV]: 'pipe', NODE_OPTIONS: '--disable-sigusr1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ignore', 'ignore', 'ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
const completed = Promise.withResolvers<unknown>()
|
||||
let diagnostic = ''
|
||||
assert(child.stderr !== null && child.stdout !== null)
|
||||
child.stderr.on('data', (chunk: Buffer) => { diagnostic = (diagnostic + chunk.toString()).slice(-8192) })
|
||||
child.stdout.resume()
|
||||
const exited = new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`Restricted PTC worker exited (${code ?? signal}): ${diagnostic}`))
|
||||
})
|
||||
})
|
||||
void exited.catch((error: unknown) => { completed.reject(error) })
|
||||
const streams: ReadonlyArray<Readable | Writable | null | undefined> = child.stdio
|
||||
const control = streams[SUBPROCESS_CONTROL_FD]
|
||||
assert(control instanceof Duplex)
|
||||
const channel = new JsonChannel(control, 1_048_576, (raw) => {
|
||||
const message = z.object({ type: z.string(), value: z.unknown().optional(), error: z.unknown().optional() }).parse(raw)
|
||||
if (message.type === 'ready') {
|
||||
const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};`
|
||||
void channel.send({ type: 'boot', data: { code, namespaces: [], maxOutputBytes: 65_536 } })
|
||||
.catch((error: unknown) => { completed.reject(error) })
|
||||
} else if (message.type === 'done') {
|
||||
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
|
||||
else completed.resolve(decodePtcJsonWire(message.value))
|
||||
channel.close()
|
||||
} else completed.reject(new Error(`Unexpected restricted-worker message: ${message.type}`))
|
||||
}, (error) => { completed.reject(error) })
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 20_000)
|
||||
try {
|
||||
const result = z.object({ executable: z.string(), env: z.array(z.string()), denied: z.array(z.enum(['EPERM', 'EACCES'])) }).parse(await completed.promise)
|
||||
assert.equal(result.executable, executable)
|
||||
assert.deepEqual(result.env, [])
|
||||
assert.equal(result.denied.length, 2)
|
||||
await exited
|
||||
} finally {
|
||||
channel.close()
|
||||
child.kill('SIGTERM')
|
||||
try { await exited } finally { clearTimeout(force) }
|
||||
}
|
||||
}
|
||||
@@ -2176,6 +2176,11 @@
|
||||
"symbol": "PtcRunSandbox",
|
||||
"source": "packages/ptc-runtime/ptc-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/ssh.md",
|
||||
"symbol": "HelperLaunch",
|
||||
"source": "packages/ssh/ssh/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/ssh.md",
|
||||
"symbol": "Config",
|
||||
|
||||
@@ -52,6 +52,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' },
|
||||
'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' },
|
||||
'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' },
|
||||
'packages/ssh/ssh-helper-runtime': { kind: 'none', reason: 'The private executable carrier registers no model-facing content; its providers and consumers own operation results.' },
|
||||
'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' },
|
||||
'packages/ssh/fs-ssh': { kind: 'indirect', reason: 'The remote filesystem delegates model rendering to the existing filesystem consumers.' },
|
||||
'packages/ssh/subprocess-ssh': { kind: 'indirect', reason: 'The remote process provider delegates model rendering to Bash, terminal, LSP and ptc-runtime consumers.' },
|
||||
|
||||
@@ -39,6 +39,16 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('verifyRuntimeClosure', () => {
|
||||
it('checks a private worker closure without requiring application presets', async () => {
|
||||
const manifestPath = 'packages/ssh/ssh-helper-runtime/package.json'
|
||||
const root = fixture({ [manifestPath]: { name: 'helper', dependencies: { '@scope/worker': 'workspace:^' } } })
|
||||
workspace(root, '@scope/worker', { peerDependencies: { '@scope/peer': 'workspace:^' } })
|
||||
workspace(root, '@scope/peer', {})
|
||||
expect((await verifyRuntimeClosure(root, manifestPath, false)).failures).toEqual(['helper -> @scope/worker -> @scope/peer'])
|
||||
writeFileSync(join(root, manifestPath), JSON.stringify({ name: 'helper', dependencies: { '@scope/worker': 'workspace:^', '@scope/peer': 'workspace:^' } }))
|
||||
expect(await verifyRuntimeClosure(root, manifestPath, false)).toMatchObject({ failures: [], presetCount: 0 })
|
||||
})
|
||||
|
||||
it('requires only plugins active for each published target', async () => {
|
||||
const root = fixture({
|
||||
'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } },
|
||||
|
||||
@@ -42,18 +42,20 @@ export interface RuntimeClosureResult {
|
||||
* Check that the runtime manifest contains every shipped-preset plugin and workspace peer.
|
||||
* @param root repository root containing the runtime manifest and shipped presets.
|
||||
* @param manifestPath runtime manifest path relative to {@link root}.
|
||||
* @param includePresets whether this carrier includes the shipped application presets.
|
||||
* @returns the discovered preset count, reachable workspace package count, and violations.
|
||||
*/
|
||||
export async function verifyRuntimeClosure(
|
||||
root: string,
|
||||
manifestPath = 'python/sdk-runtime/package.json',
|
||||
includePresets = true,
|
||||
): Promise<RuntimeClosureResult> {
|
||||
const runtimeManifest = await loadManifest(resolve(root, manifestPath))
|
||||
const runtimeName = runtimeManifest.name ?? manifestPath
|
||||
const workspace = await loadWorkspacePackages(root)
|
||||
const runtimeDependencies = runtimeManifest.dependencies ?? {}
|
||||
const platforms = await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json'))
|
||||
const presetPaths = globSync(AGENT_PRESET_GLOB, { cwd: root }).sort()
|
||||
const platforms = includePresets ? await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json')) : {}
|
||||
const presetPaths = includePresets ? globSync(AGENT_PRESET_GLOB, { cwd: root }).sort() : []
|
||||
const targets = Object.keys(platforms).sort()
|
||||
const parents = new Map<string, string | undefined>()
|
||||
const queue: string[] = []
|
||||
@@ -65,8 +67,8 @@ export async function verifyRuntimeClosure(
|
||||
}
|
||||
|
||||
const failures: string[] = []
|
||||
if (presetPaths.length === 0) failures.push(`no agent presets matched ${AGENT_PRESET_GLOB}`)
|
||||
if (targets.length === 0) failures.push('python/sdk-runtime/platforms.json defines no runtime targets')
|
||||
if (includePresets && presetPaths.length === 0) failures.push(`no agent presets matched ${AGENT_PRESET_GLOB}`)
|
||||
if (includePresets && targets.length === 0) failures.push('python/sdk-runtime/platforms.json defines no runtime targets')
|
||||
failures.push(...await missingPresetPlugins(root, runtimeDependencies, presetPaths, targets))
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const packageName = queue[index]
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Unpack and run a release archive through the actual Loader-mounted SSH consumers. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { extract, list } from 'tar'
|
||||
import { fileDigest, runtimeReportSchema, verifyPayload, type ArtifactManifest } from './ssh-helper/artifact.ts'
|
||||
import { probeRestrictedWorker } from './ssh-helper/worker-probe.ts'
|
||||
import { verifyNativePayload } from './ssh-helper/native.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const fixtures = join(root, 'packages/ssh/ssh-helper-runtime/tests/fixtures')
|
||||
|
||||
async function directoryPermissions(directory: string, mode: number): Promise<void> {
|
||||
await chmod(directory, mode)
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await directoryPermissions(join(directory, entry.name), mode)
|
||||
}
|
||||
}
|
||||
|
||||
async function makeReadonly(directory: string, manifest: ArtifactManifest): Promise<void> {
|
||||
for (const file of manifest.files) await chmod(join(directory, file.path), file.mode & ~0o222)
|
||||
await chmod(join(directory, 'manifest.json'), 0o444)
|
||||
await directoryPermissions(directory, 0o555)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject links and escaping paths before extracting a helper's release archive.
|
||||
* @param archive - gzip tar file supplied by the build.
|
||||
* @param directory - empty extraction directory owned by the caller.
|
||||
* @returns the extracted payload root.
|
||||
*/
|
||||
export async function extractHelper(archive: string, directory: string): Promise<string> {
|
||||
const entries: { path: string; type: string }[] = []
|
||||
await list({ file: archive, onReadEntry: (entry) => { entries.push({ path: entry.path, type: entry.type }) } })
|
||||
const seen = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
const path = entry.path.replace(/\/$/, '')
|
||||
if (!['File', 'Directory'].includes(entry.type) || path.split('/').some(part => part === '..' || part === '.' || part === '')
|
||||
|| path.includes('\\') || !path.startsWith('dsh-ssh-helper') || (path !== 'dsh-ssh-helper' && !path.startsWith('dsh-ssh-helper/')) || seen.has(path)) {
|
||||
throw new Error(`Invalid SSH helper archive entry: ${entry.path} (${entry.type})`)
|
||||
}
|
||||
seen.add(path)
|
||||
}
|
||||
if (!seen.has('dsh-ssh-helper/manifest.json')) throw new Error('SSH helper archive has no manifest.json')
|
||||
await extract({ file: archive, cwd: directory, strict: true })
|
||||
return join(directory, 'dsh-ssh-helper')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the isolated consumer process and await all output and process teardown.
|
||||
* @param config - generated Loader configuration.
|
||||
* @param report - output report path.
|
||||
* @param sandbox - expected confinement availability on this lane.
|
||||
* @param environment - isolated client environment, including any test-owned SSH wrapper.
|
||||
*/
|
||||
export async function runArtifactDriver(config: string, report: string, sandbox: string, environment: NodeJS.ProcessEnv): Promise<void> {
|
||||
const child = spawn(process.execPath, ['--import', 'tsx/esm', join(fixtures, 'driver.ts'), config, report, sandbox], {
|
||||
cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: environment,
|
||||
})
|
||||
let output = ''
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', (chunk: Buffer) => {
|
||||
output = (output + chunk.toString()).slice(-32_768)
|
||||
})
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 120_000)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`SSH artifact consumer exited (${code ?? signal}):\n${output}`))
|
||||
})
|
||||
})
|
||||
} finally { clearTimeout(force) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the shared provider composition around a selected helper transport.
|
||||
* @param connection - concrete Loader connection row, direct fixture or OpenSSH.
|
||||
* @param workspace - target-side workspace path.
|
||||
* @returns YAML consumed by the normal application boot path.
|
||||
*/
|
||||
export function artifactComposition(connection: { id: string; name: string; config: object }, workspace: string): string {
|
||||
const rows = [
|
||||
{ id: 'projections', name: '@deepseek-ai/dsh-session-projection' },
|
||||
{ id: 'policy', name: '@deepseek-ai/dsh-sandbox-policy', config: { mode: 'workspace-write', workspaceRoot: workspace } },
|
||||
connection,
|
||||
{ id: 'fs', name: '@deepseek-ai/dsh-fs-ssh' },
|
||||
{ id: 'subprocess', name: '@deepseek-ai/dsh-subprocess-ssh' },
|
||||
{ id: 'sandbox', name: '@deepseek-ai/dsh-sandbox-ssh' },
|
||||
]
|
||||
return rows.map(row => `- ${JSON.stringify(row)}`).join('\n')
|
||||
+ '\n- id: ptc\n name: "@deepseek-ai/dsh-ptc-runtime-node"\n inject: [ssh]\n config:\n launch: !!js ctx.ssh.ptcLaunch\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify archive integrity and all core operations after relocation outside the checkout.
|
||||
* @param argv - verifier flags.
|
||||
*/
|
||||
export async function verifySshHelper(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({ args: argv, options: {
|
||||
archive: { type: 'string' }, sandbox: { type: 'string', default: 'required' },
|
||||
report: { type: 'string' },
|
||||
backend: { type: 'string' },
|
||||
} })
|
||||
if (values.archive === undefined || !['required', 'unavailable'].includes(values.sandbox)) throw new Error('Use --archive=<file> --sandbox=required|unavailable')
|
||||
if (values.backend !== undefined && !['bwrap', 'landlock-run', 'sandbox-exec'].includes(values.backend)) throw new Error('Unknown expected sandbox backend')
|
||||
if (values.backend === 'landlock-run' && process.platform !== 'linux') throw new Error('Landlock verification requires Linux')
|
||||
const archive = resolve(values.archive)
|
||||
const checksum = (await readFile(`${archive}.sha256`, 'utf8')).split(/\s/)[0]
|
||||
if (await fileDigest(archive) !== checksum) throw new Error('SSH helper archive checksum mismatch')
|
||||
const temporary = await realpath(await mkdtemp(join(homedir(), '.dsh-helper-check-')))
|
||||
try {
|
||||
const installed = await extractHelper(archive, temporary)
|
||||
const manifest = await verifyPayload(installed)
|
||||
const host = `node24-${process.platform === 'darwin' ? 'macos' : process.platform}-${process.arch}`
|
||||
if (manifest.target !== host) throw new Error(`Artifact ${manifest.target} cannot be validated on ${host}`)
|
||||
await verifyNativePayload(installed, manifest.target)
|
||||
await makeReadonly(installed, manifest)
|
||||
const workspace = join(temporary, 'workspace')
|
||||
const home = join(temporary, 'home')
|
||||
await mkdir(workspace); await mkdir(home)
|
||||
let path = '/usr/bin:/bin'
|
||||
if (values.backend === 'landlock-run') {
|
||||
const bin = join(temporary, 'bin')
|
||||
await mkdir(bin)
|
||||
await writeFile(join(bin, 'bwrap'), '#!/bin/sh\nexit 127\n')
|
||||
await chmod(join(bin, 'bwrap'), 0o755)
|
||||
path = `${bin}:${path}`
|
||||
}
|
||||
const executable = join(installed, 'dsh-ssh-helper')
|
||||
const helperHash = await fileDigest(executable)
|
||||
const environment = {
|
||||
HOME: home, PATH: process.env.PATH, DSH_HOME: join(home, 'dsh'),
|
||||
TSX_TSCONFIG_PATH: join(root, 'tsconfig.base.json'), DSH_TELEMETRY_DISABLED: '1',
|
||||
...(values.backend === undefined ? {} : { DSH_EXPECT_SANDBOX_RUNNER: values.backend }),
|
||||
}
|
||||
const runs = await Promise.allSettled([0, 1].map(async (index) => {
|
||||
const cwd = join(workspace, String(index))
|
||||
await mkdir(cwd)
|
||||
const config = join(temporary, `cordis-${index}.yml`)
|
||||
const report = join(temporary, `report-${index}.json`)
|
||||
await writeFile(config, artifactComposition({ id: 'ssh', name: join(fixtures, 'direct-helper.ts'), config: {
|
||||
executable, helperHash, workspace: cwd, home, path,
|
||||
} }, cwd))
|
||||
await runArtifactDriver(config, report, values.sandbox, environment)
|
||||
}))
|
||||
const failures = runs.filter(result => result.status === 'rejected').map((result): unknown => result.reason)
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'Concurrent SSH artifact consumers failed')
|
||||
if (process.platform === 'darwin') await probeRestrictedWorker(executable, workspace, home, await realpath(root), await realpath(process.execPath))
|
||||
const evidence = { manifest, runtime: runtimeReportSchema.parse(JSON.parse(await readFile(join(temporary, 'report-0.json'), 'utf8'))),
|
||||
restrictedWorker: process.platform === 'darwin', expectedBackend: values.backend ?? null,
|
||||
readonlyInstall: true, concurrentColdStarts: 2 }
|
||||
if (values.report !== undefined) {
|
||||
const destination = resolve(values.report)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
}
|
||||
console.log(`SSH helper ${manifest.target}: ${JSON.stringify(evidence.runtime)}`)
|
||||
} finally {
|
||||
await directoryPermissions(temporary, 0o755)
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await verifySshHelper(process.argv.slice(2))
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Real OpenSSH acceptance against a glibc 2.28 container with no installed Node. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs, promisify } from 'node:util'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { fileDigest, runtimeReportSchema, verifyPayload } from './ssh-helper/artifact.ts'
|
||||
import { artifactComposition, extractHelper, runArtifactDriver } from './verify-ssh-helper-artifact.ts'
|
||||
|
||||
const run = promisify(execFile)
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
async function docker(...args: string[]): Promise<string> {
|
||||
return (await run('docker', args, { timeout: 300_000, maxBuffer: 16 * 1024 * 1024 })).stdout.trim()
|
||||
}
|
||||
|
||||
async function sshdReady(container: string): Promise<void> {
|
||||
const logs = spawn('docker', ['logs', '--follow', container], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let text = ''
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const closed = new Promise<void>((resolve) => { logs.once('close', () => { resolve(); ready.reject(new Error(`sshd exited before readiness: ${text}`)) }) })
|
||||
const read = (chunk: Buffer): void => {
|
||||
text = (text + chunk.toString()).slice(-8192)
|
||||
if (text.includes('Server listening on')) ready.resolve()
|
||||
}
|
||||
logs.stdout.on('data', read); logs.stderr.on('data', read)
|
||||
logs.once('error', (error) => { ready.reject(error) })
|
||||
const deadline = setTimeout(() => { ready.reject(new Error(`sshd readiness timed out: ${text}`)) }, 30_000)
|
||||
try { await ready.promise }
|
||||
finally { clearTimeout(deadline); logs.kill('SIGTERM'); await closed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install only a validated release into an isolated SSH server and exercise production providers.
|
||||
* @param argv - archive and optional report flags.
|
||||
*/
|
||||
export async function verifySshTransport(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({ args: argv, options: { archive: { type: 'string' }, report: { type: 'string' } } })
|
||||
if (values.archive === undefined) throw new Error('Use --archive=<Linux helper archive>')
|
||||
const archive = resolve(values.archive)
|
||||
assert.equal(await fileDigest(archive), (await readFile(`${archive}.sha256`, 'utf8')).split(/\s/)[0], 'Archive checksum')
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'dsh-helper-ssh-'))
|
||||
const image = `dsh-helper-test:${randomUUID()}`
|
||||
let imageBuilt = false
|
||||
let container: string | undefined
|
||||
try {
|
||||
const installed = await extractHelper(archive, temporary)
|
||||
const manifest = await verifyPayload(installed)
|
||||
if (!manifest.target.includes('-linux-')) throw new Error('OpenSSH container acceptance requires a Linux artifact')
|
||||
const platform = manifest.target.endsWith('-arm64') ? 'linux/arm64' : 'linux/amd64'
|
||||
await docker('build', '--platform', platform, '--tag', image, join(root, 'packages/ssh/ssh-helper-runtime/tests/fixtures/sshd'))
|
||||
imageBuilt = true
|
||||
const identity = join(temporary, 'identity')
|
||||
await run('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', identity])
|
||||
container = await docker('run', '--detach', '--platform', platform, '--publish', '127.0.0.1::22',
|
||||
'--mount', `type=bind,src=${installed},dst=/opt/dsh-helper,readonly`,
|
||||
'--mount', `type=bind,src=${identity}.pub,dst=/fixture-key.pub,readonly`, image)
|
||||
await sshdReady(container)
|
||||
const published = await docker('port', container, '22/tcp')
|
||||
const port = /^127\.0\.0\.1:(\d+)$/.exec(published)?.[1]
|
||||
if (port === undefined) throw new Error(`Unexpected Docker port mapping: ${published}`)
|
||||
const publicKey = await docker('exec', container, 'cat', '/etc/ssh/ssh_host_ed25519_key.pub')
|
||||
const knownHosts = join(temporary, 'known_hosts')
|
||||
await writeFile(knownHosts, `[127.0.0.1]:${port} ${publicKey}\n`, { mode: 0o600 })
|
||||
const sshConfig = join(temporary, 'ssh_config')
|
||||
await writeFile(sshConfig, `Host artifact\n HostName 127.0.0.1\n Port ${port}\n User dsh\n IdentityFile "${identity}"\n IdentitiesOnly yes\n UserKnownHostsFile "${knownHosts}"\n GlobalKnownHostsFile /dev/null\n`, { mode: 0o600 })
|
||||
const bin = join(temporary, 'bin')
|
||||
await mkdir(bin)
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
|
||||
await writeFile(join(bin, 'ssh'), `#!/bin/sh\nexec /usr/bin/ssh -F ${quote(sshConfig)} "$@"\n`)
|
||||
await chmod(join(bin, 'ssh'), 0o755)
|
||||
const workspace = '/home/dsh/workspace'
|
||||
const config = join(temporary, 'cordis.yml')
|
||||
await writeFile(config, artifactComposition({ id: 'ssh', name: '@deepseek-ai/dsh-ssh', config: {
|
||||
host: 'artifact', launch: { kind: 'executable' }, helper: '/opt/dsh-helper/dsh-ssh-helper',
|
||||
helperHash: await fileDigest(join(installed, 'dsh-ssh-helper')), workspace,
|
||||
} }, workspace))
|
||||
const home = join(temporary, 'client-home')
|
||||
await mkdir(home)
|
||||
const report = join(temporary, 'report.json')
|
||||
await runArtifactDriver(config, report, 'portable', {
|
||||
HOME: home, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}`, DSH_HOME: join(home, 'dsh'),
|
||||
TSX_TSCONFIG_PATH: join(root, 'tsconfig.base.json'), DSH_TELEMETRY_DISABLED: '1', DSH_VERIFY_NO_NODE: '1',
|
||||
})
|
||||
const evidence = { manifest, runtime: runtimeReportSchema.parse(JSON.parse(await readFile(report, 'utf8'))), glibc: await docker('exec', container, 'getconf', 'GNU_LIBC_VERSION') }
|
||||
assert.equal(evidence.glibc, 'glibc 2.28')
|
||||
if (values.report !== undefined) {
|
||||
const destination = resolve(values.report)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
}
|
||||
console.log(`SSH helper real SSH ${manifest.target}: ${JSON.stringify(evidence.runtime)}`)
|
||||
} catch (error) {
|
||||
if (container === undefined) throw error
|
||||
const logs = await run('docker', ['logs', container], { maxBuffer: 1024 * 1024 })
|
||||
throw new Error(`SSH container diagnostics:\n${logs.stdout}\n${logs.stderr}`, { cause: error })
|
||||
} finally {
|
||||
try { if (container !== undefined) await docker('rm', '--force', container) }
|
||||
finally {
|
||||
try { if (imageBuilt) await docker('image', 'rm', image) }
|
||||
finally { await rm(temporary, { recursive: true, force: true }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await verifySshTransport(process.argv.slice(2))
|
||||
@@ -468,6 +468,7 @@
|
||||
"@deepseek-ai/dsh-spill-local": ["./packages/spill/spill-local/src"],
|
||||
"@deepseek-ai/dsh-spill-policy": ["./packages/spill/spill-policy/src"],
|
||||
"@deepseek-ai/dsh-ssh": ["./packages/ssh/ssh/src"],
|
||||
"@deepseek-ai/dsh-ssh-helper-runtime": ["./packages/ssh/ssh-helper-runtime/src"],
|
||||
"@deepseek-ai/dsh-storage": ["./packages/storage/storage/src"],
|
||||
"@deepseek-ai/dsh-storage-domain": ["./packages/storage/storage-domain/src"],
|
||||
"@deepseek-ai/dsh-storage-domain/invariant": ["./packages/storage/storage-domain/src/invariant.ts"],
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
{ "path": "./packages/subprocess/subprocess" },
|
||||
{ "path": "./packages/subprocess/subprocess-local" },
|
||||
{ "path": "./packages/ssh/ssh" },
|
||||
{ "path": "./packages/ssh/ssh-helper-runtime" },
|
||||
{ "path": "./packages/ssh/fs-ssh" },
|
||||
{ "path": "./packages/ssh/subprocess-ssh" },
|
||||
{ "path": "./packages/ssh/sandbox-ssh" },
|
||||
|
||||
Reference in new issue
Block a user