Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -Eeuo pipefail
project_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
version=${1:-}
if [[ -z $version ]]; then
version=$(npm view @deepseek-ai/dsh@latest version --registry=https://registry.npmjs.org)
fi
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo 'Expected an exact DSH version, not a dist-tag or arbitrary build argument.' >&2
exit 2
fi
tag="dsh-web:${version}-7"
printf 'Building local image %s (no push)\n' "$tag"
docker build --build-arg "DSH_VERSION=$version" --tag "$tag" "$project_dir/image"
docker image inspect --format '{{.Id}}' "$tag"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'status=$?; printf "Acceptance failed (exit %s): %s\n" "$status" "$BASH_COMMAND" >&2; exit "$status"' ERR
if [[ -n ${TEST_TMPDIR:-} ]]; then
export TMPDIR
TMPDIR=$(CDPATH= cd -- "$TEST_TMPDIR" && pwd)
[[ -w $TMPDIR && -x $TMPDIR ]]
fi
project_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd -- "$project_dir"
run() {
printf '\n==> %s\n' "$*"
"$@"
}
run go test -timeout=180s ./...
run go test -race -timeout=180s ./...
run go vet ./...
run go build ./...
run npm --prefix web test
run npm --prefix web run build
run node --test image/init-profile.test.mjs
run node --check scripts/test-panel.mjs
printf '\nAll acceptance checks passed. 所有测试与检查均通过。\n'
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const commands = [
'go test -timeout=180s ./...',
'go test -race -timeout=180s ./...',
'go vet ./...',
'go build ./...',
'npm --prefix web test',
'npm --prefix web run build',
'node --test image/init-profile.test.mjs',
'node --check scripts/test-panel.mjs',
];
function fixture(t) {
const home = mkdtempSync(join(tmpdir(), 'check-trial-'));
t.after(() => rmSync(home, { recursive: true, force: true }));
const project = join(home, 'project with spaces');
const bin = join(home, 'stubs');
mkdirSync(join(project, 'scripts'), { recursive: true });
mkdirSync(join(project, 'bin'));
mkdirSync(bin);
writeFileSync(join(project, 'bin', 'existing'), 'keep');
const script = join(project, 'scripts', 'check-trial.sh');
copyFileSync(new URL('./check-trial.sh', import.meta.url), script);
for (const tool of ['go', 'npm', 'node']) {
writeFileSync(join(bin, tool), `#!/bin/bash
command="${tool} $*"
printf '%s\t%s\t%s\n' "$command" "$PWD" "$TMPDIR" >> "$COMMAND_LOG"
if [[ $command == "$FAIL_COMMAND" ]]; then exit 23; fi
`, { mode: 0o755 });
}
const log = join(home, 'commands');
return {
home, project,
run(overrides = {}) {
const result = spawnSync('bash', [script], {
cwd: home, encoding: 'utf8',
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, COMMAND_LOG: log,
FAIL_COMMAND: '', TEST_TMPDIR: '', TMPDIR: home, ...overrides },
});
assert.ifError(result.error);
const rows = existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(line => line.split('\t')) : [];
assert.equal(readFileSync(join(project, 'bin', 'existing'), 'utf8'), 'keep');
return { ...result, rows };
},
};
}
test('runs all checks from the script project and preserves the default temp directory', t => {
const f = fixture(t);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.rows, commands.map(command => [command, f.project, f.home]));
assert.match(result.stdout, /All acceptance checks passed/);
});
for (const [index, command] of commands.entries()) {
test(`stops immediately when ${command} fails`, t => {
const result = fixture(t).run({ FAIL_COMMAND: command });
assert.equal(result.status, 23);
assert.deepEqual(result.rows.map(row => row[0]), commands.slice(0, index + 1));
assert.match(result.stderr, /Acceptance failed/);
assert.doesNotMatch(result.stdout, /All acceptance checks passed/);
});
}
test('accepts an explicit relative TEST_TMPDIR without removing its contents', t => {
const f = fixture(t);
const temp = join(f.home, 'custom temp');
mkdirSync(temp);
writeFileSync(join(temp, 'keep'), 'user data');
const result = f.run({ TEST_TMPDIR: 'custom temp' });
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.rows.map(row => row[2]), commands.map(() => temp));
assert.equal(readFileSync(join(temp, 'keep'), 'utf8'), 'user data');
});
test('rejects a missing TEST_TMPDIR without creating it or starting checks', t => {
const f = fixture(t);
const temp = join(f.home, 'missing');
const result = f.run({ TEST_TMPDIR: temp });
assert.notEqual(result.status, 0);
assert.deepEqual(result.rows, []);
assert.equal(existsSync(temp), false);
assert.doesNotMatch(result.stdout, /All acceptance checks passed/);
});
+106
View File
@@ -0,0 +1,106 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
const image = process.argv[2] ?? 'dsh-web:0.2.0-rc.2-1';
let interrupted = false;
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { interrupted = true; });
function docker(args, input) {
const result = spawnSync('docker', args, { input, encoding: 'utf8', timeout: 20000, maxBuffer: 16 * 1024 * 1024 });
if (result.error || result.status !== 0) throw new Error(`Docker ${args[0]} failed (output suppressed)`);
return result.stdout + (args[0] === 'logs' ? result.stderr : '');
}
function check(condition, message) {
if (!condition) throw new Error(message);
}
async function probe(publicURL) {
const name = `dsh-runtime-probe-${randomUUID()}`;
let created = false;
try {
const id = docker(['image', 'inspect', '--format', '{{.Id}}', image]).trim();
docker(['create', '--name', name, '--pull', 'never', '--network', 'none',
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
'--user', '1000:1000', '--memory', '2g', '--cpus', '2', '--pids-limit', '256',
'--tmpfs', '/tmp:rw,exec,nosuid,nodev,size=256m',
'--tmpfs', '/data/dsh-home:rw,nosuid,nodev,uid=1000,gid=1000,size=512m',
'--tmpfs', '/workspace:rw,nosuid,nodev,uid=1000,gid=1000,size=64m', id,
'web', '--no-open', '--port', '3080',
...(publicURL ? ['--public-url', publicURL, '--trusted-host', new URL(publicURL).host] : [])]);
created = true;
docker(['start', name]);
let startupURL;
for (let attempt = 0; attempt < 60; attempt++) {
check(!interrupted, 'Interrupted');
const logs = docker(['logs', name]);
const match = logs.match(/dsh web:\s*(https?:\/\/[^\s\x1b]+[?&]token=[^\s\x1b]+)/);
if (match) { startupURL = new URL(match[1]); break; }
if (docker(['inspect', '--format', '{{.State.Running}}', name]).trim() !== 'true') {
if (logs.includes("unknown option '--public-url'")) console.log(JSON.stringify({mode: 'public-url', supported: false, reason: "unknown option '--public-url'"}));
throw new Error('Runtime exited before readiness');
}
await delay(1000);
}
check(startupURL?.searchParams.get('token'), 'No startup token found (logs suppressed)');
if (publicURL) check(startupURL.origin + startupURL.pathname === publicURL, 'Public startup URL differs');
const input = JSON.stringify({ token: startupURL.searchParams.get('token'), publicURL });
const source = String.raw`
const fs = require('node:fs');
const path = require('node:path');
const { token, publicURL } = JSON.parse(fs.readFileSync(0, 'utf8'));
const headers = publicURL ? {host: new URL(publicURL).host, 'x-forwarded-proto': 'https'} : {};
const request = (suffix, extra = {}) => fetch('http://127.0.0.1:8080' + suffix, {headers: {...headers, ...extra}, redirect: 'manual', signal: AbortSignal.timeout(10000)});
(async () => {
const anonymous = await request('/');
const exchange = await request('/?token=' + encodeURIComponent(token));
const cookies = exchange.headers.getSetCookie();
const cookie = cookies.map(value => value.split(';')[0]).join('; ');
const home = await request('/', {cookie});
const html = await home.text();
const scripts = [...html.matchAll(/<script[^>]*src=["']([^"']+)/g)].map(match => match[1]);
const evidence = [];
function scan(dir) {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) scan(file);
else if (/\.(js|mjs|html)$/.test(file) && fs.statSync(file).size < 20000000) {
const text = fs.readFileSync(file, 'utf8');
for (const needle of ['new WebSocket(', 'function remoteStreamUrl', 'REMOTE_STREAM_MUX_PATH =']) {
let index = text.indexOf(needle);
if (index >= 0) evidence.push({file, needle, line: text.slice(0,index).split('\n').length, context: text.slice(Math.max(0,index-180),index+260)});
}
}
}
}
scan('/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-api-gateway/lib');
const result = {anonymousStatus: anonymous.status, exchangeStatus: exchange.status,
redirect: exchange.headers.get('location'), cookies: cookies.map(value => ({name: value.split('=')[0], attributes: value.split(';').slice(1).map(v => v.trim())})),
authenticatedStatus: home.status, htmlConfig: [...html.matchAll(/.{0,120}(?:WebSocket|websocket|endpoint|base href|wsPath).{0,240}/g)].map(m => m[0]), scripts, evidence};
// Never serialize raw credentials, even if an upstream response reflects them.
let output = JSON.stringify(result);
for (const secret of [token, ...cookies.map(value => value.split(';')[0].slice(value.indexOf('=')+1))]) if (secret) output = output.split(secret).join('[REDACTED]');
console.log(output);
})().catch(() => {console.error('In-container probe failed (details suppressed)'); process.exit(1);});
`;
const result = JSON.parse(docker(['exec', '-i', name, 'node', '-e', source], input));
console.log(JSON.stringify({mode: publicURL ? 'public-url' : 'default', startupURL: startupURL.origin + startupURL.pathname + '?token=[REDACTED]', ...result}, null, 2));
check(result.anonymousStatus === 401, 'Expected anonymous HTTP 401');
check(result.cookies.length > 0, 'Token exchange did not set cookie');
check(result.authenticatedStatus === 200, 'Authenticated homepage was not HTTP 200');
check(result.evidence.some(item => item.needle === 'REMOTE_STREAM_MUX_PATH ='), 'WebSocket path constant not found');
} finally {
if (created) {
docker(['rm', '-f', name]);
console.log(JSON.stringify({cleanup: 'removed', container: name}));
}
}
}
try {
await probe();
// Optional capability check: current npm rc.2 intentionally fails this mode.
if (process.argv.includes('--public-url')) await probe('https://probe.invalid/dsh/probe/');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -Eeuo pipefail
image_id=$(docker image inspect --format '{{.Id}}' "$1")
expected_version=$(docker image inspect --format '{{index .Config.Labels "dsh-mgr.version"}}' "$image_id")
[[ -n "$expected_version" && "$expected_version" != '<no value>' ]] || { echo 'FAIL: missing version label.' >&2; exit 1; }
container_id=$(docker run -d --read-only --cap-drop ALL --security-opt no-new-privileges --memory 2g --cpus 2 --pids-limit 256 --tmpfs /tmp:rw,exec,nosuid,nodev,size=256m --tmpfs /data/dsh-home:rw,uid=1000,gid=1000,size=512m --tmpfs /workspace:rw,uid=1000,gid=1000,size=64m "$image_id")
cleanup() { docker rm -f "$container_id" >/dev/null; }
trap cleanup EXIT
for ((attempt=0; attempt<60; attempt++)); do
if [[ $(docker inspect --format '{{.State.Running}}' "$container_id") != true ]]; then
echo 'FAIL: runtime exited before readiness.' >&2
exit 1
fi
if docker logs "$container_id" 2>&1 | grep 'dsh web:.*token=' >/dev/null; then
docker exec "$container_id" node -e 'fetch("http://127.0.0.1:8080/").then(r=>{console.log("Proxy HTTP status:",r.status);process.exit(r.status>=500?1:0)}).catch(()=>process.exit(1))'
actual_version=$(docker exec "$container_id" dsh --version)
[[ "$actual_version" == "$expected_version" ]] || { echo 'FAIL: CLI version does not match image label.' >&2; exit 1; }
printf 'Verified DSH version: %s\n' "$actual_version"
echo 'PASS: readiness under read-only rootfs, non-root and dropped capabilities.'
exit 0
fi
sleep 1
done
echo 'FAIL: readiness timeout; credential-bearing logs were not printed.' >&2
exit 1
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
const base=process.env.TEST_PANEL_URL;
assert.ok(base && process.env.TEST_PASSWORD_FILE);
let csrf='',cookie='';
async function req(path,method='GET',body){
const r=await fetch(base+path,{method,headers:{Cookie:cookie,Origin:base,'Content-Type':'application/json','X-CSRF-Token':csrf},body:body===undefined?undefined:JSON.stringify(body)});
if(path==='/api/login')cookie=r.headers.getSetCookie().map(v=>v.split(';')[0]).join('; ');
return {status:r.status,data:await r.json()};
}
const login=await req('/api/login','POST',{username:'admin',password:readFileSync(process.env.TEST_PASSWORD_FILE,'utf8').trim()});
assert.equal(login.status,200);csrf=login.data.csrf_token;
const before=(await req('/api/instances')).data;assert.equal(before.length,1,'run with exactly one retained test instance');
const intent=await req('/api/intents','POST',{name:'Design real intent',version:'0.2.0-rc.2'});assert.equal(intent.status,202);
let created;
try {
for(let n=0;n<60;n++){
const item=(await req('/api/intents')).data.find(x=>x.id===intent.data.id);
assert.notEqual(item.status,'failed');
if(item.instance_id){created=item.instance_id;break;}
await new Promise(r=>setTimeout(r,1000));
}
assert.ok(created,'intent must create a real instance');
const list=(await req('/api/instances')).data;assert.equal(list.length,2);assert.ok(list.every(i=>i.status==='running'));
const extra=await req('/api/instances','POST',{name:'must reject third',image_id:list[0].image_id});assert.equal(extra.status,409);
assert.equal((await req('/api/intents','POST',{name:'must reject extra reservation',version:'0.2.0-rc.2'})).status,409);
console.log('PASS persisted intent -> real runtime; two running instances; third create and reservation rejected');
} finally {
if(created)assert.equal((await req('/api/instances/'+created,'DELETE')).status,200);
else await req('/api/intents/'+intent.data.id,'DELETE');
}
assert.equal((await req('/api/instances')).data.length,1);
console.log('PASS deleted only this test runtime, retained data and prior instance');
+81
View File
@@ -0,0 +1,81 @@
import {execFileSync, spawnSync} from 'node:child_process';
import assert from 'node:assert/strict';
const image=process.argv[2]||'dsh-web:0.2.0-rc.2-6';
const docker=(args)=>execFileSync('docker',args,{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
const id=docker(['run','-d','--network','none','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges','--tmpfs','/tmp:rw,exec,nosuid,nodev,size=256m','--tmpfs','/data/dsh-home:rw,uid=1000,gid=1000,size=256m','--tmpfs','/workspace:rw,uid=1000,gid=1000,size=64m',image]);
try {
let ready=false;
for(let n=0;n<60;n++){
assert.equal(docker(['inspect','--format','{{.State.Running}}',id]),'true','runtime exited');
try {docker(['exec',id,'node','-e',"fetch('http://127.0.0.1:8080/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"]);ready=true;break;}catch{}
await new Promise(r=>setTimeout(r,1000));
}
assert.ok(ready,'offline startup readiness');
const startup = spawnSync('docker', ['logs', id], {encoding:'utf8'});
const token = (startup.stdout + startup.stderr).match(/[?&]token=([^\s\x1b]+)/)?.[1];
assert.ok(token, 'startup token available');
const rpc = (method, args = {}) => {
const source = "const {token,method,args}=JSON.parse(require('node:fs').readFileSync(0,'utf8')); (async()=>{const auth=await fetch('http://127.0.0.1:3080/?token='+encodeURIComponent(token),{redirect:'manual'});const cookie=auth.headers.getSetCookie().map(v=>v.split(';')[0]).join('; ');const r=await fetch('http://127.0.0.1:3080/api/'+method,{method:'POST',headers:{'content-type':'application/json',cookie},body:JSON.stringify({type:'client-request',rpcId:'workspace-test',method,payload:{args}})});console.log(JSON.stringify((await r.json()).result));})().catch(()=>process.exit(1));";
const result = spawnSync('docker',['exec','-i',id,'node','-e',source],{encoding:'utf8',input:JSON.stringify({token,method,args})});
assert.equal(result.status,0,'authenticated RPC failed');
return JSON.parse(result.stdout);
};
const initial = rpc('workspace/initializeDefault');
assert.equal(initial.ok, true, JSON.stringify(initial));
assert.equal(initial.value.workspace.path, '/workspace');
assert.deepEqual(initial.value.workspace.sessionIds, []);
const again = rpc('workspace/initializeDefault');
assert.deepEqual(again, initial);
const registered = rpc('workspace/create', {request:{path:'/workspace'}});
assert.equal(registered.ok,true);
assert.equal(registered.value.created,false);
assert.equal(registered.value.workspace.workspaceId, initial.value.workspace.workspaceId);
console.log('PASS real offline fresh registry: canonical /workspace, no sessions, durable default reused by controller and create');
const manifest=JSON.parse(docker(['exec',id,'cat','/data/dsh-home/profiles/web/package.json']));
assert.ok(manifest.dsh.profile.bundles.includes('dsh-web-mobile'));
assert.equal(manifest.dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz');
const inventory = (home = '/data/dsh-home') => JSON.parse(docker(['exec', id, 'node', '--input-type=module', '-e', "import {readProfilePlugins} from '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-app-boot/lib/index.js'; console.log(JSON.stringify(readProfilePlugins({binName:'dsh',profileDir:" + JSON.stringify(home + '/profiles/web') + ",installAnchor:'/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'}).dependencies))"]));
assert.deepEqual(inventory().sort((a,b)=>a.name.localeCompare(b.name)), [{name:'dsh-free-search',version:'0.8.5',bundle:true,enabled:true},{name:'dsh-mgr-workspace',version:'1.0.0',bundle:true,enabled:true},{name:'dsh-web-mobile',version:'3.0.5',bundle:true,enabled:true}]);
const bundles = JSON.parse(docker(['exec',id,'node','--input-type=module','-e', "import {PluginManager} from '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-plugin-manager/lib/index.js'; const manager=Object.create(PluginManager.prototype); Object.defineProperties(manager,{profile:{value:{dir:'/data/dsh-home/profiles/web',installAnchor:'/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'}},ctx:{value:{loader:{entries:()=>[]},get:()=>undefined}},managementBundles:{value:new Set()},ownerEntryId:{value:'include:plugin-manager'}}); console.log(JSON.stringify((await manager.listBundles()).filter(b=>['dsh-web-mobile','dsh-free-search'].includes(b.name))));"]));
assert.equal(bundles.length,2);
for (const bundle of bundles) {
assert.equal(bundle.installed,true);
assert.equal(bundle.enabled,true);
assert.equal(bundle.removable,false);
assert.equal(bundle.error,undefined);
assert.equal(bundle.readOnlyReason,undefined);
}
console.log('PASS upstream listBundles: mobile and free-search installed/enabled, no error/readOnlyReason; baked host bundle not removable');
docker(['exec',id,'node','--input-type=module','-e', "import {readdirSync,lstatSync,readlinkSync} from 'node:fs'; import {join,isAbsolute} from 'node:path'; import assert from 'node:assert/strict'; const root='/data/dsh-home/profiles/web/node_modules'; assert.deepEqual(readdirSync(root).filter(n=>!n.startsWith('.')),['@deepseek-ai','@standard-schema','dsh-free-search','dsh-mgr-workspace','dsh-web-mobile']); assert.deepEqual(readdirSync(join(root,'@deepseek-ai')).sort(),['cosmokit','schemastery']); assert.deepEqual(readdirSync(join(root,'@standard-schema')),['spec']); function scan(dir){for(const n of readdirSync(dir)){const p=join(dir,n);const s=lstatSync(p);if(s.isSymbolicLink())assert.equal(isAbsolute(readlinkSync(p)),false,p);else if(s.isDirectory())scan(p);}}scan(root);"]);
const dump=docker(['exec',id,'dsh','--profile','web','--dump-config']);
assert.match(dump,/name: ['"]?dsh-web-mobile/);
const logResult=spawnSync('docker',['logs',id],{encoding:'utf8'});
assert.equal(logResult.status,0);
assert.ok(!/failed to import|entry did not activate|entries did not activate/.test(logResult.stdout+logResult.stderr),'runtime activation failure (logs withheld to protect startup token)');
docker(['exec',id,'node','--input-type=module','-e', "const {apply}=await import('/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search/lib/index.js'); const {default:assert}=await import('node:assert/strict'); for(const selected of [undefined,'deepseek-official','custom']){const web={searchProviderId:selected,fetchProviderId:'custom-fetch',registerSearchProvider(){},registerFetchProvider(){}}; apply({web,logger:{info(){},warn(){}},inject(){},get(){}},{}); assert.equal(web.searchProviderId,selected==='custom'?'custom':'ddg'); assert.equal(web.fetchProviderId,'custom-fetch');}"]);
const settings = JSON.parse(docker(['exec',id,'node','-e', "fetch('http://127.0.0.1:3080/api/dsh-free-search-settings/describe',{method:'POST'}).then(async r=>{if(r.status!==200)throw new Error('status '+r.status);console.log(JSON.stringify(await r.json()))})"]));
assert.equal(settings.ok,true);
assert.equal(settings.value.namespaces.find(n=>n.ns==='web-search-free').value.provider,'bing');
console.log('PASS live free-search activation, official provider takeover and settings describe route');
console.log('PASS offline nonroot startup, readProfilePlugins inventory, no duplicate peers or absolute symlinks');
// Restore only the portable profile manifest/patch/lockfile, without workspace or node_modules.
docker(['exec',id,'bash','-ec', 'mkdir -p /tmp/mobile-restore/profiles/web; cp /data/dsh-home/profiles/web/package.json /data/dsh-home/profiles/web/pnpm-lock.yaml /tmp/mobile-restore/profiles/web/; if test -f /data/dsh-home/profiles/web/cordis.patch.yml; then cp /data/dsh-home/profiles/web/cordis.patch.yml /tmp/mobile-restore/profiles/web/; fi']);
docker(['exec',id,'node','-e', "const fs=require('node:fs'); const p='/tmp/mobile-restore/profiles/web/package.json'; const m=JSON.parse(fs.readFileSync(p)); m.dsh.profile.bundles=m.dsh.profile.bundles.filter(n=>n!=='dsh-web-mobile');fs.writeFileSync(p,JSON.stringify(m));"]);
docker(['exec', '-e', 'DSH_HOME=/tmp/mobile-restore', '-e', 'HOME=/tmp/mobile-restore', '-e', 'XDG_DATA_HOME=/tmp/mobile-restore/.local/share', '-e', 'npm_config_ignore_scripts=true', id, 'bash', '-ec', 'node /usr/local/lib/dsh-mgr/init-profile.mjs; dsh plugin --profile web install --ignore-scripts']);
assert.deepEqual(inventory('/tmp/mobile-restore').sort((a,b)=>a.name.localeCompare(b.name)), [{name:'dsh-free-search',version:'0.8.5',bundle:true,enabled:true},{name:'dsh-mgr-workspace',version:'1.0.0',bundle:true,enabled:true},{name:'dsh-web-mobile',version:'3.0.5',bundle:true,enabled:false}]);
const workspace = docker(['exec',id,'cat','/tmp/mobile-restore/profiles/web/pnpm-workspace.yaml']);
assert.match(workspace,/autoInstallPeers: false/);
assert.match(workspace,/nodeLinker: hoisted/);
console.log('PASS offline backup restore without workspace/store/node_modules; disabled bundle preserved');
docker(['exec',id,'node','-e', "const fs=require('node:fs'); const dir='/tmp/mobile-legacy/profiles/web'; fs.mkdirSync(dir,{recursive:true}); fs.writeFileSync(dir+'/package.json',JSON.stringify({name:'dsh-profile-web',private:true,dependencies:{'dsh-web-mobile':'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'},dsh:{profile:{bundles:['@deepseek-ai/dsh-base','@deepseek-ai/dsh-web-app','dsh-web-mobile']}}}));"]);
docker(['exec','-e','DSH_HOME=/tmp/mobile-legacy','-e','HOME=/tmp/mobile-legacy','-e','XDG_DATA_HOME=/tmp/mobile-legacy/.local/share','-e','npm_config_ignore_scripts=true',id,'bash','-ec','node /usr/local/lib/dsh-mgr/init-profile.mjs; dsh plugin --profile web install --ignore-scripts']);
assert.deepEqual(inventory('/tmp/mobile-legacy'),[{name:'dsh-web-mobile',version:'3.0.5',bundle:true,enabled:true}]);
console.log('PASS recipe4 mobile-only manifest restore; no free-search injection');
docker(['exec', id, 'node', '-e', "require('node:fs').writeFileSync('/workspace/normal','preserved')"]);
assert.equal(rpc('workspace/delete', {request:{workspaceId:initial.value.workspace.workspaceId}}).ok, true);
assert.equal(rpc('workspace/initializeDefault').value, undefined);
assert.equal(docker(['exec', id, 'cat', '/workspace/normal']), 'preserved');
assert.equal(rpc('workspace/create', {request:{path:'/workspace'}}).value.created, true);
assert.equal(rpc('workspace/initializeDefault').value, undefined);
console.log('PASS deleted default stays deleted; files preserved; explicit registration remains available');
} finally {docker(['rm','-f',id]);console.log('Removed dedicated smoke container (tmpfs data only)');}
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import {readFileSync,mkdirSync,existsSync} from 'node:fs';
import {execFileSync} from 'node:child_process';
import {randomBytes} from 'node:crypto';
const base=process.env.TEST_PANEL_URL, root=process.env.TEST_DATA_ROOT;
assert.ok(base&&root&&process.env.TEST_PASSWORD_FILE);
let cookie='',csrf='';
async function req(path,method='GET',body){const r=await fetch(base+path,{method,headers:{Cookie:cookie,Origin:base,'X-CSRF-Token':csrf,...(body instanceof FormData?{}:{'Content-Type':'application/json'})},body:body===undefined?undefined:body instanceof FormData?body:JSON.stringify(body)});if(path==='/api/login')cookie=r.headers.getSetCookie().map(x=>x.split(';')[0]).join('; ');return {status:r.status,data:await r.json()};}
const login=await req('/api/login','POST',{username:'admin',password:readFileSync(process.env.TEST_PASSWORD_FILE,'utf8').trim()});assert.equal(login.status,200);csrf=login.data.csrf_token;
const uid=login.data.user.id, inst=randomBytes(16).toString('hex'), home=root+'/workspaces/'+uid+'/instances/'+inst+'/home', workspace=root+'/workspaces/'+uid+'/files';
mkdirSync(home,{recursive:true});
const id=execFileSync('docker',['create','--name','dsh-inst-'+inst,'--label','dsh-mgr.role=dsh','--label','dsh-mgr.instance='+inst,'--label','dsh-mgr.owner='+uid,'--mount','type=bind,source='+home+',target=/data/dsh-home','--mount','type=bind,source='+workspace+',target=/workspace','--entrypoint','/bin/true','dsh-web:0.2.0-rc.2-2'],{encoding:'utf8'}).trim();
assert.ok((await req('/api/orphans')).data.includes(id));
assert.equal((await req('/api/orphans?id='+id+'&confirm=wrong','DELETE')).status,400);
assert.equal((await req('/api/orphans?id='+id+'&confirm='+id,'DELETE')).status,200);
assert.ok(existsSync(home));assert.ok(existsSync(workspace));
assert.ok(!(await req('/api/orphans')).data.includes(id));
console.log('PASS real stopped orphan reclaimed, confirmation enforced, bind directories retained');
const denied=new FormData();denied.set('path','.');denied.set('file',new Blob(['harmless fixture']),'design-denied.exe');
assert.equal((await req('/api/ws/upload','POST',denied)).status,415);
const name='design-allowed-'+inst+'.txt', allowed=new FormData();allowed.set('path','.');allowed.set('file',new Blob(['harmless fixture']),name);
assert.equal((await req('/api/ws/upload','POST',allowed)).status,200);assert.equal((await req('/api/ws/delete?path='+name,'DELETE')).status,200);
console.log('PASS configured upload whitelist accepts .txt and rejects .exe');
+87
View File
@@ -0,0 +1,87 @@
// Integration test against a disposable, locally running panel. No model calls.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
const base = process.env.TEST_PANEL_URL || 'http://127.0.0.1:18088';
const testDirectory='integration-'+Date.now();
let cookie = '', csrf = '', instance;
const cookies = new Map();
function keepCookies(response) {
for (const value of response.headers.getSetCookie()) { const pair=value.split(';')[0]; const at=pair.indexOf('='); cookies.set(pair.slice(0,at),pair.slice(at+1)); }
cookie=[...cookies].map(([key,value])=>key+'='+value).join('; ');
}
async function request(path, method = 'GET', body) {
if(path.startsWith('/api/ws/') && instance) path += (path.includes('?')?'&':'?')+'instance='+encodeURIComponent(instance.id);
const response = await fetch(base + path, {method,headers:{Cookie:cookie,Origin:base,'X-CSRF-Token':csrf,...(body instanceof FormData ? {} : {'Content-Type':'application/json'})},body:body===undefined?undefined:body instanceof FormData?body:JSON.stringify(body)});
keepCookies(response);
const text = await response.text(); let data; try {data=JSON.parse(text)}catch{data=text}
if(!response.ok)throw new Error(method+' '+path+': '+response.status+' '+String(typeof data==='object'?data.error:data).slice(0,300));
return {response,data};
}
try {
const login=await request('/api/login','POST',{username:process.env.TEST_USERNAME || 'testadmin',password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'});
cookie=login.response.headers.getSetCookie().map(v=>v.split(';')[0]).join('; ');csrf=login.data.csrf_token;
assert.equal((await request('/api/me')).data.user.username,process.env.TEST_USERNAME || 'testadmin');
await request('/api/refresh','POST');
assert.equal((await request('/api/me')).data.user.username,process.env.TEST_USERNAME || 'testadmin');
let images=(await request('/api/images')).data;
const build=(await request('/api/builds','POST',{version:process.env.TEST_FROM_VERSION || images[0]?.version || '0.2.0-rc.2'})).data;
let buildSucceeded=false;
for(let n=0;n<120;n++){const jobs=(await request('/api/builds')).data;const job=jobs.find(j=>j.id===build.id);if(job?.status==='failed')throw new Error('build/validation failed: '+job.error);if(job?.status==='success'){buildSucceeded=true;break;}await new Promise(r=>setTimeout(r,1000));}
assert.ok(buildSucceeded,'submitted build/validation job did not complete before timeout');
images=(await request('/api/images')).data.filter(i=>i.status==='available');assert.ok(images.length,'validated image required');
const source=images.find(i=>!process.env.TEST_FROM_VERSION || i.version===process.env.TEST_FROM_VERSION);
const target=images.find(i=>!process.env.TEST_TO_VERSION || i.version===process.env.TEST_TO_VERSION);
assert.ok(source,'source image required');assert.ok(target,'target image required');
console.log('PASS local image reuse, persistent build task and runtime verification');
instance=(await request('/api/instances','POST',{name:'Integration check',image_id:source.image_id || source.id,version:source.version})).data;
await request('/api/ws/mkdir','POST',{path:testDirectory});
const form=new FormData();form.set('path',testDirectory);form.set('file',new Blob(['hello workspace']), 'hello.txt');await request('/api/ws/upload','POST',form);
assert.equal((await request('/api/ws/download?path='+testDirectory+'/hello.txt')).data,'hello workspace');
await request('/api/ws/rename','POST',{path:testDirectory+'/hello.txt',new_path:testDirectory+'/renamed.txt'});
const zip=await fetch(base+'/api/ws/download?path='+testDirectory+'&instance='+encodeURIComponent(instance.id),{headers:{Cookie:cookie}});
assert.equal(zip.status,200);assert.match(zip.headers.get('content-type'),/zip/);
assert.equal(new Uint8Array(await zip.arrayBuffer()).slice(0,2).toString(),'80,75');
console.log('PASS login, workspace upload/download/rename');
let ready=false;
for(let n=0;n<45;n++){try{const r=await request('/dsh/'+instance.id+'/');if(typeof r.data==='string'&&r.data.includes('<html')){ready=true;break}}catch{}await new Promise(r=>setTimeout(r,1000));}
assert.ok(ready,'authenticated DSH proxy never became ready');
console.log('PASS real Docker create/start and authenticated DSH proxy');
if(process.env.TEST_PLUGIN==='1'){
const seed = `const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/package.json';const pkg=JSON.parse(fs.readFileSync(p,'utf8'));pkg.dependencies={...(pkg.dependencies||{}),'is-number':'7.0.0'};fs.writeFileSync(p,JSON.stringify(pkg));`;
execFileSync('docker',['exec','dsh-inst-'+instance.id,'node','-e',seed]);
console.log('Seeded exact plugin dependency fixture');
}
const root='/api/instances/'+instance.id;
await request(root+'/stop','POST');
const profile=(await request(root+'/profile')).data;assert.equal(typeof profile.content,'string');
await request(root+'/profile','PUT',{content:profile.content});
const backup=(await request(root+'/backups','POST',{password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'})).data;
assert.ok(backup.id,'backup ID');
const archive=await fetch(base+root+'/backups?download='+backup.id,{headers:{Cookie:cookie}});
assert.equal(archive.status,200);
const importForm=new FormData();importForm.set('file',new Blob([await archive.arrayBuffer()]),'roundtrip.dshbak');importForm.set('password',process.env.TEST_PASSWORD || 'Local-Test-Only-1842');
const imported=(await request(root+'/backups','POST',importForm)).data;assert.ok(imported.id);
await request(root+'/restore','POST',{backup_id:backup.id,password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'});
console.log('PASS stopped profile save, encrypted backup and restore');
await request(root+'/start','POST');await request(root+'/stop','POST');
if(process.env.TEST_UPGRADE==='1'){instance=(await request(root+'/upgrade','POST',{image_id:target.image_id||target.id,password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'})).data;console.log('PASS image replacement upgrade '+source.version+' -> '+target.version);}
if(process.env.TEST_PLUGIN==='1'){
execFileSync('docker',['exec',instance.container_id || 'dsh-inst-'+instance.id,'node','-e',`const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/node_modules/is-number/package.json';if(JSON.parse(fs.readFileSync(p)).version!=='7.0.0')process.exit(1)`]);
console.log('PASS manifest dependency rebuilt across restore and upgrade');
}
if(process.env.TEST_OFFLINE_ROLLBACK==='1'){
const container=instance.container_id;assert.ok(container);
execFileSync('docker',['exec',container,'node','-e',`const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/package.json';const pkg=JSON.parse(fs.readFileSync(p));pkg.dependencies['offline-design-fixture']='http://127.0.0.1:9/unavailable.tgz';fs.writeFileSync(p,JSON.stringify(pkg));fs.writeFileSync(process.env.DSH_HOME+'/rollback-marker','preserve-me');`]);
await request(root+'/stop','POST');
await assert.rejects(request(root+'/upgrade','POST',{image_id:target.image_id||target.id,password:process.env.TEST_PASSWORD}),/upgrade readiness failed/);
const current=(await request('/api/instances')).data.find(i=>i.id===instance.id);
assert.equal(current.container_id,container);assert.ok(['stopped','exited'].includes(current.status));assert.equal(current.version,target.version);
const inspect=JSON.parse(execFileSync('docker',['inspect',container]));assert.equal(inspect[0].State.Running,false);
console.log('PASS offline dependency failure rolls back identity and original stopped state');
}
await request('/api/ws/delete?path='+testDirectory+'/renamed.txt','DELETE');
await request('/api/ws/delete?path='+testDirectory,'DELETE');
console.log('PASS lifecycle and workspace cleanup');
}finally{
if(instance){await request('/api/instances/'+instance.id,'DELETE');console.log('Removed disposable instance; retained test data per API policy.');}
}