dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x

This commit is contained in:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

@@ -0,0 +1,63 @@
{
"version": "1.0.11",
"bootstrapApi": 1,
"systemSkillCatalog": {
"artifactId": "system-skill-catalog",
"source": "resources/skills",
"mediaType": "application/vnd.dsh-platform.system-skill-catalog.v1+tar+gzip"
},
"components": [
{
"id": "dsh-runtime",
"artifactId": "component-dsh-runtime",
"source": "dsh-runtime/component.json",
"mediaType": "application/vnd.dsh-platform.component.v1+json"
}
],
"patches": [
{
"id": "directory-picker",
"artifactId": "patch-directory-picker",
"source": "resources/patches/directory-picker.mjs",
"mediaType": "text/javascript"
},
{
"id": "browser-loopback",
"artifactId": "patch-browser-loopback",
"source": "resources/patches/browser-loopback.mjs",
"mediaType": "text/javascript"
},
{
"id": "managed-lifecycle",
"artifactId": "patch-managed-lifecycle",
"source": "resources/patches/managed-lifecycle.mjs",
"mediaType": "text/javascript"
},
{
"id": "native-corners",
"artifactId": "patch-native-corners",
"source": "resources/patches/native-corners.mjs",
"mediaType": "text/javascript"
}
],
"systemPlugins": [
{
"id": "platform-management",
"artifactId": "system-plugin-platform-management",
"source": "resources/plugins/platform-management/package",
"mediaType": "application/vnd.dsh-platform.system-plugin.v1+tar+gzip"
},
{
"id": "settings-navigation",
"artifactId": "system-plugin-settings-navigation",
"source": "resources/plugins/settings-navigation/package",
"mediaType": "application/vnd.dsh-platform.system-plugin.v1+tar+gzip"
},
{
"id": "settings-document-editor",
"artifactId": "system-plugin-settings-document-editor",
"source": "resources/plugins/settings-document-editor/package",
"mediaType": "application/vnd.dsh-platform.system-plugin.v1+tar+gzip"
}
]
}
@@ -0,0 +1,43 @@
{
"schema": 1,
"id": "dsh-runtime",
"type": "service",
"command": {
"executable": "/run/dsh-platform/views/runtime/bin/dsh",
"args": ["web", "--patch", "/run/dsh-platform/views/system-plugins/cordis.patch.yml", "--host", "127.0.0.1", "--port", "3079"],
"timeoutSeconds": 30
},
"environment": {
"HOME": "/home/node",
"LOGNAME": "node",
"USER": "node",
"DSH_BUNDLED_SKILL_DIR": "/run/dsh-platform/views/skills",
"XDG_CACHE_HOME": "/home/node/.cache",
"XDG_CONFIG_HOME": "/home/node/.config",
"XDG_DATA_HOME": "/home/node/.local/share"
},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/hooks/dsh-web-ready.mjs"],
"timeoutSeconds": 30
},
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "http",
"host": "127.0.0.1",
"port": 3079,
"path": "/",
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": {
"stdout": true,
"stderr": true
}
}
@@ -0,0 +1,41 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
export const BROWSER_CONNECTION_TARGET = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-connection/lib/client.js'
export const BROWSER_CONNECTION_RELATIVE_TARGET = 'node_modules/@deepseek-ai/dsh-client-connection/lib/client.js'
const LOOPBACK_CLASSIFICATIONS = Object.freeze([
'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),',
'isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),',
])
export function patchBrowserLoopback(target = BROWSER_CONNECTION_TARGET) {
const after = 'isLoopback: true,'
const source = readFileSync(target, 'utf8')
const matches = LOOPBACK_CLASSIFICATIONS.flatMap(before => source.split(before).slice(1).map(() => before))
if (matches.length !== 1) {
throw new Error(
`Expected exactly one browser loopback classification in ${target}, found ${matches.length}. ` +
'The upstream package may have changed; review this patch before building.',
)
}
writeFileSync(target, source.replace(matches[0], after))
}
export function applyPatch(dshRoot) {
patchBrowserLoopback(resolve(dshRoot, BROWSER_CONNECTION_RELATIVE_TARGET))
}
export function verifyPatch(dshRoot) {
const target = resolve(dshRoot, BROWSER_CONNECTION_RELATIVE_TARGET)
const source = readFileSync(target, 'utf8')
const matches = source.split('isLoopback: true,').length - 1
if (matches !== 1 || LOOPBACK_CLASSIFICATIONS.some(classification => source.includes(classification))) {
throw new Error(`Browser loopback Patch verification failed for ${target}`)
}
}
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) patchBrowserLoopback()
@@ -0,0 +1,38 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
export const DIRECTORY_PICKER_TARGET = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker-browse/lib/index.js'
export const DIRECTORY_PICKER_RELATIVE_TARGET = 'node_modules/@deepseek-ai/dsh-host-directory-picker-browse/lib/index.js'
export function patchDirectoryPicker(target = DIRECTORY_PICKER_TARGET) {
const before = 'const target = resolve(path ?? home);'
const after = 'const target = resolve(path ?? process.env.DSH_DEFAULT_WORKSPACE ?? home);'
const source = readFileSync(target, 'utf8')
const matches = source.split(before).length - 1
if (matches !== 1) {
throw new Error(
`Expected exactly one directory picker default path in ${target}, found ${matches}. ` +
'The upstream package may have changed; review this patch before building.',
)
}
writeFileSync(target, source.replace(before, after))
}
export function applyPatch(dshRoot) {
patchDirectoryPicker(resolve(dshRoot, DIRECTORY_PICKER_RELATIVE_TARGET))
}
export function verifyPatch(dshRoot) {
const target = resolve(dshRoot, DIRECTORY_PICKER_RELATIVE_TARGET)
const expected = 'const target = resolve(path ?? process.env.DSH_DEFAULT_WORKSPACE ?? home);'
const source = readFileSync(target, 'utf8')
const matches = source.split(expected).length - 1
if (matches !== 1 || source.includes('const target = resolve(path ?? home);')) {
throw new Error(`Directory picker Patch verification failed for ${target}`)
}
}
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) patchDirectoryPicker()
@@ -0,0 +1,550 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
export const MANAGED_LIFECYCLE_MODULE = 'lib/dsh-docker-managed-lifecycle.js'
export const PROFILE_PACKAGE_STORAGE_MODULE = 'lib/dsh-docker-profile-package-storage.js'
const BIN_TARGET = 'lib/bin.js'
const BIN_IMPORT = 'import { prepareManagedInvocation } from "./dsh-docker-managed-lifecycle.js";'
const PROFILE_IMPORT = 'import { managedSigtermHandler, markManagedReady, provideManagedLifecycle } from "./dsh-docker-managed-lifecycle.js";'
// parseDshArgs signature changed in dsh 0.2.0 (version and manageDesktopProfile come from runCli scope).
const BIN_DISPATCHES = [
'const invocation = parseDshArgs(process.argv.slice(2), readVersion());',
'const invocation = parseDshArgs(process.argv.slice(2), version, manageDesktopProfile);',
]
const binDispatchPatched = (dispatch) => `${dispatch}\nconst managedExitCode = await prepareManagedInvocation(invocation);\nif (managedExitCode !== null) process.exit(managedExitCode);`
// dsh 0.1.x indents the handler with 2 tabs, 0.2.x with 3 — match the shape, not the indent.
const SIGTERM_SOURCE = /process\.on\("SIGTERM", \(\) => \{\n\t+interrupt\(0\);\n\t+\}\);/g
const SIGTERM_PATCHED = 'process.on("SIGTERM", managedSigtermHandler(interrupt));'
const HOST_PROVIDER = 'hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment);'
const HOST_PROVIDER_PATCHED = `${HOST_PROVIDER}\n\t\tprovideManagedLifecycle(hostCtx);`
// Same: settled-return indentation differs between dsh 0.1.x (1 tab) and 0.2.x (2 tabs).
const PROFILE_RETURN_SOURCE = /(\t+)return \{\n\t+ctx,\n\t+shutdown\n\t+\};/g
const PROFILE_PACKAGE_STORAGE_SOURCE = String.raw`import {
closeSync, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync,
statSync, unlinkSync, writeFileSync
} from "node:fs";
import { spawnSync } from "node:child_process";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, normalize } from "node:path";
const MODULES_BACKUP = ".dsh-platform-node_modules.previous";
const WORKSPACE_BACKUP = ".dsh-platform-pnpm-workspace.previous";
const MIGRATION_COMMIT = ".dsh-platform-profile-migration.committed";
const PNPM_TIMEOUT_MS = 3e5;
function profileDirectory(profile) {
if (typeof profile !== "string" || profile === "" || profile === "." || profile === ".."
|| profile === "node_modules" || profile.includes("/") || profile.includes("\\")) {
throw new Error("invalid managed DSH Profile name");
}
const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
return { dshHome, profileDir: join(dshHome, "profiles", profile) };
}
function installedStore(profileDir) {
const modulesState = join(profileDir, "node_modules", ".modules.yaml");
if (!existsSync(modulesState)) return null;
let value;
try {
value = JSON.parse(readFileSync(modulesState, "utf8"));
} catch {
throw new Error("Web Profile package metadata is not valid pnpm state");
}
if (typeof value?.storeDir !== "string" || !isAbsolute(value.storeDir)) {
throw new Error("Web Profile package metadata has no absolute pnpm store");
}
return normalize(value.storeDir);
}
function withStoreDirectory(source, storeRoot) {
const line = "storeDir: " + JSON.stringify(storeRoot) + "\n";
const matches = source.match(/^storeDir:[^\r\n]*(?:\r?\n|$)/gm) ?? [];
if (matches.length > 1) throw new Error("Web Profile pnpm workspace has multiple storeDir entries");
if (matches.length === 1) return source.replace(/^storeDir:[^\r\n]*(?:\r?\n|$)/m, line);
return source.replace(/(?:\r?\n)?$/, "\n\n" + line);
}
function replaceFile(path, content) {
const temporary = path + ".dsh-platform-" + String(process.pid) + "-" + String(Date.now()) + ".tmp";
try {
writeFileSync(temporary, content, { flag: "wx", mode: statSync(path).mode & 0o777 });
renameSync(temporary, path);
} finally {
try { unlinkSync(temporary); } catch {}
}
}
function durableBackup(path, content, mode) {
const handle = openSync(path, "wx", mode);
try {
writeFileSync(handle, content);
fsyncSync(handle);
} finally {
closeSync(handle);
}
}
function restoreWorkspace(workspacePath, backupPath) {
if (!existsSync(backupPath)) return;
if (existsSync(workspacePath)) unlinkSync(workspacePath);
renameSync(backupPath, workspacePath);
}
function recoverInterruptedMigration(profileDir, workspacePath) {
const modulesPath = join(profileDir, "node_modules");
const modulesBackup = join(profileDir, MODULES_BACKUP);
const workspaceBackup = join(profileDir, WORKSPACE_BACKUP);
const commitMarker = join(profileDir, MIGRATION_COMMIT);
if (!existsSync(modulesBackup) && !existsSync(workspaceBackup) && !existsSync(commitMarker)) return;
if (existsSync(commitMarker)) {
rmSync(workspaceBackup, { force: true });
rmSync(modulesBackup, { recursive: true, force: true });
rmSync(commitMarker, { force: true });
return;
}
if (!existsSync(modulesBackup)) {
restoreWorkspace(workspacePath, workspaceBackup);
return;
}
if (existsSync(modulesPath)) rmSync(modulesPath, { recursive: true, force: true });
if (existsSync(modulesBackup)) renameSync(modulesBackup, modulesPath);
restoreWorkspace(workspacePath, workspaceBackup);
}
function pnpmInstall(profileDir, offline) {
const arguments_ = ["install"];
if (offline) arguments_.push("--offline");
arguments_.push("--frozen-lockfile", "--reporter=append-only");
return spawnSync("pnpm", arguments_, {
cwd: profileDir,
encoding: "utf8",
env: process.env,
timeout: PNPM_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024
});
}
function failureDetail(result) {
if (result.error !== void 0) return result.error.message;
const output = [result.stdout, result.stderr].filter((value) => typeof value === "string" && value.trim() !== "").join("\n");
return output.trim().slice(-4096) || "pnpm exited with status " + String(result.status);
}
function migrateProfile(profileDir, workspacePath, originalWorkspace, currentStore, storeRoot) {
const modulesPath = join(profileDir, "node_modules");
const modulesBackup = join(profileDir, MODULES_BACKUP);
const workspaceBackup = join(profileDir, WORKSPACE_BACKUP);
const commitMarker = join(profileDir, MIGRATION_COMMIT);
const targetStore = join(storeRoot, basename(currentStore));
let copiedStore = false;
let migratedStore;
try {
if (existsSync(currentStore) && statSync(currentStore).isDirectory()) {
try {
mkdirSync(targetStore, { recursive: true });
cpSync(currentStore, targetStore, { recursive: true, force: false, errorOnExist: false });
copiedStore = true;
} catch {}
}
durableBackup(workspaceBackup, originalWorkspace, statSync(workspacePath).mode & 0o777);
replaceFile(workspacePath, withStoreDirectory(originalWorkspace, storeRoot));
renameSync(modulesPath, modulesBackup);
let result = pnpmInstall(profileDir, copiedStore);
if (result.status !== 0 && copiedStore) {
rmSync(modulesPath, { recursive: true, force: true });
result = pnpmInstall(profileDir, false);
}
if (result.status !== 0) throw new Error(failureDetail(result));
migratedStore = installedStore(profileDir);
if (migratedStore === null || dirname(migratedStore) !== normalize(storeRoot)) {
throw new Error("pnpm rebuilt the Profile with an unexpected store");
}
durableBackup(commitMarker, "committed\n", 384);
} catch (error) {
if (existsSync(modulesPath)) rmSync(modulesPath, { recursive: true, force: true });
if (existsSync(modulesBackup)) renameSync(modulesBackup, modulesPath);
restoreWorkspace(workspacePath, workspaceBackup);
rmSync(commitMarker, { force: true });
throw new Error("failed to migrate Profile package storage: " + String(error?.message ?? error));
}
// The new layout is committed after verification. Cleanup is recoverable:
// removing the workspace backup first ensures any surviving modules backup
// is recognized as a committed migration on the next launch.
rmSync(workspaceBackup, { force: true });
rmSync(modulesBackup, { recursive: true, force: true });
rmSync(commitMarker, { force: true });
return migratedStore;
}
export function prepareProfilePackageStorage(profile) {
const { dshHome, profileDir } = profileDirectory(profile);
const manifestPath = join(profileDir, "package.json");
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
if (!existsSync(manifestPath) || !existsSync(workspacePath)) return Object.freeze({ status: "absent" });
const storeRoot = join(dshHome, ".pnpm-store");
recoverInterruptedMigration(profileDir, workspacePath);
const currentStore = installedStore(profileDir);
if (currentStore !== null && dirname(currentStore) !== normalize(storeRoot)) {
const migratedStore = migrateProfile(
profileDir, workspacePath, readFileSync(workspacePath, "utf8"), currentStore, storeRoot
);
console.log("dsh: migrated Profile package storage from " + currentStore + " to " + migratedStore);
return Object.freeze({ status: "migrated", currentStore: migratedStore, storeRoot });
}
const original = readFileSync(workspacePath, "utf8");
const updated = withStoreDirectory(original, storeRoot);
if (updated !== original) replaceFile(workspacePath, updated);
return Object.freeze({ status: "ready", currentStore, storeRoot });
}
`;
const ADAPTER_SOURCE = String.raw`import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { request } from "node:http";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { prepareProfilePackageStorage } from "./dsh-docker-profile-package-storage.js";
const API_PREFIX = "/_dsh_platform/api/v1/";
const REQUEST_TIMEOUT_MS = 3e3;
const RESTART_FALLBACK_MS = 10e3;
let managedSessionId = null;
function packageManifest(root, packageName) {
if (packageName.includes("\\")) return null;
const parts = packageName.split("/");
const validShape = packageName.startsWith("@") ? parts.length === 2 : parts.length === 1;
if (!validShape || parts.some((part) => part === "" || part === "." || part === "..")) {
return null;
}
return join(root, "node_modules", ...parts, "package.json");
}
function packageExists(root, packageName) {
const manifest = packageManifest(root, packageName);
return manifest !== null && existsSync(manifest);
}
function repairOrphanedProfileBundles() {
const profileDir = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "profiles", "web");
const manifestPath = join(profileDir, "package.json");
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const bundles = manifest?.dsh?.profile?.bundles;
if (!Array.isArray(bundles)) return;
const dependencies = new Set(Object.keys(manifest.dependencies ?? {}));
const dshRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const kept = [];
const removed = [];
for (const packageName of bundles) {
if (typeof packageName !== "string"
|| dependencies.has(packageName)
|| packageExists(profileDir, packageName)
|| packageExists(dshRoot, packageName)) {
kept.push(packageName);
} else {
removed.push(packageName);
}
}
if (removed.length === 0) return;
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: kept } };
const temporary = manifestPath + ".dsh-platform-" + String(process.pid) + "-" + String(Date.now()) + ".tmp";
try {
writeFileSync(temporary, JSON.stringify(manifest, null, 2) + "\n", {
flag: "wx",
mode: statSync(manifestPath).mode & 0o777
});
renameSync(temporary, manifestPath);
} finally {
try { unlinkSync(temporary); } catch {}
}
console.log("dsh: removed orphaned profile bundle(s): " + removed.join(", "));
}
function socketPath(name) {
return process.env.DSH_PLATFORM_RUN + "/" + name;
}
function requestJson(socket, method, path, body) {
return new Promise((resolveRequest, reject) => {
const bytes = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
const req = request({
socketPath: socket,
method,
path,
headers: bytes === void 0 ? {} : {
"content-type": "application/json",
"content-length": bytes.byteLength
}
}, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => {
let value = {};
try {
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {}
if ((response.statusCode ?? 500) >= 400) {
const error = new Error(value.error ?? "local API request failed");
error.statusCode = response.statusCode;
reject(error);
} else resolveRequest(value);
});
});
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error("local API request timed out")));
req.once("error", reject);
req.end(bytes);
});
}
function managedWeb(invocation) {
return process.env.DSH_PLATFORM_MANAGED === "1"
&& invocation?.mode === "profile"
&& invocation.profile === "web";
}
async function management(method, path) {
return requestJson(socketPath("management-cli.sock"), method, API_PREFIX + path);
}
export async function prepareManagedInvocation(invocation) {
if (process.env.DSH_PLATFORM_MANAGED === "1" && invocation?.mode === "plugin") {
try {
prepareProfilePackageStorage(invocation.profile);
} catch (error) {
console.error("dsh: failed to prepare Profile package storage: " + String(error?.message ?? error));
return 1;
}
return null;
}
if (!managedWeb(invocation)) return null;
if (typeof process.env.DSH_PLATFORM_RUN !== "string" || process.env.DSH_PLATFORM_RUN === "") {
console.error("dsh: managed lifecycle runtime directory is unavailable");
return 1;
}
let claimed;
try {
claimed = await requestJson(socketPath("dsh-lifecycle.sock"), "POST", "/v1/runtime/claim", {
launchToken: process.env.DSH_PLATFORM_LAUNCH_TOKEN ?? ""
});
} catch (error) {
if (error?.statusCode !== 409) {
console.error("dsh: managed lifecycle broker is unavailable; refusing an unsupervised Web instance");
return 1;
}
}
if (claimed !== void 0) {
if (typeof claimed.sessionId !== "string" || claimed.sessionId === "") {
console.error("dsh: managed lifecycle broker returned an invalid launch session");
return 1;
}
managedSessionId = claimed.sessionId;
delete process.env.DSH_PLATFORM_LAUNCH_TOKEN;
try {
prepareProfilePackageStorage("web");
} catch (error) {
console.error("dsh: Web Profile package storage repair failed; plugin changes may be unavailable: " + String(error?.message ?? error));
}
try {
repairOrphanedProfileBundles();
} catch (error) {
console.error("dsh: failed to repair orphaned Web Profile bundles: " + String(error?.message ?? error));
return 1;
}
return null;
}
let status;
try {
status = await management("GET", "status");
} catch {
console.error("dsh: DSH is managed by the container platform, but its Control Plane is unavailable");
console.error("dsh: open /_dsh_platform/console/ after the Control Plane recovers");
return 1;
}
const state = status?.dshLifecycle?.state;
if (state === "stopped") {
try {
const task = await management("POST", "start-dsh");
console.log("dsh: requested managed DSH start (task " + String(task.taskId) + ")");
return 0;
} catch (error) {
console.error("dsh: managed DSH start was rejected: " + String(error?.message ?? error));
return 1;
}
}
if (state === "failed" || status?.recoveryMode != null) {
console.error("dsh: managed DSH is in recovery mode; open /_dsh_platform/console/");
return 1;
}
if (["running", "starting", "stopping", "restarting", "recovering"].includes(state)) {
console.log("dsh: managed DSH is " + state + "; no second Web instance was started");
return 0;
}
console.error("dsh: managed DSH state is unavailable; open /_dsh_platform/console/");
return 1;
}
export function managedSigtermHandler(interrupt) {
if (managedSessionId === null) return () => interrupt(0);
let count = 0;
let fallback;
let abandoned = false;
return () => {
count += 1;
if (count > 1) {
abandoned = true;
if (fallback !== void 0) clearTimeout(fallback);
interrupt(0);
return;
}
void requestJson(socketPath("dsh-lifecycle.sock"), "POST", "/v1/runtime/signal", {
sessionId: managedSessionId,
signal: "SIGTERM"
}).then(async ({ disposition }) => {
if (abandoned) return;
if (disposition === "terminate") {
interrupt(0);
return;
}
if (disposition !== "request-restart") throw new Error("invalid lifecycle signal response");
const task = await management("POST", "restart-dsh");
console.log("dsh: requested managed DSH restart (task " + String(task.taskId) + ")");
fallback = setTimeout(() => interrupt(0), RESTART_FALLBACK_MS);
fallback.unref?.();
}).catch(() => interrupt(0));
};
}
export async function markManagedReady(ctx) {
if (managedSessionId === null) return;
const connection = ctx?.get?.("connection");
const webServer = ctx?.get?.("webServer");
const readyUrl = typeof connection?.authenticatedUrl === "function"
&& Number.isInteger(webServer?.port)
? connection.authenticatedUrl("http://127.0.0.1:" + String(webServer.port))
: null;
await requestJson(socketPath("dsh-lifecycle.sock"), "POST", "/v1/runtime/ready", {
sessionId: managedSessionId,
readyUrl
});
}
export function provideManagedLifecycle(hostCtx) {
if (managedSessionId === null) return;
hostCtx.provide("dshPlatformLifecycle", Object.freeze({
restart: () => management("POST", "restart-dsh")
}));
}
`
function exactlyOnce(source, pattern, name, path) {
const matches = [...source.matchAll(pattern)]
if (matches.length !== 1) {
throw new Error(`Expected exactly one ${name} in ${path}, found ${String(matches.length)}. The upstream package may have changed; review this patch before building.`)
}
return matches[0]
}
function sigtermSource(source) {
const matches = [...source.matchAll(SIGTERM_SOURCE)]
return matches.length === 1 ? matches[0] : null
}
function replaceOnce(source, before, after, name, path) {
const count = source.split(before).length - 1
if (count !== 1) {
throw new Error(`Expected exactly one ${name} in ${path}, found ${String(count)}. The upstream package may have changed; review this patch before building.`)
}
return source.replace(before, after)
}
function profileImplementation(dshRoot, binSource) {
// dsh 0.1.x: hashed chunk import; 0.2.x: bare facade ./profile-boot.js (any quote style).
const binMatch = exactlyOnce(
binSource,
/const \{ runProfile \} = await import\(['"](\.\/profile-boot(?:-[A-Za-z0-9_-]+)?\.js)['"]\);/g,
'dynamic Web Profile import',
join(dshRoot, BIN_TARGET),
)
const wrapperPath = join(dshRoot, 'lib', basename(binMatch[1]))
const wrapper = readFileSync(wrapperPath, 'utf8')
if (sigtermSource(wrapper) !== null && wrapper.includes(HOST_PROVIDER)) return wrapperPath
const implementation = exactlyOnce(
wrapper,
/import \{[^\n;]*\brunProfile\b[^\n;]*\} from ['"](\.\/profile-boot(?:-[A-Za-z0-9_-]+)?\.js)['"];?/g,
'Profile implementation import',
wrapperPath,
)
return join(dshRoot, 'lib', basename(implementation[1]))
}
export function applyPatch(dshRoot) {
const root = resolve(dshRoot)
const binPath = join(root, BIN_TARGET)
const bin = readFileSync(binPath, 'utf8')
const profilePath = profileImplementation(root, bin)
let profile = readFileSync(profilePath, 'utf8')
if (readFileSync(binPath, 'utf8').includes(BIN_IMPORT)) throw new Error('Managed lifecycle Patch is already applied')
if (existsSync(join(root, MANAGED_LIFECYCLE_MODULE))) throw new Error('Managed lifecycle adapter path already exists')
if (existsSync(join(root, PROFILE_PACKAGE_STORAGE_MODULE))) throw new Error('Profile package storage adapter path already exists')
exactlyOnce(bin, /^#!\/usr\/bin\/env node$/gm, 'DSH bin shebang', binPath)
let patchedBin = bin.replace(/^#!\/usr\/bin\/env node$/m, `#!/usr/bin/env node\n${BIN_IMPORT}`)
const dispatch = BIN_DISPATCHES.find(candidate => patchedBin.includes(candidate))
if (dispatch === undefined) {
throw new Error(`Expected one known invocation dispatch in ${binPath}; the upstream package may have changed. Known: ${JSON.stringify(BIN_DISPATCHES)}`)
}
patchedBin = replaceOnce(patchedBin, dispatch, binDispatchPatched(dispatch), 'DSH invocation dispatch', binPath)
profile = `${PROFILE_IMPORT}\n${profile}`
const sigterm = sigtermSource(profile)
if (sigterm === null) throw new Error(`Expected exactly one Web Profile SIGTERM handler in ${profilePath}, found 0. The upstream package may have changed; review this patch before building.`)
profile = profile.replace(SIGTERM_SOURCE, SIGTERM_PATCHED)
profile = replaceOnce(profile, HOST_PROVIDER, HOST_PROVIDER_PATCHED, 'Web Profile host provider', profilePath)
// Settled return: preserve the source indentation, insert the ready-marker before it.
const settledMatches = [...profile.matchAll(PROFILE_RETURN_SOURCE)]
if (settledMatches.length !== 1) {
throw new Error(`Expected exactly one Web Profile settled return in ${profilePath}, found ${String(settledMatches.length)}. The upstream package may have changed; review this patch before building.`)
}
const settled = settledMatches[0]
profile = profile.replace(
PROFILE_RETURN_SOURCE,
(match, indent) => `${indent}if (ctx.get("loader") !== void 0) await markManagedReady(ctx);\n${match}`,
)
writeFileSync(join(root, MANAGED_LIFECYCLE_MODULE), ADAPTER_SOURCE)
writeFileSync(join(root, PROFILE_PACKAGE_STORAGE_MODULE), PROFILE_PACKAGE_STORAGE_SOURCE)
writeFileSync(profilePath, profile)
writeFileSync(binPath, patchedBin)
}
export function verifyPatch(dshRoot) {
const root = resolve(dshRoot)
const binPath = join(root, BIN_TARGET)
const bin = readFileSync(binPath, 'utf8')
const profilePath = profileImplementation(root, bin)
const profile = readFileSync(profilePath, 'utf8')
const adapter = readFileSync(join(root, MANAGED_LIFECYCLE_MODULE), 'utf8')
const packageStorage = readFileSync(join(root, PROFILE_PACKAGE_STORAGE_MODULE), 'utf8')
const dispatched = BIN_DISPATCHES.filter(candidate => bin.split(binDispatchPatched(candidate)).length - 1 === 1)
if (bin.split(BIN_IMPORT).length - 1 !== 1 || dispatched.length !== 1) {
throw new Error(`Managed lifecycle bin Patch verification failed for ${binPath}`)
}
if (profile.split(PROFILE_IMPORT).length - 1 !== 1
|| profile.split(SIGTERM_PATCHED).length - 1 !== 1
|| profile.split(HOST_PROVIDER_PATCHED).length - 1 !== 1
|| [...profile.matchAll(/\t+if \(ctx\.get\("loader"\) !== void 0\) await markManagedReady\(ctx\);\n\t+return \{\n\t+ctx,\n\t+shutdown\n\t+\};/g)].length !== 1
|| sigtermSource(profile) !== null) {
throw new Error(`Managed lifecycle Profile Patch verification failed for ${profilePath}`)
}
for (const marker of ['prepareManagedInvocation', 'managedSigtermHandler', 'markManagedReady', 'provideManagedLifecycle']) {
if (!adapter.includes(`export function ${marker}`) && !adapter.includes(`export async function ${marker}`)) {
throw new Error(`Managed lifecycle adapter is missing ${marker}`)
}
}
if (!adapter.includes('function repairOrphanedProfileBundles()')) {
throw new Error('Managed lifecycle adapter is missing repairOrphanedProfileBundles')
}
for (const marker of ['prepareProfilePackageStorage', 'migrateProfile', '.pnpm-store']) {
if (!packageStorage.includes(marker)) throw new Error(`Managed lifecycle Profile package storage is missing ${marker}`)
}
}
@@ -0,0 +1,29 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
export const NATIVE_CORNERS_RELATIVE_TARGET = 'node_modules/@deepseek-ai/dsh-client-ui-theme/lib/client.js'
const BEFORE = 'var corner_shape_css_default = "@supports (corner-shape:superellipse(1.5)){:root{--dsw-corner-shape:superellipse(1.5)}*,:before,:after{corner-shape:var(--dsw-corner-shape)}}";'
const AFTER = 'var corner_shape_css_default = "";'
export function patchNativeCorners(target) {
if (!existsSync(target)) return
const source = readFileSync(target, 'utf8')
const matches = source.split(BEFORE).length - 1
if (matches !== 1) throw new Error(`Expected exactly one DSH global corner theme in ${target}, found ${matches}. The upstream package may have changed; review this patch before building.`)
writeFileSync(target, source.replace(BEFORE, AFTER))
}
export function applyPatch(dshRoot) {
patchNativeCorners(resolve(dshRoot, NATIVE_CORNERS_RELATIVE_TARGET))
}
export function verifyPatch(dshRoot) {
const target = resolve(dshRoot, NATIVE_CORNERS_RELATIVE_TARGET)
if (!existsSync(target)) return
const source = readFileSync(target, 'utf8')
if (source.split(AFTER).length - 1 !== 1 || source.includes(BEFORE)) throw new Error(`Native corners Patch verification failed for ${target}`)
}
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) patchNativeCorners(process.argv[2])
@@ -0,0 +1,10 @@
[
{
"insert": [
{
"id": "dsh-docker.platform-management.plugin",
"name": "@dsh-docker/platform-management"
}
]
}
]
@@ -0,0 +1,128 @@
import { readFileSync, watch } from 'node:fs'
import { basename, dirname } from 'node:path'
import { installProviderRouting } from './provider-routing.js'
export const name = '@dsh-docker/platform-management'
export const inject = ['subprocess']
const PROXY_KEYS = Object.freeze([
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy',
'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy',
])
const CONNECTION_PROXY_KEYS = Object.freeze([
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy',
])
const SHARED_DSH_PROXY_URL = 'http://127.0.0.1:17898'
const ROUTING_STATE_PATH = `${process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'}/outbound-proxy-routing.json`
function routingState(path = ROUTING_STATE_PATH) {
try {
const value = JSON.parse(readFileSync(path, 'utf8'))
if (value?.schema !== 1 || typeof value.enabled !== 'boolean' || typeof value.scopes?.agentNetwork !== 'boolean') return null
return value
} catch {
return null
}
}
function agentNetworkEnvironment(environment = process.env) {
const proxy = environment.DSH_PLATFORM_AGENT_PROXY_URL
if (typeof proxy !== 'string' || proxy === '') return null
const noProxy = environment.NO_PROXY ?? environment.no_proxy ?? ''
const allProxy = environment.ALL_PROXY !== undefined || environment.all_proxy !== undefined
return {
HTTP_PROXY: proxy,
HTTPS_PROXY: proxy,
http_proxy: proxy,
https_proxy: proxy,
NO_PROXY: noProxy,
no_proxy: noProxy,
...(allProxy ? { ALL_PROXY: proxy, all_proxy: proxy } : {}),
}
}
function sharedDshProxyEnabled(state) {
return state !== null && state.enabled
&& (state.scopes?.dshCore === true || state.scopes?.dshPlugins === true)
}
function synchronizeSharedDshEnvironment(environment, state) {
if (state === null) return
for (const key of CONNECTION_PROXY_KEYS) delete environment[key]
const noProxy = [...new Set([
...(state.noProxy?.system ?? []),
...(state.noProxy?.user ?? []),
])].join(',')
environment.NO_PROXY = noProxy
environment.no_proxy = noProxy
if (!sharedDshProxyEnabled(state)) return
for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) environment[key] = SHARED_DSH_PROXY_URL
if (state.environment?.allProxy === 'scope-proxy') {
environment.ALL_PROXY = SHARED_DSH_PROXY_URL
environment.all_proxy = SHARED_DSH_PROXY_URL
}
}
function watchSharedDshEnvironment(path = ROUTING_STATE_PATH, environment = process.env) {
const original = Object.fromEntries(PROXY_KEYS.map(key => [key, environment[key]]))
const synchronize = () => synchronizeSharedDshEnvironment(environment, routingState(path))
synchronize()
let watcher
try {
watcher = watch(dirname(path), (_event, filename) => {
if (filename === null || filename.toString() === basename(path)) synchronize()
})
watcher.on('error', () => {})
} catch {}
return () => {
watcher?.close()
for (const key of PROXY_KEYS) {
if (original[key] === undefined) delete environment[key]
else environment[key] = original[key]
}
}
}
function managedSubprocessSpec(spec, environment = process.env, routingPath = ROUTING_STATE_PATH) {
if (spec?.env?.DSH_SHELL !== '1') return spec
const env = { ...spec.env }
for (const key of PROXY_KEYS) delete env[key]
const state = routingState(routingPath)
if (state !== null && (!state.enabled || !state.scopes.agentNetwork)) {
for (const key of PROXY_KEYS) env[key] = undefined
return { ...spec, env }
}
const proxy = agentNetworkEnvironment(environment)
if (proxy === null) return spec
return { ...spec, env: { ...env, ...proxy } }
}
export function apply(ctx) {
const removeSharedDshEnvironment = watchSharedDshEnvironment()
const removeProviderRouting = installProviderRouting(ctx)
const service = ctx.subprocess
const spawn = service.spawn
const spawnTerminal = service.spawnTerminal
const managedSpawn = function (spec) {
return spawn.call(this, managedSubprocessSpec(spec))
}
const managedSpawnTerminal = function (spec) {
return spawnTerminal.call(this, managedSubprocessSpec(spec))
}
service.spawn = managedSpawn
service.spawnTerminal = managedSpawnTerminal
ctx.effect(() => () => {
removeSharedDshEnvironment()
removeProviderRouting()
if (service.spawn === managedSpawn) service.spawn = spawn
if (service.spawnTerminal === managedSpawnTerminal) service.spawnTerminal = spawnTerminal
}, 'dsh-docker managed network scopes')
}
export const managedNetworkInternals = Object.freeze({
agentNetworkEnvironment,
managedSubprocessSpec,
routingState,
sharedDshProxyEnabled,
synchronizeSharedDshEnvironment,
})
@@ -0,0 +1,248 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { readFileSync } from 'node:fs'
import { request } from 'node:http'
import { Agent, fetch as proxyFetch, ProxyAgent } from 'undici'
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/
const FETCH_ROUTER = Symbol.for('dsh-docker.platform-management.provider-fetch-router')
const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
const DEFAULT_SOCKET = '/run/dsh-platform/outbound-proxy.sock'
const DEFAULT_PROXY_URL = 'http://127.0.0.1:17897'
const DEFAULT_SHARED_PROXY_URL = 'http://127.0.0.1:17898'
const DEFAULT_AGENT_PROXY_URL = 'http://127.0.0.1:17895'
const DEFAULT_ROUTING_STATE = '/run/dsh-platform/outbound-proxy-routing.json'
function routingState(path) {
try {
const value = JSON.parse(readFileSync(path, 'utf8'))
if (value?.schema !== 1 || typeof value.enabled !== 'boolean' || value.modelApi === null || typeof value.modelApi !== 'object') return null
return value
} catch {
return null
}
}
function providerProxyEnabled(state, providerId) {
if (state === null || !state.enabled) return false
const policy = state.modelApi.providers?.[providerId] ?? state.modelApi.default
return policy?.proxyEnabled === true
}
function providerRoutingCapability(options) {
return options?.routingCapability === 'shared-dsh' || options?.supportsIndependentRouting === false
? 'shared-dsh'
: 'provider'
}
function sharedDshProxyEnabled(state) {
return state !== null && state.enabled
&& (state.scopes?.dshCore === true || state.scopes?.dshPlugins === true)
}
function agentNetworkProxyEnabled(state) {
return state !== null && state.enabled && state.scopes?.agentNetwork === true
}
function controlRequest(socketPath, method, path, body) {
return new Promise((resolve, reject) => {
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
const outgoing = request({
socketPath,
method,
path,
headers: bytes === undefined ? {} : {
'content-type': 'application/json',
'content-length': bytes.byteLength,
},
signal: AbortSignal.timeout(5_000),
}, response => {
const chunks = []
let size = 0
response.on('data', chunk => {
size += chunk.byteLength
if (size > MAX_CONTROL_RESPONSE_BYTES) outgoing.destroy(new Error('Outbound Proxy response is too large'))
else chunks.push(chunk)
})
response.on('end', () => {
let value
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
reject(new Error('Outbound Proxy response is invalid'))
return
}
if ((response.statusCode ?? 500) >= 400) {
const error = new Error(value?.error?.message ?? `Outbound Proxy returned HTTP ${String(response.statusCode)}`)
error.statusCode = response.statusCode
error.code = value?.error?.code
reject(error)
} else resolve(value)
})
})
outgoing.once('error', reject)
outgoing.end(bytes)
})
}
async function issueProviderHandle(providerId, socketPath) {
for (let attempt = 0; attempt < 2; attempt += 1) {
const configuration = await controlRequest(socketPath, 'GET', '/v1/configuration')
try {
return await controlRequest(socketPath, 'POST', '/v1/provider-handles', {
providerId,
policyRevision: configuration.revision,
})
} catch (error) {
if (error?.code !== 'REVISION_CONFLICT' || attempt > 0) throw error
}
}
throw new Error('Provider policy changed repeatedly')
}
function installFetchRouter(store, routedFetch = proxyFetch, resolveDefaultRoute = () => undefined) {
const owner = Object.freeze({})
let registry = globalThis[FETCH_ROUTER]
if (registry === undefined) {
const original = globalThis.fetch
registry = { original, owner, store, routedFetch, resolveDefaultRoute }
const wrapper = function (input, init) {
const route = registry.store.getStore() ?? registry.resolveDefaultRoute()
if (route === undefined || (init !== undefined && Object.hasOwn(init, 'dispatcher'))) {
return registry.original.call(this, input, init)
}
return registry.routedFetch(input, { ...init, dispatcher: route.dispatcher })
}
registry.wrapper = wrapper
globalThis[FETCH_ROUTER] = registry
globalThis.fetch = wrapper
} else {
registry.owner = owner
registry.store = store
registry.routedFetch = routedFetch
registry.resolveDefaultRoute = resolveDefaultRoute
}
return () => {
if (registry.owner !== owner) return
if (globalThis.fetch === registry.wrapper) globalThis.fetch = registry.original
delete globalThis[FETCH_ROUTER]
}
}
function routedIterator(next, providerId, store, { resolveRoute }) {
let iterator
let routePromise
let closed = false
async function route() {
if (routePromise === undefined) {
routePromise = resolveRoute(providerId)
}
return routePromise
}
async function invoke(method, value) {
const context = await route()
if (iterator === undefined) iterator = store.run(context, () => next()[Symbol.asyncIterator]())
if (typeof iterator[method] !== 'function') {
await close(context)
if (method === 'throw') throw value
return { value, done: true }
}
try {
const result = await store.run(context, () => iterator[method](value))
if (result.done) await close(context)
return result
} catch (error) {
await close(context)
throw error
}
}
async function close(context) {
if (closed) return
closed = true
if (context.owned) await context.dispatcher.close()
}
return {
next: value => invoke('next', value),
return: value => invoke('return', value),
throw: value => invoke('throw', value),
[Symbol.asyncIterator]() { return this },
}
}
export function installProviderRouting(ctx, {
socketPath = process.env.DSH_PLATFORM_RUN === undefined
? DEFAULT_SOCKET
: `${process.env.DSH_PLATFORM_RUN}/outbound-proxy.sock`,
proxyUrl = DEFAULT_PROXY_URL,
routingStatePath = process.env.DSH_PLATFORM_RUN === undefined
? DEFAULT_ROUTING_STATE
: `${process.env.DSH_PLATFORM_RUN}/outbound-proxy-routing.json`,
createDispatcher = options => new ProxyAgent(options),
createSharedDispatcher = options => new ProxyAgent(options),
createAgentDispatcher = options => new ProxyAgent(options),
createDirectDispatcher = () => new Agent(),
routedFetch = proxyFetch,
} = {}) {
const store = new AsyncLocalStorage()
const directDispatcher = createDirectDispatcher()
const sharedDispatcher = createSharedDispatcher({ uri: DEFAULT_SHARED_PROXY_URL })
const agentDispatcher = createAgentDispatcher({ uri: DEFAULT_AGENT_PROXY_URL })
const resolveDefaultRoute = () => {
const state = routingState(routingStatePath)
if (state === null) return undefined
return { dispatcher: sharedDshProxyEnabled(state) ? sharedDispatcher : directDispatcher }
}
const removeFetchRouter = installFetchRouter(store, routedFetch, resolveDefaultRoute)
const disposeTool = ctx.on('tools/execute', (_execution, next) => {
const state = routingState(routingStatePath)
return store.run({
dispatcher: agentNetworkProxyEnabled(state) ? agentDispatcher : directDispatcher,
owned: false,
}, next)
}, { global: true, prepend: true })
const disposeStream = ctx.on('llm/stream', (options, next) => {
if (!PROVIDER_ID_PATTERN.test(options.provider)) return next()
const capability = providerRoutingCapability(options)
return routedIterator(next, options.provider, store, {
resolveRoute: async providerId => {
const state = routingState(routingStatePath)
if (state !== null && !providerProxyEnabled(state, providerId)) {
return { dispatcher: directDispatcher, owned: false }
}
if (capability === 'shared-dsh') {
return {
dispatcher: sharedDshProxyEnabled(state) ? sharedDispatcher : directDispatcher,
owned: false,
}
}
const { handle } = await issueProviderHandle(providerId, socketPath)
return {
dispatcher: createDispatcher({ uri: proxyUrl, token: `DSH-Provider ${handle}` }),
owned: true,
}
},
})
}, { global: true, prepend: true })
return () => {
disposeTool?.()
disposeStream?.()
removeFetchRouter()
void directDispatcher.close()
void sharedDispatcher.close()
void agentDispatcher.close()
}
}
export const providerRoutingInternals = Object.freeze({
FETCH_ROUTER,
controlRequest,
installFetchRouter,
issueProviderHandle,
agentNetworkProxyEnabled,
providerProxyEnabled,
providerRoutingCapability,
sharedDshProxyEnabled,
routingState,
routedIterator,
})
@@ -0,0 +1,32 @@
{
"name": "@dsh-docker/platform-management",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dsh-docker/platform-management",
"version": "1.0.0",
"dependencies": {
"undici": "8.10.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"react": "^18.2.0"
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
}
}
}
@@ -0,0 +1,39 @@
{
"name": "@dsh-docker/platform-management",
"version": "1.0.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.bundle.js",
"./package.json": "./package.json"
},
"dependencies": {
"undici": "8.10.0"
},
"dshDocker": {
"description": {
"zh": "管理 DSH Docker 更新、运行维护、系统资源与登录会话。",
"en": "Manage DSH Docker updates, runtime maintenance, system resources, and login sessions."
}
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"react": "^18.2.0"
}
}
@@ -0,0 +1,10 @@
[
{
"insert": [
{
"id": "dsh-docker.settings-document-editor.plugin",
"name": "@dsh-docker/settings-document-editor"
}
]
}
]
@@ -0,0 +1,270 @@
window.__ModuleLoader__.load({ id: "@dsh-docker/settings-document-editor", factory: (require) => {
var module = { exports: {} }; var exports = module.exports;
const css = Object.freeze({"actions":"dshSettingsDocumentEditor_actions","body":"dshSettingsDocumentEditor_body","close":"dshSettingsDocumentEditor_close","confirmActions":"dshSettingsDocumentEditor_confirmActions","confirmation":"dshSettingsDocumentEditor_confirmation","danger":"dshSettingsDocumentEditor_danger","dialog":"dshSettingsDocumentEditor_dialog","dirty":"dshSettingsDocumentEditor_dirty","editor":"dshSettingsDocumentEditor_editor","editorFrame":"dshSettingsDocumentEditor_editorFrame","error":"dshSettingsDocumentEditor_error","footer":"dshSettingsDocumentEditor_footer","header":"dshSettingsDocumentEditor_header","lineNumbers":"dshSettingsDocumentEditor_lineNumbers","loading":"dshSettingsDocumentEditor_loading","mask":"dshSettingsDocumentEditor_mask","overlay":"dshSettingsDocumentEditor_overlay","primary":"dshSettingsDocumentEditor_primary","saved":"dshSettingsDocumentEditor_saved"});
const styleId = "@dsh-docker/settings-document-editor/style.module.css";
if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) {
const tag = document.createElement('style');
tag.dataset.plugin = "@dsh-docker/settings-document-editor";
tag.dataset.pluginCss = styleId;
tag.textContent = ".dshSettingsDocumentEditor_overlay {\n position: fixed;\n inset: 0;\n z-index: 1200;\n display: grid;\n place-items: center;\n padding: 24px;\n color: var(--dsw-alias-label-primary);\n}\n\n.dshSettingsDocumentEditor_overlay,\n.dshSettingsDocumentEditor_overlay *,\n.dshSettingsDocumentEditor_overlay *::before,\n.dshSettingsDocumentEditor_overlay *::after {\n corner-shape: round;\n}\n\n.dshSettingsDocumentEditor_mask {\n position: absolute;\n inset: 0;\n background: rgb(0 0 0 / 48%);\n}\n\n.dshSettingsDocumentEditor_dialog {\n position: relative;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr) auto;\n box-sizing: border-box;\n width: min(820px, 100%);\n height: min(680px, calc(100dvh - 48px));\n min-height: 420px;\n overflow: hidden;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n.dshSettingsDocumentEditor_header,\n.dshSettingsDocumentEditor_footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 18px;\n}\n\n.dshSettingsDocumentEditor_header { border-bottom: 1px solid var(--dsw-alias-border-l2); }\n.dshSettingsDocumentEditor_footer { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshSettingsDocumentEditor_header h2 { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; }\n.dshSettingsDocumentEditor_header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }\n\n.dshSettingsDocumentEditor_close {\n flex: none;\n width: 32px;\n height: 32px;\n padding: 0;\n border: 0;\n border-radius: 6px;\n color: var(--dsw-alias-label-secondary);\n background: transparent;\n font: 18px/32px ui-sans-serif, system-ui, sans-serif;\n cursor: pointer;\n}\n.dshSettingsDocumentEditor_close:hover { background: var(--dsw-alias-interactive-bg-hover); }\n\n.dshSettingsDocumentEditor_body { position: relative; min-height: 0; padding: 14px 18px; }\n.dshSettingsDocumentEditor_editorFrame {\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n box-sizing: border-box;\n width: 100%;\n height: 100%;\n min-height: 300px;\n overflow: hidden;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-base);\n}\n.dshSettingsDocumentEditor_editorFrame:focus-within { border-color: var(--dsw-alias-border-l2); }\n\n.dshSettingsDocumentEditor_lineNumbers {\n box-sizing: border-box;\n min-width: 44px;\n height: 100%;\n margin: 0;\n overflow: hidden;\n padding: 12px 10px;\n border-right: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-tertiary);\n background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, var(--dsw-alias-bg-module-platform));\n font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right;\n user-select: none;\n}\n\n.dshSettingsDocumentEditor_editor {\n box-sizing: border-box;\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n resize: none;\n padding: 12px 14px;\n border: 0;\n outline: none;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n letter-spacing: 0;\n tab-size: 2;\n}\n\n.dshSettingsDocumentEditor_loading {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 9px;\n height: 100%;\n color: var(--dsw-alias-label-secondary);\n font-size: 13px;\n}\n.dshSettingsDocumentEditor_loading span {\n width: 14px;\n height: 14px;\n border: 2px solid var(--dsw-alias-border-l2);\n border-top-color: var(--dsw-alias-label-primary);\n border-radius: 50%;\n animation: document-editor-spin .8s linear infinite;\n}\n@keyframes document-editor-spin { to { transform: rotate(360deg); } }\n\n.dshSettingsDocumentEditor_error {\n position: absolute;\n right: 28px;\n bottom: 24px;\n left: 28px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 9px 11px;\n border-left: 3px solid var(--dsw-alias-state-error-primary);\n color: var(--dsw-alias-state-error-primary);\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 4px 16px rgb(0 0 0 / 18%);\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshSettingsDocumentEditor_actions,\n.dshSettingsDocumentEditor_confirmActions { display: flex; justify-content: flex-end; gap: 8px; }\n.dshSettingsDocumentEditor_footer button,\n.dshSettingsDocumentEditor_error button,\n.dshSettingsDocumentEditor_confirmation button {\n min-height: 32px;\n padding: 4px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 16px;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n.dshSettingsDocumentEditor_footer button:hover:not(:disabled),\n.dshSettingsDocumentEditor_errorLine truncated
document.head.appendChild(tag);
}
const React = require('react')
const { useCallback, useEffect, useRef, useState } = React
const { createPortal } = require('react-dom')
const API = '/_dsh_platform/plugin-api/v1/settings-document'
const h = React.createElement
const inject = ['slots', 'locale', 'connection', 'remote']
function browserCookie(name) {
for (const part of document.cookie.split(';')) {
const separator = part.indexOf('=')
if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim()
}
return ''
}
async function request(method, body) {
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method)
const response = await fetch(API, {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const value = await response.json()
if (!response.ok) {
const error = new Error(value.error ?? `HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
return value
}
class DocumentController {
constructor() {
this.listeners = new Set()
this.state = {
open: false,
loading: false,
saving: false,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
}
}
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publish(patch) {
this.state = { ...this.state, ...patch }
for (const listener of this.listeners) listener(this.state)
}
async open() {
if (this.state.open) return
this.publish({
open: true,
loading: true,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
})
await this.reload()
}
async reload() {
this.publish({ loading: true, error: null, conflict: false, confirmClose: false })
try {
const value = await request('GET')
this.publish({
loading: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ loading: false, error: error.message })
}
}
edit(content) {
this.publish({ content, error: null, conflict: false })
}
requestClose() {
if (this.state.saving) return
if (this.state.content !== this.state.savedContent) this.publish({ confirmClose: true })
else this.close()
}
close() {
this.publish({ open: false, confirmClose: false, error: null, conflict: false })
}
async save() {
if (this.state.saving || this.state.loading || this.state.revision === null
|| this.state.content === this.state.savedContent) return
this.publish({ saving: true, error: null, conflict: false })
try {
const value = await request('PUT', { content: this.state.content, revision: this.state.revision })
this.publish({
saving: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ saving: false, error: error.message, conflict: error.status === 409 })
}
}
}
function Editor({ controller, t }) {
const [state, setState] = useState(controller.state)
const textarea = useRef(null)
const lineNumbers = useRef(null)
useEffect(() => controller.subscribe(setState), [controller])
useEffect(() => {
if (state.open && !state.loading && !state.confirmClose) textarea.current?.focus()
}, [state.open, state.loading, state.confirmClose])
const keyDown = useCallback(event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void controller.save()
} else if (event.key === 'Escape') {
event.preventDefault()
controller.requestClose()
}
}, [controller])
useEffect(() => {
if (!state.open) return undefined
document.addEventListener('keydown', keyDown)
return () => document.removeEventListener('keydown', keyDown)
}, [keyDown, state.open])
if (!state.open) return null
const dirty = state.content !== state.savedContent
return createPortal(h('div', { className: css.overlay, role: 'presentation' },
h('div', { className: css.mask, 'aria-hidden': 'true', onClick: () => controller.requestClose() }),
h('section', { className: css.dialog, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-title' },
h('header', { className: css.header },
h('div', null,
h('h2', { id: 'dsh-settings-document-title' }, t('title')),
h('p', null, t('filename'))),
h('button', { className: css.close, type: 'button', 'aria-label': t('close'), onClick: () => controller.requestClose() }, '×')),
h('div', { className: css.body },
state.loading
? h('div', { className: css.loading, role: 'status' }, h('span', null), t('loading'))
: h('div', { className: css.editorFrame },
h('pre', { ref: lineNumbers, className: css.lineNumbers, 'aria-hidden': 'true' },
Array.from({ length: state.content.split('\n').length }, (_, index) => String(index + 1)).join('\n')),
h('textarea', {
ref: textarea,
className: css.editor,
value: state.content,
wrap: 'off',
spellCheck: false,
'aria-label': t('editorLabel'),
onChange: event => controller.edit(event.target.value),
onScroll: event => { lineNumbers.current.scrollTop = event.currentTarget.scrollTop },
})),
state.error === null ? null : h('div', { className: css.error, role: 'alert' },
h('span', null, state.conflict ? t('conflict') : t('loadError')),
state.conflict ? h('button', { type: 'button', onClick: () => { void controller.reload() } }, t('reload')) : null)),
h('footer', { className: css.footer },
h('span', { className: dirty ? css.dirty : css.saved, role: 'status' }, dirty ? t('unsaved') : t('saved')),
h('div', { className: css.actions },
h('button', { type: 'button', disabled: dirty || state.loading || state.saving, onClick: () => { void controller.reload() } }, t('reload')),
h('button', { type: 'button', onClick: () => controller.requestClose() }, t('cancel')),
h('button', { className: css.primary, type: 'button', disabled: !dirty || state.loading || state.saving, onClick: () => { void controller.save() } }, state.saving ? t('saving') : t('save')))),
state.confirmClose ? h('div', { className: css.confirmation, role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-discard-title' },
h('div', null,
h('strong', { id: 'dsh-settings-document-discard-title' }, t('discardTitle')),
h('p', null, t('discardBody')),
h('div', { className: css.confirmActions },
h('button', { type: 'button', onClick: () => controller.publish({ confirmClose: false }) }, t('continueEditing')),
h('button', { className: css.danger, type: 'button', onClick: () => controller.close() }, t('discard'))))) : null)), document.body)
}
function interceptDocumentOpen(target, method, controller, result) {
const descriptor = Object.getOwnPropertyDescriptor(target, method)
const intercepted = async () => {
void controller.open()
return result
}
Object.defineProperty(target, method, {
configurable: true,
enumerable: descriptor?.enumerable ?? true,
value: intercepted,
writable: true,
})
return () => {
if (target[method] !== intercepted) return
if (descriptor === undefined) Reflect.deleteProperty(target, method)
else Object.defineProperty(target, method, descriptor)
}
}
function apply(ctx) {
const controller = new DocumentController()
ctx.effect(() => ctx.locale.register('settings.dshDocumentEditor', {
zh: {
title: '编辑配置文件', filename: 'settings.yaml', close: '关闭', loading: '正在读取配置文件', editorLabel: '配置文件内容',
loadError: '无法读取或保存配置文件。', conflict: '配置文件已在其他位置发生变化,请重新加载后再编辑。', reload: '重新加载',
unsaved: '有未保存的修改', saved: '所有修改均已保存', cancel: '关闭', save: '保存', saving: '正在保存',
discardTitle: '放弃未保存的修改?', discardBody: '关闭后,本次未保存的修改将会丢失。', continueEditing: '继续编辑', discard: '放弃修改',
},
en: {
title: 'Edit configuration file', filename: 'settings.yaml', close: 'Close', loading: 'Loading configuration file', editorLabel: 'Configuration file content',
loadError: 'The configuration file could not be loaded or saved.', conflict: 'The configuration file changed elsewhere. Reload it before editing again.', reload: 'Reload',
unsaved: 'Unsaved changes', saved: 'All changes saved', cancel: 'Close', save: 'Save', saving: 'Saving',
discardTitle: 'Discard unsaved changes?', discardBody: 'Your unsaved changes will be lost when this editor closes.', continueEditing: 'Continue editing', discard: 'Discard changes',
},
}), 'dsh-settings-document-editor: locale')
const connection = ctx.get('connection')
if (typeof connection.api?.settings?.openDocument === 'function') {
ctx.effect(() => interceptDocumentOpen(
connection.api.settings,
'openDocument',
controller,
{ result: { ok: true, value: { opened: true } } },
), 'dsh-settings-document-editor: legacy open action')
} else {
ctx.inject(['remote.settings'], child => {
child.effect(() => interceptDocumentOpen(
child.remote.settings,
'openSettingsDocument',
controller,
{ ok: true, value: { opened: true } },
), 'dsh-settings-document-editor: open action')
})
}
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-document-editor',
order: 80,
locale: 'settings.dshDocumentEditor',
}, props => h(Editor, { ...props, controller })))
}
exports.inject = inject;
exports.apply = apply;
return module.exports; } });
@@ -0,0 +1,256 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import css from './style.module.css'
const { createPortal } = require('react-dom')
const API = '/_dsh_platform/plugin-api/v1/settings-document'
const h = React.createElement
export const inject = ['slots', 'locale', 'connection', 'remote']
function browserCookie(name) {
for (const part of document.cookie.split(';')) {
const separator = part.indexOf('=')
if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim()
}
return ''
}
async function request(method, body) {
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method)
const response = await fetch(API, {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const value = await response.json()
if (!response.ok) {
const error = new Error(value.error ?? `HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
return value
}
class DocumentController {
constructor() {
this.listeners = new Set()
this.state = {
open: false,
loading: false,
saving: false,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
}
}
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publish(patch) {
this.state = { ...this.state, ...patch }
for (const listener of this.listeners) listener(this.state)
}
async open() {
if (this.state.open) return
this.publish({
open: true,
loading: true,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
})
await this.reload()
}
async reload() {
this.publish({ loading: true, error: null, conflict: false, confirmClose: false })
try {
const value = await request('GET')
this.publish({
loading: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ loading: false, error: error.message })
}
}
edit(content) {
this.publish({ content, error: null, conflict: false })
}
requestClose() {
if (this.state.saving) return
if (this.state.content !== this.state.savedContent) this.publish({ confirmClose: true })
else this.close()
}
close() {
this.publish({ open: false, confirmClose: false, error: null, conflict: false })
}
async save() {
if (this.state.saving || this.state.loading || this.state.revision === null
|| this.state.content === this.state.savedContent) return
this.publish({ saving: true, error: null, conflict: false })
try {
const value = await request('PUT', { content: this.state.content, revision: this.state.revision })
this.publish({
saving: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ saving: false, error: error.message, conflict: error.status === 409 })
}
}
}
function Editor({ controller, t }) {
const [state, setState] = useState(controller.state)
const textarea = useRef(null)
const lineNumbers = useRef(null)
useEffect(() => controller.subscribe(setState), [controller])
useEffect(() => {
if (state.open && !state.loading && !state.confirmClose) textarea.current?.focus()
}, [state.open, state.loading, state.confirmClose])
const keyDown = useCallback(event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void controller.save()
} else if (event.key === 'Escape') {
event.preventDefault()
controller.requestClose()
}
}, [controller])
useEffect(() => {
if (!state.open) return undefined
document.addEventListener('keydown', keyDown)
return () => document.removeEventListener('keydown', keyDown)
}, [keyDown, state.open])
if (!state.open) return null
const dirty = state.content !== state.savedContent
return createPortal(h('div', { className: css.overlay, role: 'presentation' },
h('div', { className: css.mask, 'aria-hidden': 'true', onClick: () => controller.requestClose() }),
h('section', { className: css.dialog, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-title' },
h('header', { className: css.header },
h('div', null,
h('h2', { id: 'dsh-settings-document-title' }, t('title')),
h('p', null, t('filename'))),
h('button', { className: css.close, type: 'button', 'aria-label': t('close'), onClick: () => controller.requestClose() }, '×')),
h('div', { className: css.body },
state.loading
? h('div', { className: css.loading, role: 'status' }, h('span', null), t('loading'))
: h('div', { className: css.editorFrame },
h('pre', { ref: lineNumbers, className: css.lineNumbers, 'aria-hidden': 'true' },
Array.from({ length: state.content.split('\n').length }, (_, index) => String(index + 1)).join('\n')),
h('textarea', {
ref: textarea,
className: css.editor,
value: state.content,
wrap: 'off',
spellCheck: false,
'aria-label': t('editorLabel'),
onChange: event => controller.edit(event.target.value),
onScroll: event => { lineNumbers.current.scrollTop = event.currentTarget.scrollTop },
})),
state.error === null ? null : h('div', { className: css.error, role: 'alert' },
h('span', null, state.conflict ? t('conflict') : t('loadError')),
state.conflict ? h('button', { type: 'button', onClick: () => { void controller.reload() } }, t('reload')) : null)),
h('footer', { className: css.footer },
h('span', { className: dirty ? css.dirty : css.saved, role: 'status' }, dirty ? t('unsaved') : t('saved')),
h('div', { className: css.actions },
h('button', { type: 'button', disabled: dirty || state.loading || state.saving, onClick: () => { void controller.reload() } }, t('reload')),
h('button', { type: 'button', onClick: () => controller.requestClose() }, t('cancel')),
h('button', { className: css.primary, type: 'button', disabled: !dirty || state.loading || state.saving, onClick: () => { void controller.save() } }, state.saving ? t('saving') : t('save')))),
state.confirmClose ? h('div', { className: css.confirmation, role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-discard-title' },
h('div', null,
h('strong', { id: 'dsh-settings-document-discard-title' }, t('discardTitle')),
h('p', null, t('discardBody')),
h('div', { className: css.confirmActions },
h('button', { type: 'button', onClick: () => controller.publish({ confirmClose: false }) }, t('continueEditing')),
h('button', { className: css.danger, type: 'button', onClick: () => controller.close() }, t('discard'))))) : null)), document.body)
}
function interceptDocumentOpen(target, method, controller, result) {
const descriptor = Object.getOwnPropertyDescriptor(target, method)
const intercepted = async () => {
void controller.open()
return result
}
Object.defineProperty(target, method, {
configurable: true,
enumerable: descriptor?.enumerable ?? true,
value: intercepted,
writable: true,
})
return () => {
if (target[method] !== intercepted) return
if (descriptor === undefined) Reflect.deleteProperty(target, method)
else Object.defineProperty(target, method, descriptor)
}
}
export function apply(ctx) {
const controller = new DocumentController()
ctx.effect(() => ctx.locale.register('settings.dshDocumentEditor', {
zh: {
title: '编辑配置文件', filename: 'settings.yaml', close: '关闭', loading: '正在读取配置文件', editorLabel: '配置文件内容',
loadError: '无法读取或保存配置文件。', conflict: '配置文件已在其他位置发生变化,请重新加载后再编辑。', reload: '重新加载',
unsaved: '有未保存的修改', saved: '所有修改均已保存', cancel: '关闭', save: '保存', saving: '正在保存',
discardTitle: '放弃未保存的修改?', discardBody: '关闭后,本次未保存的修改将会丢失。', continueEditing: '继续编辑', discard: '放弃修改',
},
en: {
title: 'Edit configuration file', filename: 'settings.yaml', close: 'Close', loading: 'Loading configuration file', editorLabel: 'Configuration file content',
loadError: 'The configuration file could not be loaded or saved.', conflict: 'The configuration file changed elsewhere. Reload it before editing again.', reload: 'Reload',
unsaved: 'Unsaved changes', saved: 'All changes saved', cancel: 'Close', save: 'Save', saving: 'Saving',
discardTitle: 'Discard unsaved changes?', discardBody: 'Your unsaved changes will be lost when this editor closes.', continueEditing: 'Continue editing', discard: 'Discard changes',
},
}), 'dsh-settings-document-editor: locale')
const connection = ctx.get('connection')
if (typeof connection.api?.settings?.openDocument === 'function') {
ctx.effect(() => interceptDocumentOpen(
connection.api.settings,
'openDocument',
controller,
{ result: { ok: true, value: { opened: true } } },
), 'dsh-settings-document-editor: legacy open action')
} else {
ctx.inject(['remote.settings'], child => {
child.effect(() => interceptDocumentOpen(
child.remote.settings,
'openSettingsDocument',
controller,
{ ok: true, value: { opened: true } },
), 'dsh-settings-document-editor: open action')
})
}
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-document-editor',
order: 80,
locale: 'settings.dshDocumentEditor',
}, props => h(Editor, { ...props, controller })))
}
@@ -0,0 +1,3 @@
export const name = '@dsh-docker/settings-document-editor'
export function apply() {}
@@ -0,0 +1,215 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px;
color: var(--dsw-alias-label-primary);
}
.overlay,
.overlay *,
.overlay *::before,
.overlay *::after {
corner-shape: round;
}
.mask {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 48%);
}
.dialog {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
box-sizing: border-box;
width: min(820px, 100%);
height: min(680px, calc(100dvh - 48px));
min-height: 420px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
box-shadow: 0 18px 50px rgb(0 0 0 / 32%);
}
.header,
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
}
.header { border-bottom: 1px solid var(--dsw-alias-border-l2); }
.footer { border-top: 1px solid var(--dsw-alias-border-l2); }
.header h2 { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; }
.header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
.close {
flex: none;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--dsw-alias-label-secondary);
background: transparent;
font: 18px/32px ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.close:hover { background: var(--dsw-alias-interactive-bg-hover); }
.body { position: relative; min-height: 0; padding: 14px 18px; }
.editorFrame {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 300px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
}
.editorFrame:focus-within { border-color: var(--dsw-alias-border-l2); }
.lineNumbers {
box-sizing: border-box;
min-width: 44px;
height: 100%;
margin: 0;
overflow: hidden;
padding: 12px 10px;
border-right: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, var(--dsw-alias-bg-module-platform));
font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: right;
user-select: none;
}
.editor {
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
resize: none;
padding: 12px 14px;
border: 0;
outline: none;
color: var(--dsw-alias-label-primary);
background: transparent;
font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0;
tab-size: 2;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
height: 100%;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
}
.loading span {
width: 14px;
height: 14px;
border: 2px solid var(--dsw-alias-border-l2);
border-top-color: var(--dsw-alias-label-primary);
border-radius: 50%;
animation: document-editor-spin .8s linear infinite;
}
@keyframes document-editor-spin { to { transform: rotate(360deg); } }
.error {
position: absolute;
right: 28px;
bottom: 24px;
left: 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 11px;
border-left: 3px solid var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
background: var(--dsw-alias-bg-module-platform);
box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
font-size: 12px;
line-height: 18px;
}
.actions,
.confirmActions { display: flex; justify-content: flex-end; gap: 8px; }
.footer button,
.error button,
.confirmation button {
min-height: 32px;
padding: 4px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
color: var(--dsw-alias-label-primary);
background: transparent;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.footer button:hover:not(:disabled),
.error button:hover,
.confirmation button:hover { background: var(--dsw-alias-interactive-bg-hover); }
.footer button:disabled { cursor: not-allowed; opacity: .45; }
.footer .primary {
border-color: transparent;
color: var(--dsw-alias-label-primary-foreground);
background: var(--dsw-alias-button-primary-fill);
}
.footer .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
.saved, .dirty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
.dirty { color: var(--dsw-alias-state-warn-label); }
.confirmation {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
padding: 24px;
background: rgb(0 0 0 / 48%);
}
.confirmation > div {
box-sizing: border-box;
width: min(420px, 100%);
padding: 18px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
}
.confirmation strong { font-size: 15px; line-height: 22px; }
.confirmation p { margin: 6px 0 18px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }
.confirmation .danger { color: var(--dsw-alias-state-error-primary); }
@media (max-width: 640px) {
.overlay { padding: 0; }
.dialog { width: 100%; height: 100dvh; min-height: 0; border: 0; border-radius: 0; }
.header, .footer { padding: 12px 14px; }
.body { padding: 12px 14px; }
.editorFrame { min-height: 0; }
.footer { align-items: flex-start; flex-direction: column; }
.actions { width: 100%; }
.actions button { flex: 1; }
.error { right: 20px; bottom: 20px; left: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.loading span { animation: none; }
}
@@ -0,0 +1,37 @@
{
"name": "@dsh-docker/settings-document-editor",
"version": "1.0.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.bundle.js",
"./package.json": "./package.json"
},
"dshDocker": {
"description": {
"zh": "在浏览器中查看和编辑 DSH 配置文件。",
"en": "View and edit the DSH settings document in the browser."
}
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-api-remotes": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
@@ -0,0 +1,10 @@
[
{
"insert": [
{
"id": "dsh-docker.settings-navigation.plugin",
"name": "@dsh-docker/settings-navigation"
}
]
}
]
@@ -0,0 +1,267 @@
window.__ModuleLoader__.load({ id: "@dsh-docker/settings-navigation", factory: (require) => {
var module = { exports: {} }; var exports = module.exports;
const css = Object.freeze({"backButton":"dshSettingsNavigation_backButton","backMount":"dshSettingsNavigation_backMount","content":"dshSettingsNavigation_content","header":"dshSettingsNavigation_header","nav":"dshSettingsNavigation_nav","navList":"dshSettingsNavigation_navList","navTitle":"dshSettingsNavigation_navTitle","options":"dshSettingsNavigation_options","panel":"dshSettingsNavigation_panel"});
const styleId = "@dsh-docker/settings-navigation/style.module.css";
if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) {
const tag = document.createElement('style');
tag.dataset.plugin = "@dsh-docker/settings-navigation";
tag.dataset.pluginCss = styleId;
tag.textContent = ".dshSettingsNavigation_panel {\n --dsh-settings-navigation-header-height: 54px;\n}\n\n.dshSettingsNavigation_nav,\n.dshSettingsNavigation_navList,\n.dshSettingsNavigation_content,\n.dshSettingsNavigation_options {\n min-height: 0;\n}\n\n.dshSettingsNavigation_navList {\n flex: 1;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-gutter: stable;\n padding-bottom: 18px;\n}\n\n.dshSettingsNavigation_backMount {\n display: none;\n flex: none;\n}\n\n.dshSettingsNavigation_backButton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n cursor: pointer;\n}\n\n.dshSettingsNavigation_backButton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dshSettingsNavigation_backButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] {\n display: grid;\n grid-template: var(--dsh-settings-navigation-header-height) minmax(0, 1fr) / minmax(0, 1fr);\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_nav,\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_content {\n grid-area: 1 / 1 / 3 / 2;\n width: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_nav {\n padding-right: 16px;\n padding-left: 16px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_navTitle {\n padding-left: 0;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_navList {\n padding-right: 0;\n padding-bottom: 22px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_content {\n pointer-events: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_header {\n position: relative;\n z-index: 1;\n pointer-events: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_header > * {\n pointer-events: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='directory'] .dshSettingsNavigation_options {\n display: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_nav {\n display: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_content {\n pointer-events: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_backMount {\n display: inline-flex;\n align-items: center;\n margin-right: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_options {\n display: block;\n}\n";
document.head.appendChild(tag);
}
const React = require('react')
const { useCallback, useEffect, useRef, useState } = React
const { createPortal } = require('react-dom')
const { IconChevronLeftOutline14 } = require('@deepseek-ai/dsh-client-ui-primitives')
const h = React.createElement
const COMPACT_WIDTH = 640
const NS = 'settings.dshSettingsNavigation'
const inject = ['slots', 'locale']
function directChildren(element) {
return Array.from(element?.children ?? [])
}
function matchSettingsDialog(dialog) {
if (dialog?.getAttribute?.('role') !== 'dialog'
|| dialog.getAttribute('aria-modal') !== 'true') return null
const labelledBy = dialog.getAttribute('aria-labelledby')
const title = labelledBy ? dialog.ownerDocument?.getElementById(labelledBy) : null
if (!title || !dialog.contains(title)) return null
const [nav, content, ...extra] = directChildren(dialog)
if (extra.length !== 0 || nav?.tagName !== 'NAV' || content === undefined
|| !nav.contains(title)) return null
const [navTitle, navList, ...navExtra] = directChildren(nav)
const [header, options, ...contentExtra] = directChildren(content)
if (navExtra.length !== 0 || contentExtra.length !== 0 || navTitle !== title
|| navList === undefined || header === undefined || options === undefined) return null
const buttons = Array.from(navList.querySelectorAll?.('button') ?? [])
const current = buttons.find(button => button.getAttribute('aria-current') === 'true')
if (buttons.length === 0 || current === undefined || header.querySelector?.('button') === null) return null
return { dialog, nav, navTitle, navList, content, header, options, buttons, current }
}
function findSettingsDialog(root) {
for (const dialog of root.querySelectorAll?.('[role="dialog"][aria-modal="true"][aria-labelledby]') ?? []) {
const match = matchSettingsDialog(dialog)
if (match !== null) return match
}
return null
}
class SettingsNavigationController {
constructor({ root, ResizeObserverImpl, MutationObserverImpl, requestFrame, classes, onChange }) {
this.root = root
this.ResizeObserverImpl = ResizeObserverImpl
this.MutationObserverImpl = MutationObserverImpl
this.requestFrame = requestFrame
this.classes = classes
this.onChange = onChange
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.backMount = null
this.resizeObserver = null
this.mutationObserver = null
this.scanQueued = false
}
start() {
this.scan()
this.mutationObserver = new this.MutationObserverImpl(() => this.queueScan())
this.mutationObserver.observe(this.root.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['aria-current', 'aria-labelledby'],
})
}
stop() {
this.mutationObserver?.disconnect()
this.mutationObserver = null
this.detach()
}
queueScan() {
if (this.scanQueued) return
this.scanQueued = true
queueMicrotask(() => {
this.scanQueued = false
this.scan()
})
}
scan() {
const next = findSettingsDialog(this.root)
const sameStructure = next !== null && this.match !== null
&& ['dialog', 'nav', 'navTitle', 'navList', 'content', 'header', 'options']
.every(key => next[key] === this.match[key])
if (sameStructure) {
this.match.current = next.current
this.match.buttons = next.buttons
return
}
this.detach()
if (next !== null) this.attach(next)
}
attach(match) {
this.match = match
const roles = [
['dialog', match.dialog, this.classes.panel],
['nav', match.nav, this.classes.nav],
['nav-title', match.navTitle, this.classes.navTitle],
['nav-list', match.navList, this.classes.navList],
['content', match.content, this.classes.content],
['header', match.header, this.classes.header],
['options', match.options, this.classes.options],
]
for (const [role, node, className] of roles) {
node.dataset.dshSettingsNavigationRole = role
node.classList.add(className)
}
this.backMount = this.root.createElement('span')
this.backMount.className = this.classes.backMount
this.backMount.dataset.dshSettingsNavigationRole = 'back'
match.header.prepend(this.backMount)
match.navList.addEventListener('click', this.onNavClick)
this.view = match.buttons.indexOf(match.current) > 0 ? 'detail' : 'directory'
this.resizeObserver = new this.ResizeObserverImpl(entries => {
const width = entries.at(-1)?.contentRect?.width ?? match.dialog.getBoundingClientRect().width
this.setCompact(width < COMPACT_WIDTH)
})
this.resizeObserver.observe(match.dialog)
this.setCompact(match.dialog.getBoundingClientRect().width < COMPACT_WIDTH)
}
detach() {
if (this.match === null) return
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.match.navList.removeEventListener('click', this.onNavClick)
const roles = [
[this.match.dialog, this.classes.panel],
[this.match.nav, this.classes.nav],
[this.match.navTitle, this.classes.navTitle],
[this.match.navList, this.classes.navList],
[this.match.content, this.classes.content],
[this.match.header, this.classes.header],
[this.match.options, this.classes.options],
]
for (const [node, className] of roles) {
delete node.dataset.dshSettingsNavigationRole
node.classList.remove(className)
}
delete this.match.dialog.dataset.dshSettingsNavigationMode
delete this.match.dialog.dataset.dshSettingsNavigationView
this.backMount?.remove()
this.backMount = null
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.onChange()
}
setCompact(compact) {
if (this.match === null) return
if (this.modeInitialized && compact && !this.compact) this.view = 'detail'
this.compact = compact
this.modeInitialized = true
this.match.dialog.dataset.dshSettingsNavigationMode = compact ? 'compact' : 'wide'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
}
onNavClick = event => {
if (!this.compact || this.match === null) return
const button = event.target.closest?.('button')
if (!button || !this.match.navList.contains(button)) return
this.match.current = button
this.directoryScrollTop = this.match.navList.scrollTop
this.view = 'detail'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
this.requestFrame(() => this.backMount?.querySelector('button')?.focus())
}
back() {
if (!this.compact || this.match === null) return
this.view = 'directory'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
const current = this.match.current
const navList = this.match.navList
const scrollTop = this.directoryScrollTop
this.requestFrame(() => {
current?.focus({ preventScroll: true })
this.requestFrame(() => { navList.scrollTop = scrollTop })
})
}
snapshot() {
return {
active: this.match !== null,
compact: this.compact,
view: this.view,
backMount: this.backMount,
}
}
}
function SettingsNavigationOverlay({ t }) {
const [, render] = useState(0)
const forceRender = useCallback(() => render(value => value + 1), [])
const controllerRef = useRef(null)
useEffect(() => {
const controller = new SettingsNavigationController({
root: document,
ResizeObserverImpl: ResizeObserver,
MutationObserverImpl: MutationObserver,
requestFrame: callback => requestAnimationFrame(callback),
classes: css,
onChange: forceRender,
})
controllerRef.current = controller
controller.start()
return () => {
controller.stop()
controllerRef.current = null
}
}, [forceRender])
const state = controllerRef.current?.snapshot()
if (!state?.active || !state.compact || state.view !== 'detail' || state.backMount === null) return null
return createPortal(h('button', {
type: 'button',
className: css.backButton,
'aria-label': t('back'),
onClick: () => controllerRef.current?.back(),
}, h(IconChevronLeftOutline14, { size: 18 })), state.backMount)
}
function apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, {
zh: { back: '返回设置目录' },
en: { back: 'Back to Settings directory' },
}), 'dsh-settings-navigation: locale')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-navigation',
order: 70,
locale: NS,
}, SettingsNavigationOverlay))
}
exports.inject = inject;
exports.apply = apply;
return module.exports; } });
@@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import css from './style.module.css'
const { createPortal } = require('react-dom')
const { IconChevronLeftOutline14 } = require('@deepseek-ai/dsh-client-ui-primitives')
const h = React.createElement
const COMPACT_WIDTH = 640
const NS = 'settings.dshSettingsNavigation'
export const inject = ['slots', 'locale']
function directChildren(element) {
return Array.from(element?.children ?? [])
}
function matchSettingsDialog(dialog) {
if (dialog?.getAttribute?.('role') !== 'dialog'
|| dialog.getAttribute('aria-modal') !== 'true') return null
const labelledBy = dialog.getAttribute('aria-labelledby')
const title = labelledBy ? dialog.ownerDocument?.getElementById(labelledBy) : null
if (!title || !dialog.contains(title)) return null
const [nav, content, ...extra] = directChildren(dialog)
if (extra.length !== 0 || nav?.tagName !== 'NAV' || content === undefined
|| !nav.contains(title)) return null
const [navTitle, navList, ...navExtra] = directChildren(nav)
const [header, options, ...contentExtra] = directChildren(content)
if (navExtra.length !== 0 || contentExtra.length !== 0 || navTitle !== title
|| navList === undefined || header === undefined || options === undefined) return null
const buttons = Array.from(navList.querySelectorAll?.('button') ?? [])
const current = buttons.find(button => button.getAttribute('aria-current') === 'true')
if (buttons.length === 0 || current === undefined || header.querySelector?.('button') === null) return null
return { dialog, nav, navTitle, navList, content, header, options, buttons, current }
}
function findSettingsDialog(root) {
for (const dialog of root.querySelectorAll?.('[role="dialog"][aria-modal="true"][aria-labelledby]') ?? []) {
const match = matchSettingsDialog(dialog)
if (match !== null) return match
}
return null
}
class SettingsNavigationController {
constructor({ root, ResizeObserverImpl, MutationObserverImpl, requestFrame, classes, onChange }) {
this.root = root
this.ResizeObserverImpl = ResizeObserverImpl
this.MutationObserverImpl = MutationObserverImpl
this.requestFrame = requestFrame
this.classes = classes
this.onChange = onChange
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.backMount = null
this.resizeObserver = null
this.mutationObserver = null
this.scanQueued = false
}
start() {
this.scan()
this.mutationObserver = new this.MutationObserverImpl(() => this.queueScan())
this.mutationObserver.observe(this.root.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['aria-current', 'aria-labelledby'],
})
}
stop() {
this.mutationObserver?.disconnect()
this.mutationObserver = null
this.detach()
}
queueScan() {
if (this.scanQueued) return
this.scanQueued = true
queueMicrotask(() => {
this.scanQueued = false
this.scan()
})
}
scan() {
const next = findSettingsDialog(this.root)
const sameStructure = next !== null && this.match !== null
&& ['dialog', 'nav', 'navTitle', 'navList', 'content', 'header', 'options']
.every(key => next[key] === this.match[key])
if (sameStructure) {
this.match.current = next.current
this.match.buttons = next.buttons
return
}
this.detach()
if (next !== null) this.attach(next)
}
attach(match) {
this.match = match
const roles = [
['dialog', match.dialog, this.classes.panel],
['nav', match.nav, this.classes.nav],
['nav-title', match.navTitle, this.classes.navTitle],
['nav-list', match.navList, this.classes.navList],
['content', match.content, this.classes.content],
['header', match.header, this.classes.header],
['options', match.options, this.classes.options],
]
for (const [role, node, className] of roles) {
node.dataset.dshSettingsNavigationRole = role
node.classList.add(className)
}
this.backMount = this.root.createElement('span')
this.backMount.className = this.classes.backMount
this.backMount.dataset.dshSettingsNavigationRole = 'back'
match.header.prepend(this.backMount)
match.navList.addEventListener('click', this.onNavClick)
this.view = match.buttons.indexOf(match.current) > 0 ? 'detail' : 'directory'
this.resizeObserver = new this.ResizeObserverImpl(entries => {
const width = entries.at(-1)?.contentRect?.width ?? match.dialog.getBoundingClientRect().width
this.setCompact(width < COMPACT_WIDTH)
})
this.resizeObserver.observe(match.dialog)
this.setCompact(match.dialog.getBoundingClientRect().width < COMPACT_WIDTH)
}
detach() {
if (this.match === null) return
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.match.navList.removeEventListener('click', this.onNavClick)
const roles = [
[this.match.dialog, this.classes.panel],
[this.match.nav, this.classes.nav],
[this.match.navTitle, this.classes.navTitle],
[this.match.navList, this.classes.navList],
[this.match.content, this.classes.content],
[this.match.header, this.classes.header],
[this.match.options, this.classes.options],
]
for (const [node, className] of roles) {
delete node.dataset.dshSettingsNavigationRole
node.classList.remove(className)
}
delete this.match.dialog.dataset.dshSettingsNavigationMode
delete this.match.dialog.dataset.dshSettingsNavigationView
this.backMount?.remove()
this.backMount = null
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.onChange()
}
setCompact(compact) {
if (this.match === null) return
if (this.modeInitialized && compact && !this.compact) this.view = 'detail'
this.compact = compact
this.modeInitialized = true
this.match.dialog.dataset.dshSettingsNavigationMode = compact ? 'compact' : 'wide'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
}
onNavClick = event => {
if (!this.compact || this.match === null) return
const button = event.target.closest?.('button')
if (!button || !this.match.navList.contains(button)) return
this.match.current = button
this.directoryScrollTop = this.match.navList.scrollTop
this.view = 'detail'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
this.requestFrame(() => this.backMount?.querySelector('button')?.focus())
}
back() {
if (!this.compact || this.match === null) return
this.view = 'directory'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
const current = this.match.current
const navList = this.match.navList
const scrollTop = this.directoryScrollTop
this.requestFrame(() => {
current?.focus({ preventScroll: true })
this.requestFrame(() => { navList.scrollTop = scrollTop })
})
}
snapshot() {
return {
active: this.match !== null,
compact: this.compact,
view: this.view,
backMount: this.backMount,
}
}
}
function SettingsNavigationOverlay({ t }) {
const [, render] = useState(0)
const forceRender = useCallback(() => render(value => value + 1), [])
const controllerRef = useRef(null)
useEffect(() => {
const controller = new SettingsNavigationController({
root: document,
ResizeObserverImpl: ResizeObserver,
MutationObserverImpl: MutationObserver,
requestFrame: callback => requestAnimationFrame(callback),
classes: css,
onChange: forceRender,
})
controllerRef.current = controller
controller.start()
return () => {
controller.stop()
controllerRef.current = null
}
}, [forceRender])
const state = controllerRef.current?.snapshot()
if (!state?.active || !state.compact || state.view !== 'detail' || state.backMount === null) return null
return createPortal(h('button', {
type: 'button',
className: css.backButton,
'aria-label': t('back'),
onClick: () => controllerRef.current?.back(),
}, h(IconChevronLeftOutline14, { size: 18 })), state.backMount)
}
export function apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, {
zh: { back: '返回设置目录' },
en: { back: 'Back to Settings directory' },
}), 'dsh-settings-navigation: locale')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-navigation',
order: 70,
locale: NS,
}, SettingsNavigationOverlay))
}
@@ -0,0 +1,3 @@
export const name = '@dsh-docker/settings-navigation'
export function apply() {}
@@ -0,0 +1,108 @@
.panel {
--dsh-settings-navigation-header-height: 54px;
}
.nav,
.navList,
.content,
.options {
min-height: 0;
}
.navList {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-bottom: 18px;
}
.backMount {
display: none;
flex: none;
}
.backButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--dsw-alias-label-primary);
background: transparent;
cursor: pointer;
}
.backButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.backButton:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 2px;
}
.panel[data-dsh-settings-navigation-mode='compact'] {
display: grid;
grid-template: var(--dsh-settings-navigation-header-height) minmax(0, 1fr) / minmax(0, 1fr);
}
.panel[data-dsh-settings-navigation-mode='compact'] .nav,
.panel[data-dsh-settings-navigation-mode='compact'] .content {
grid-area: 1 / 1 / 3 / 2;
width: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'] .nav {
padding-right: 16px;
padding-left: 16px;
}
.panel[data-dsh-settings-navigation-mode='compact'] .navTitle {
padding-left: 0;
}
.panel[data-dsh-settings-navigation-mode='compact'] .navList {
padding-right: 0;
padding-bottom: 22px;
}
.panel[data-dsh-settings-navigation-mode='compact'] .content {
pointer-events: none;
}
.panel[data-dsh-settings-navigation-mode='compact'] .header {
position: relative;
z-index: 1;
pointer-events: none;
}
.panel[data-dsh-settings-navigation-mode='compact'] .header > * {
pointer-events: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='directory'] .options {
display: none;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .nav {
display: none;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .content {
pointer-events: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .backMount {
display: inline-flex;
align-items: center;
margin-right: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .options {
display: block;
}
@@ -0,0 +1,32 @@
{
"name": "@dsh-docker/settings-navigation",
"version": "1.0.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.bundle.js",
"./package.json": "./package.json"
},
"dshDocker": {
"description": {
"zh": "优化 DSH 设置目录滚动和窄屏分级导航。",
"en": "Improve DSH Settings directory scrolling and compact navigation."
}
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
@@ -0,0 +1,13 @@
{
"schema": 1,
"skills": [
{
"id": "dsh-docker-operations",
"source": "dsh-docker-operations",
"description": {
"zh": "在 DSH Docker 环境中使用受支持的命令完成开发、插件、维护、更新、恢复与诊断。",
"en": "Use supported commands for development, plugins, maintenance, updates, recovery, and diagnostics in the DSH Docker Environment."
}
}
]
}
@@ -0,0 +1,31 @@
---
name: dsh-docker-operations
description: Use for operating inside the official DSH Docker Environment, including files, development tools, plugins, skills, lifecycle, logs, updates, recovery, networking, permissions, and container-specific diagnostics.
user-invocable: true
disable-model-invocation: false
---
# DSH Docker Operations
Use the platform's supported interfaces before inspecting implementation details. Match the user's language in every response; these instructions being English does not change the response language.
## Operating rules
1. Preserve the user's requested scope and authorization. Read-only diagnosis does not authorize updates, restarts, plugin mutations, or destructive file operations.
2. Start with `dsh`, `dsh-platform`, their help output, and the DSH Management Console. Do not rediscover public behavior by reading Stage-0, Bootstrap, sockets, process environments, or runtime source.
3. In routine work, never override `HOME`, `USER`, `LOGNAME`, XDG variables, npm configuration, pnpm configuration, or package-manager cache paths. The container already provides the correct `node` environment.
4. Do not read credentials from process environments, files, command histories, or service internals. Never print secrets.
5. Do not mutate platform state, Store assets, runtime views, keyrings, receipts, or Unix sockets. Use only the documented recovery commands for exceptional operations.
6. Inspect `/opt/dsh-platform` or `/run/dsh-platform` internals only when the user explicitly asks to develop or debug the platform implementation itself. Ordinary DSH, plugin, update, and container troubleshooting does not meet that condition.
## Load the relevant guide
- Environment identity, paths, persistence, Root, and sudo: [references/environment-and-permissions.md](references/environment-and-permissions.md)
- Workspaces, files, development tools, and package managers: [references/files-and-development.md](references/files-and-development.md)
- DSH CLI, profiles, user plugins, System Plugins, and skills: [references/dsh-and-extensions.md](references/dsh-and-extensions.md)
- Status, health, logs, restart, and Management Console: [references/lifecycle-and-logs.md](references/lifecycle-and-logs.md)
- Stable/experimental updates, rollback, reset, and recovery: [references/updates-and-recovery.md](references/updates-and-recovery.md)
- Gateway, ports, reverse proxies, authentication, and remote access: [references/networking-and-auth.md](references/networking-and-auth.md)
- Ordered troubleshooting and protected implementation boundaries: [references/diagnostics-and-boundaries.md](references/diagnostics-and-boundaries.md)
Read only the references needed for the current task. When a referenced command differs from the installed version, prefer that command's current `--help` output and report the difference.
@@ -0,0 +1,44 @@
# Diagnostics and protected boundaries
## Ordered diagnosis
Use the least invasive source of truth that can answer the question:
1. Run the relevant public command's `--help` when syntax is uncertain.
2. Inspect `dsh-platform status` and the current operation/task state.
3. Read focused structured logs through `dsh-platform logs` or a management UI.
4. Check DSH/Profile state with the public `dsh` CLI.
5. Use the standalone Management Console for recovery actions, Root file inspection, or a terminal explicitly requested by the user.
6. Inspect platform implementation files only when the user explicitly asks to debug or develop dsh-docker itself.
For a restart failure immediately after plugin removal, first look for `cannot resolve profile bundle` in the structured DSH Runtime logs. A current managed startup repairs legacy orphaned Bundle references automatically; verify the subsequent lifecycle terminal state before escalating. Do not bypass that repair by editing the Web Profile manifest or lockfile directly.
For a browser plugin-load failure around a restart, correlate the current `dshLifecycle` task with `browser.plugin-load.failed` and the matching recovery event. Apply this diagnosis equally to official DSH plugins, bundled System Plugins, and User Plugins. `browser.plugin-load.recovery.completed` identifies a recovered transition race. Recovery attempt three opens Gateway's terminal plugin-failure page and records `browser.plugin-load.recovery.failed`; a failure without recovery eligibility is also persistent. Diagnose either case from the named plugin and DSH Runtime logs. Do not create a reload loop, match visible page text, or assume every plugin-load failure is transient.
For an update check failure, distinguish the remote request from the cached target: a later failed check must retain the last verified Stable or upstream result and report `remote check failed`; only a first failed check has no verified result to show. Check the public update status and the structured `update.check.failed` event before changing proxy settings. When an external proxy is configured, use the Proxy tab's asynchronous test and its task result; never read `/data/platform/state/proxy`, inspect the Outbound Proxy environment, or call its control socket directly. A proxy test failure must not replace the active configuration.
Stop once the cause is established. Do not enumerate Seed trees, runtime packages, sockets, process environments, or supervisor source as a substitute for trying the public operation.
## Protected trust boundary
Stage-0 is the only writer and verifier for Recovery-signed keyrings, Release signatures, monotonic ledgers, trusted objects, and receipts. Bootstrap, Updater, DSH, plugins, and agents consume its verified results.
Never:
- edit or replace the Recovery Root public key;
- add a Release key or modify keyring state;
- call the Stage-0 Trust socket directly;
- import an object by supplying an arbitrary expected hash;
- forge, copy, reactivate, or rewrite a receipt;
- lower keyring generation or target sequence;
- mutate current/previous deployment or Bootstrap slots by hand.
Use `dsh-platform trust status` for read-only trust diagnostics. Trust reset is a last-resort Root TTY operation, not a Web/API repair method.
## Failure reporting
Distinguish among DSH startup, update/restart, automatic rollback, startup failure, prolonged unavailability, Gateway authorization, and an unclassified proxy error. Capture the public status plus complete relevant structured error. Do not label a warning or ordinary stderr line as a failure without its structured level/result, and do not report an operation as successful until its terminal state and postcondition are verified.
Repeated authentication, 2FA, Management-origin probe, and upstream failures are rate-limited by failure class. Treat `suppressedCount` on the next retained event as part of the incident total; do not infer the attempt count from the number of visible warning rows alone.
Browser-session activity is reported at most once per session every five minutes. Use `access.session.expired` and `access.session.invalidated` reason codes to distinguish absolute or idle expiry, credential or Management-entry changes, and source DSH-session loss. Root authentication recovery is duplicated to the audit stream with only the operation, result, and session-revocation counts; credential values, reset keys, one-time codes, Cookies, and CSRF tokens must never appear.
@@ -0,0 +1,47 @@
# DSH, profiles, plugins, and skills
## DSH CLI
Use the `dsh` shim. It dynamically executes the current verified Runtime and remains correct across updates and rollbacks. Consult `dsh --help` and the relevant subcommand help instead of calling files below `/run/dsh-platform/views/runtime`.
The Web Profile is stored below `$DSH_HOME/profiles/web`. Treat its package manifest, lockfile, and bundle declarations as a coordinated profile owned by the DSH CLI; do not hand-edit them for ordinary plugin operations.
## User plugins
Install a published user plugin with:
```sh
dsh plugin --profile web add <package-or-supported-spec>
```
Remove it with:
```sh
dsh plugin --profile web remove <package-name>
```
Run these commands with the inherited `node` environment. The public container `dsh` shim also drops a Root caller to the managed `node` identity, but that protection is not a reason to invoke profile-internal pnpm commands as Root. Do not redirect pnpm configuration or caches. Prefer the package declared by the plugin project; clone/build source only when the user explicitly requests source installation or no published package exists.
The platform pins the Web Profile store below `$DSH_HOME/.pnpm-store` and transactionally migrates a legacy Profile whose pnpm metadata points at an older workspace or home. If a package operation reports `ERR_PNPM_UNEXPECTED_STORE`, restart DSH once through the public lifecycle command and inspect the DSH Runtime log for the migration result. Do not delete `$DSH_HOME`, edit `node_modules/.modules.yaml`, or create/chmod the old store path to suppress the check.
Some plugin changes require DSH to restart. Use:
```sh
dsh-platform restart
```
When this command is run by an Agent inside a DSH session, submit the asynchronous task and report its task ID. Do not add `--wait`: stopping the current DSH process interrupts the tool transport before a durable result can be returned. Use `--wait` only from an external operator terminal, the standalone Management Console terminal, or external automation.
The standalone Management Console can disable or uninstall a faulty Web Profile Bundle while DSH is down. It deliberately manages only DSH bundle plugins, not arbitrary dependencies or hand-written Cordis patch entries. After Apply reports that the Profile change was saved, treat that mutation as committed even if DSH remains unavailable or its restart fails; inspect lifecycle logs and correct another faulty plugin or retry startup instead of repeating the saved action.
If a plugin tool reports a successful removal but DSH then fails with `cannot resolve profile bundle`, inspect public DSH Runtime logs and restart once through `dsh-platform restart`. An authorized Web startup atomically removes only an orphaned Bundle which is no longer a declared dependency and cannot be resolved from the Profile or DSH installation. Do not hand-edit `package.json`, the lockfile, or `dsh.profile.bundles`; if the managed repair does not restore readiness, use User Plugin recovery in the standalone Management Console.
## System Plugins
System Plugins are signed resources supplied by the current DSH Docker Environment. Manage them through **System Plugins** in Platform Management or the standalone Management Console. The standalone console remains usable while DSH is down. Its selections are drafts until Apply; once Apply saves a selection, the change remains committed for the next DSH start even if DSH is already unavailable or the activation restart fails. Diagnose that lifecycle failure separately instead of treating the saved plugin action as unapplied. Do not install, delete, or impersonate `@dsh-docker/*` packages through the normal user-plugin CLI.
## Skills
User and project skills belong to the official DSH roots such as `$DSH_HOME/skills`, `.dsh/skills`, or `.agents/skills`. System Skills are signed platform resources and are managed through **System Skills** in the two management interfaces.
Installing, uninstalling, enabling, or disabling a System Skill hot-refreshes the DSH skill catalog. It does not require a DSH restart, and the standalone Management Console can still apply the selection while DSH is unavailable. User Skill changes in that console are likewise independent of DSH lifecycle state. A project or user skill with the same name may override the bundled System Skill according to DSH's native precedence; platform management does not alter or remove that override.
@@ -0,0 +1,35 @@
# Environment and permissions
## Process identity
DSH and its Agent normally run as `node` with:
- `HOME=/home/node`
- `USER=node`
- `LOGNAME=node`
- XDG configuration, data, and cache below `/home/node`
- `DSH_HOME`, normally `/data/dsh`
- the configured default workspace, normally `/workspace`
Do not replace these values to work around a package-manager error. First inspect ownership of the target profile or workspace and use the existing environment. A reference to `/root/.config`, `/root/.cache`, or `/root/.local` from a normal DSH/plugin operation is a defect or inherited override, not a directory to create or chmod around.
The standalone Management Console terminal and file manager run through the platform's Root maintenance broker. Its terminal intentionally uses UID 0 and `HOME=/root`. Do not generalize that identity to DSH or Agent commands.
## Persistent and ephemeral paths
- `$DSH_HOME` (`/data/dsh` by default): user configuration, profiles, sessions, user plugins, and DSH data.
- `$DSH_PLATFORM_DATA` (`/data/platform` by default): platform state, trusted/managed assets, cache, snapshots, and logs.
- `$DSH_DEFAULT_WORKSPACE` (`/workspace` by default): initial user workspace; it is not a sandbox.
- `/run/dsh-platform`: ephemeral runtime views and local sockets rebuilt at container start.
- `/opt/dsh-platform`: immutable image implementation and Seed.
- `/home/node`: normal user home and package-manager/XDG state.
Use the configured environment variables instead of hard-coding their default paths when writing scripts intended to survive custom deployments.
## Root and sudo
When `DSH_SUDO_ENABLED=true`, the DSH/Agent process can use passwordless `sudo` for commands that genuinely need operating-system authority. Use it narrowly for system package installation or protected host-mounted files. Keep ordinary workspace, DSH profile, npm, and pnpm operations as `node` so new files remain usable by DSH.
When sudo is disabled, do not assume a failing command may be rerun as Root. Explain the required permission or use the authenticated Management Console when the user explicitly asks for container maintenance.
Never combine broad Root operations with guessed paths, unresolved variables, recursive deletion, the Docker socket, or unrelated host mounts.
@@ -0,0 +1,31 @@
# Files and development tools
## Work in the selected workspace
Start from `$DSH_DEFAULT_WORKSPACE` when the user has not named a directory. Confirm the current directory before installing dependencies, initializing a repository, or running destructive commands. Preserve existing files and unrelated changes.
Use ordinary shell and editor tools for accessible workspace and DSH user data. Use the standalone Management Console file manager for browser-based upload/download, archive operations, ownership/mode changes, or Root-only repair. It is available independently of the DSH Runtime.
Do not edit these managed paths during routine work:
- `$DSH_PLATFORM_DATA/state`
- `$DSH_PLATFORM_DATA/store`
- `/run/dsh-platform/views`
- `/opt/dsh-platform`
Cache directories may be cleared only when the user requests it and the exact cache target is known. Never delete `$DSH_HOME` to solve a platform or image upgrade problem.
## Package managers and toolchains
Run language package managers from the intended project or DSH profile directory under the inherited environment. Do not set XDG/npm/pnpm variables to the workspace, `/root`, or a temporary directory merely to make one command pass.
For Node development:
- use the installed Node/npm/pnpm commands and their project lockfile;
- do not use global installs when a project-local dependency or `npx`/`pnpm exec` is appropriate;
- keep user caches and configuration under `/home/node` through the inherited environment;
- use `sudo` only for explicit operating-system or global-tool installation requested by the user.
For other development environments, use the same rule: normal user tooling belongs to `/home/node` and the workspace, while system packages may require narrowly scoped sudo. Do not make a tool inherit Root's home just because its installer was invoked through sudo.
Before downloading or executing a third-party installer, verify the requested source and explain any material external side effect. Prefer the project's documented package manager over cloning and building source unless the user explicitly wants a source installation.
@@ -0,0 +1,59 @@
# Lifecycle, health, and logs
## Public status and lifecycle
Start diagnosis with:
```sh
dsh-platform status
```
Restart only DSH, while keeping Gateway and Management available, with:
```sh
dsh-platform restart
```
For an Agent running inside the current DSH session, `restart` must remain asynchronous: report the returned task ID and let the browser enter the lifecycle holding page. Never run `restart --wait` or `stop --wait` from that session because DSH shutdown interrupts the tool transport. `--wait` is reserved for an external operator terminal, the standalone Management Console terminal, and external automation.
The standalone Management Console and CLI also expose explicit lifecycle operations:
```sh
dsh-platform start
dsh-platform stop
dsh-platform restart
```
An explicit stop lasts only for the current container lifetime. An unexpected DSH exit is recovered at most three times; check `dshLifecycle` and `recoveryMode` rather than repeatedly sending restart commands.
The container admits only the Bootstrap-supervised Web Profile. Do not launch a replacement with `dsh web`: when DSH is stopped that command delegates to the public start operation, and otherwise it reports the managed state without creating a second instance. An unregistered first `SIGTERM` is converted into a public asynchronous restart, but this compatibility behavior is not a reason to signal the process directly.
Do not send signals to DSH, kill its PID, invoke Bootstrap sockets, or restart the whole container unless the user specifically requested that broader action. During a registered lifecycle operation, browser navigation enters the localized holding page and returns to the original same-origin path only after the DSH HTTP upstream responds and the platform lifecycle has finished starting, restarting, recovering, or switching. This prevents a listening but not yet plugin-ready DSH instance from being shown early. API and WebSocket requests receive `503` until that combined readiness succeeds.
Do not diagnose these classified holding responses as Gateway outages. An explicit start, stop, restart, switch, or recovery does not emit `gateway.upstream.failed` or `gateway.upstream.recovered`; those records are reserved for an unclassified upstream outage and its recovery.
Plugin bundle requests are held while a registered lifecycle transition makes DSH temporarily unavailable. The same guard covers official DSH plugins, bundled System Plugins, and User Plugins. A failed bundle request or structured dynamic-import error may enter the holding page twice for the same running Deployment. A third structured failure opens Gateway's terminal plugin-failure page and links to the standalone Management Console; it never starts an unbounded reload loop. Detection uses the plugin request or module error URL, not visible page text. Do not advise repeated manual refreshes before checking lifecycle state, recovery-attempt fields, and browser recovery events in platform logs.
Docker health probes Gateway control-plane health at `127.0.0.1:3080/_dsh_gateway/health`. It is independent of DSH readiness, so an intentional DSH stop does not make Docker report the container as unhealthy while the standalone Management Console remains available.
## Logs
Inside the container, query structured platform logs with:
```sh
dsh-platform logs --limit 500
dsh-platform logs --source dsh-runtime --limit 500
dsh-platform logs --since <ISO-8601-time> --limit 500
```
The standalone Management Console and Platform Management plugin provide filtering, expansion of complete structured entries, refresh, live following, and JSONL export. From the Docker host, `docker logs <container>` includes platform and DSH output.
When reporting a failure, include the operation/task ID, source, timestamp, complete error, and the smallest relevant surrounding entries. Do not include credentials, terminal contents, or unrelated user data.
For a browser plugin-load failure, correlate `browser.plugin-load.failed` with `browser.plugin-load.recovery.started`, `browser.plugin-load.recovery.completed`, or `browser.plugin-load.recovery.failed`. A completed recovery confirms a transient lifecycle race. Attempt three and the terminal Gateway page identify a persistent failure; a failure without recovery eligibility also requires investigation of the named plugin bundle and DSH Runtime logs.
Logs rotate automatically according to the platform size and retention settings. Clearing the UI display is a local display cutoff and does not delete the persisted platform log files.
## Management surfaces
The standalone DSH Management Console is served at `/_dsh_platform/console/` and stays available when DSH is starting, restarting, recovering, or down. Platform Management inside DSH exposes the routine subset appropriate while DSH is available. Prefer the standalone console for recovery, Root terminal/file access, user-plugin recovery, and System Skill reinstallation.
@@ -0,0 +1,63 @@
# Networking and authentication
## Ports and proxying
- Port `3080` is the Gateway and the only normal published service.
- Port `3079` is the loopback-only DSH upstream inside the container; do not publish or access it remotely.
- The standalone Management Console is below `/_dsh_platform/console/` on the Gateway in compatibility mode. Separate-origin mode uses the container's dedicated Management entry without a DSH upstream.
For remote access, configure `DSH_TRUSTED_HOSTS` for accepted external Host values, initialize the local administrator in the browser, and terminate HTTPS at a trusted reverse proxy or equivalent network boundary. Never expose port `3080` or the standalone Management Console before authentication: its terminal and file manager operate with Root authority. The reverse proxy must preserve the original `Host` and normal WebSocket upgrade headers. Optional strong isolation publishes the dedicated Management entry without a DSH upstream; for a public separate origin configure its exact public URL, and for a local separate origin configure the host port mapped to the container entry (default `3081`, but the host port is user-selectable). Do not assume either separate entry is exposed unless the operator configured it.
Do not weaken Host/Origin checks to work around a reverse-proxy error. Diagnose the forwarded Host, scheme, Origin, WebSocket upgrade, and authentication headers first.
## Managed outbound proxy
Configure an existing HTTP or SOCKS5 proxy through the **Proxy** tab in the DSH Management Console or Platform Management. Do not edit `/data/platform/state/proxy`, read the Outbound Proxy process environment, or call `/run/dsh-platform/outbound-proxy.sock` directly. Proxy credentials are write-only and must never be recovered from files or logs.
The UI can route updates, platform components, DSH core/plugins, Agent network operations, the Management container terminal, and model Providers independently. A remote Provider identified through DSH `llm/stream` uses one switch: off selects direct access and on selects its dedicated proxy route. A future Provider that cannot carry stable identity is marked with a read-only **Follow DSH** label; its switch selects direct access when off and the shared DSH policy when on. Local Providers remain direct. Commands launched outside the container are not classified by the platform.
For Agent-issued `curl`, Git, npm, pnpm, and similar commands, preserve the injected `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, and optional `ALL_PROXY` environment. Do not replace these with the external proxy URL or expose its credentials. Tool support for `ALL_PROXY` varies; inspect the tool's current documentation or behavior before relying on it.
Use `.example.com`, not `*.example.com`, for a domain suffix. Platform-required loopback rules cannot be removed. If the proxy runs on the Docker host, obtain the current bridge gateway with `ip route`; container `127.0.0.1` is not the host. The host proxy must listen on the bridge address, with appropriate firewall restrictions.
Use the built-in asynchronous proxy test before saving uncertain settings. A failed test does not replace the active configuration. When diagnosing update checks, distinguish a later remote failure that retains the last verified result from a first check that has never succeeded.
## Authentication
Access Manager owns the single local administrator account. A fresh installation stays in an initialization page until the browser sets a username and main password. DSH login creates a DSH Session; the standalone Management Console consumes a one-time handoff and creates a separate Management Session that remains valid only while its source DSH Session is active. Direct Management access always completes the main DSH login first, including on an isolated Management Origin. An optional Management console password is a second layer. DSH logout or session expiry also invalidates linked Management sessions. During a classified DSH outage, top-level navigation may show a generic Gateway recovery page before login so the Management entry remains reachable; it does not grant a session or expose failure details. Do not look for Gateway Basic Auth, `DSH_PROXY_PASSWORD`, `DSH_PLATFORM_PASSWORD`, or a temporary login key: those legacy login paths are removed.
Browser authentication forms only transport values; the Access Manager performs authentication and credential decisions. Authentication Settings changes the username without a current password, while main-password and Management-console-password changes require the current main password. Resetting or disabling the Management console password does not require the old Management console password and revokes existing Management Sessions.
After five consecutive failures from one browser source, Access Manager imposes a 30-second retry wait and doubles later waits up to 15 minutes. Each source has fixed limits of 12 failures per hour and 24 per 24 hours; the whole instance has wider flood limits of 20 per minute, 60 per hour, and 120 per 24 hours. Treat `AUTHENTICATION_RETRY_REQUIRED` and `AUTHENTICATION_RATE_LIMITED` as backend decisions; do not retry around them or implement credential admission in the browser.
These `access` recovery, credential, session, and retry-clear commands require the user's explicit authorization in addition to Root access. Run them only from an interactive container TTY:
```sh
dsh-platform access status
dsh-platform access reset
dsh-platform access set-username
dsh-platform access reset-password
dsh-platform access reset-management-password
dsh-platform access disable-management-password
dsh-platform access clear-retry
dsh-platform access clear-retry --global-only
dsh-platform access clear-retry --main-password
dsh-platform access clear-retry --management-password
dsh-platform access clear-retry --two-factor
dsh-platform access disable-two-factor
dsh-platform access clear-sessions
dsh-platform access clear-sessions --management-only
dsh-platform access generate-key
```
`access status` is read-only and reports the current account, initialization state, and retry state. The combined `access reset` command atomically applies selected username and main-password changes. When a Management console password is enabled, `y` resets it, the default `n` preserves it, and `d` disables it. When two-factor authentication is enabled, a separate `y/[n]` prompt can disable it. It submits nothing until all prompts complete. Yes/no recovery prompts accept only `y` or `n`, with the default shown in brackets. Other interactive recovery commands include `access set-username`, `access reset-password`, `access reset-management-password`, and `access disable-management-password`. `access clear-retry` uses the same Root/TTY and `y/[n]` boundary; it clears every browser-source wait, every source rolling window, and the instance-wide failure windows without changing credentials or sessions. Add `--global-only` to clear only the instance-wide windows while leaving browser waits and source windows active. The optional `--main-password`, `--management-password`, or `--two-factor` selector limits clearing to one credential class; `--two-factor` clears only its daily limit and leaves the fixed 10-second code backoff active. Fresh empty volumes register without a key. Persisted pre-account deployments and damaged authentication state use `access generate-key`, which issues a single-use authentication reset key valid for ten minutes from the same Root TTY. Never pass passwords as arguments or pipe them to the CLI.
`--global-only` limits `access clear-retry` to the instance-wide flood windows; without it, both source/browser waits and instance windows are cleared. `access clear-sessions` clears all sessions, or only Management sessions with `--management-only`.
`access set-username` changes only the administrator name; `access reset-password` changes the main password and revokes all sessions; `access reset-management-password` sets or changes the Management password and revokes Management sessions; `access disable-management-password` and `access disable-two-factor` disable those optional layers without logging out existing sessions. `access generate-key` issues the one-time key used by migration or damaged-account recovery.
Do not read configured passwords from environment files, `/proc`, process listings, or service memory. Ask the user to authenticate through the normal browser flow.
The Platform Management and Settings Document Editor System Plugins use the restricted DSH-side platform API and must not require a separate Management Console login. Access is inherited from the authenticated DSH Session and limited by an exact Gateway route allowlist; do not search for a Management session token or invent a separate plugin credential. The restricted API cannot access files, the Root terminal, User Plugin recovery, or authentication settings.
When the Gateway is behind another proxy, use the public Gateway URL for checks. A direct request with an untrusted Host can correctly return `403`; that is not evidence that DSH itself failed.
@@ -0,0 +1,44 @@
# Updates, rollback, and recovery
## Read-only check and activation
Checking never downloads or activates an update:
```sh
dsh-platform check
```
Inspect `dsh-platform status` after the check. Activate the latest supported target only after the user confirms:
```sh
dsh-platform update
```
For an Agent running inside the current DSH session, activation must remain asynchronous: report the returned task ID and do not use `update --wait`, because switching DSH can interrupt the tool transport. `--wait` is reserved for an external operator terminal, the standalone Management Console terminal, and external automation.
After submitting an update, use the public `dsh-platform status` result and task-correlated platform logs to report its current phase or terminal outcome. The Management interfaces reconstruct their stage history from this persistent state and JSONL log stream, so a browser refresh does not imply that the operation restarted. Prefer the phase, measurable byte/item/file/service counters, and structured failure details over an estimated elapsed time. Do not inspect updater journals, Deployment slots, or internal sockets to infer progress.
Use `dsh-platform channel` to inspect the current channel and `dsh-platform channel stable|experimental` only when the user requests a channel change.
Stable targets update the signed supported DSH and Environment. Before activating a newer upstream DSH, Experimental mode first converges the complete Deployment to the signed Stable target sequence, even if the displayed DSH and Environment versions already match. Do not download npm/GitHub artifacts yourself, submit an expected hash, or call the Trust API; Stage-0 alone authorizes and imports release objects.
If npm exposes a selected DSH version before every dependency version is available, Runtime materialization automatically retries transient `E404`, `ETARGET`, and `ENOVERSIONS` failures in fresh install roots with bounded backoff. Do not repeatedly restart the update while these retries are active. If the bounded attempts still end in a version Hold, report its sanitized reason, wait until the Registry dependency graph is complete, and use `dsh-platform retry` only with the user's authorization.
## Rollback and return to stable
Use `dsh-platform rollback` only when a complete previous state is available and the user explicitly confirms the target and service interruption. Explain any reported snapshot or data boundary before proceeding, then verify status and health after rollback. Experimental return-to-stable can restore an older data snapshot and discard newer DSH data; use the interactive `dsh-platform return-stable` flow and present the data-loss boundary before proceeding.
The in-DSH Platform Management interface intentionally does not expose rollback or return-to-stable. Direct users who need these recovery actions to the standalone DSH Management Console, where return-to-stable is shown only for an active Experimental Deployment with a verified Stable recovery point.
Runtime reset rebuilds DSH from verified Pristine content and the current complete patch/plugin set. Use the standalone Management Console for that operation; it does not erase `$DSH_HOME`, sessions, user plugins, or the workspace.
## Exceptional recovery
```sh
dsh-platform recover
dsh-platform trust reset
```
Both commands are Root-only interactive console operations. `recover` restores the image-baseline Deployment after current and previous Deployments are unusable; `trust reset` clears the accepted trust state.
Never manually delete `$DSH_PLATFORM_DATA/state`, rewrite slots, copy objects into Store, edit keyrings, forge receipts, or reinterpret an image reference. If a legacy or corrupt platform volume prevents startup, preserve `$DSH_HOME` and follow the exact platform error/recovery guidance.