Merge pull request #343 from pikasTech/fix/342-runner-trans-windows-route
Pipelines as Code CI / agentrun-nc01-v02-ci-da4b3c0d62f8f1f9d1f60ea83d00bff9902db684 Success
Pipelines as Code CI / agentrun-nc01-v02-ci-da4b3c0d62f8f1f9d1f60ea83d00bff9902db684 Success
fix: Runner 复用 UniDesk trans Windows 路由
This commit is contained in:
@@ -93,7 +93,16 @@ imageRef:
|
|||||||
- 通过 `toolCredentials` 获取 GitHub Issue/PR 写入 token:`agentrun-v01-tool-github-pr` -> env `GH_TOKEN`。
|
- 通过 `toolCredentials` 获取 GitHub Issue/PR 写入 token:`agentrun-v01-tool-github-pr` -> env `GH_TOKEN`。
|
||||||
- 通过 `toolCredentials` 获取 UniDesk SSH 透传 token:`agentrun-v01-tool-unidesk-ssh` -> env `UNIDESK_SSH_CLIENT_TOKEN`。
|
- 通过 `toolCredentials` 获取 UniDesk SSH 透传 token:`agentrun-v01-tool-unidesk-ssh` -> env `UNIDESK_SSH_CLIENT_TOKEN`。
|
||||||
- 通过 `toolCredentials` 获取 GitHub SSH 凭据:`agentrun-v01-tool-github-ssh` -> volume `/home/agentrun/.ssh`。
|
- 通过 `toolCredentials` 获取 GitHub SSH 凭据:`agentrun-v01-tool-github-ssh` -> volume `/home/agentrun/.ssh`。
|
||||||
- 通过声明 GitHub repo URL 的多仓库 gitbundle,装配 UniDesk repo 中的 `unidesk-*` skills、AgentRun repo 中的 `tools/trans`、`tools/tran`、`tools/apply_patch`,以及 `agent_skills` repo 中的 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`;runner 物化阶段自动把 GitHub URL 改写到 G14 git mirror,Artificer 规格本身不声明 mirror 开关或 mirror base URL。
|
- 通过声明 GitHub repo URL 的多仓库 gitbundle 完成工具与 skill 装配:
|
||||||
|
- UniDesk repo 提供 primary source workspace 与 `unidesk-*` skills;
|
||||||
|
- AgentRun repo 提供 `tools/trans`、`tools/tran`、`tools/apply_patch` 等 runner tool shim;
|
||||||
|
- `agent_skills` repo 提供 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`;
|
||||||
|
- `tools/tran` 只委派 primary UniDesk workspace 的 `scripts/tran`,Windows、host 与 k3s route 解析和 frontend transport 均由 UniDesk 共享实现负责;
|
||||||
|
- runner 物化阶段自动把 GitHub URL 改写到 G14 git mirror,Artificer 规格本身不声明 mirror 开关或 mirror base URL。
|
||||||
|
- primary source workspace 与受控远端 tool target 是不同边界:
|
||||||
|
- Agent 修改的源码仍以 `ResourceBundleRef` 物化出的 primary workspace 为唯一来源;
|
||||||
|
- 用户明确授权的 `trans <route> ...` 指向远端 tool target,不属于旁路 source workspace;
|
||||||
|
- route 授权、SecretRef、脱敏与短连接继续由 UniDesk frontend 和 `unidesk-ssh` tool credential 约束。
|
||||||
- 通过 `requiredSkills[]` 在 runner 启动 backend 前校验 `dad-dev` 与 UniDesk 关键 skills 已被 materialize;缺失时必须 `required-skill-unavailable`,不得使用模型默认 skill 猜测。
|
- 通过 `requiredSkills[]` 在 runner 启动 backend 前校验 `dad-dev` 与 UniDesk 关键 skills 已被 materialize;缺失时必须 `required-skill-unavailable`,不得使用模型默认 skill 猜测。
|
||||||
- Artificer 默认是 session-capable agent。`render`/`queue submit --aipod Artificer` 在调用方没有显式传入 `sessionRef` 时,必须生成稳定的默认 `SessionRef`,`sessionId` 使用 `sess_artificer_<24 hex>` 形态,`conversationId` 默认等于 `sessionId`,并把该引用写入 Queue task/run 装配输入;后续 follow-up、补测、reviewer feedback 和 manager recovery 优先继续同一个 session,而不是每次新开 session。
|
- Artificer 默认是 session-capable agent。`render`/`queue submit --aipod Artificer` 在调用方没有显式传入 `sessionRef` 时,必须生成稳定的默认 `SessionRef`,`sessionId` 使用 `sess_artificer_<24 hex>` 形态,`conversationId` 默认等于 `sessionId`,并把该引用写入 Queue task/run 装配输入;后续 follow-up、补测、reviewer feedback 和 manager recovery 优先继续同一个 session,而不是每次新开 session。
|
||||||
|
|
||||||
|
|||||||
@@ -26,23 +26,17 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal(containerfile.includes("npm --version"), true, "runner image build smoke must verify npm");
|
assert.equal(containerfile.includes("npm --version"), true, "runner image build smoke must verify npm");
|
||||||
assert.equal(containerfile.includes("gh --version"), true, "runner image build smoke must verify GitHub CLI");
|
assert.equal(containerfile.includes("gh --version"), true, "runner image build smoke must verify GitHub CLI");
|
||||||
|
|
||||||
assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools");
|
assert.equal(tran.startsWith("#!/bin/sh\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools");
|
||||||
assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools");
|
assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools");
|
||||||
assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools");
|
assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools");
|
||||||
assert.equal(agentrunGit.startsWith("#!/usr/bin/env bun\n"), true, "tools/agentrun-git must be a shebang executable discovered by gitbundle tools");
|
assert.equal(agentrunGit.startsWith("#!/usr/bin/env bun\n"), true, "tools/agentrun-git must be a shebang executable discovered by gitbundle tools");
|
||||||
assert.equal(tran.includes("UNIDESK_SSH_CLIENT_TOKEN"), true, "tools/tran must require the scoped UniDesk SSH client token");
|
assert.equal(tran.includes("scripts/tran"), true, "tools/tran must delegate to the primary UniDesk workspace implementation");
|
||||||
assert.equal(tran.includes("/ws/ssh"), true, "tools/tran must use the frontend SSH WebSocket path");
|
assert.equal(tran.includes("UNIDESK_TRAN_REPO_ROOT"), true, "tools/tran must bind the shared implementation to the primary workspace");
|
||||||
assert.equal(tran.includes("apply-patch < patch.diff"), true, "tools/tran help must advertise runner-side apply-patch");
|
assert.equal(tran.includes("parseRoute"), false, "tools/tran must not carry a second route parser");
|
||||||
|
assert.equal(tran.includes("does not support Windows routes"), false, "tools/tran must not reject shared Windows routes");
|
||||||
assert.equal(agentrunGit.includes("GIT_TERMINAL_PROMPT"), true, "tools/agentrun-git must disable interactive git prompts");
|
assert.equal(agentrunGit.includes("GIT_TERMINAL_PROMPT"), true, "tools/agentrun-git must disable interactive git prompts");
|
||||||
assert.equal(agentrunGit.includes("AGENTRUN_GIT_DIRECT_HOSTS"), true, "tools/agentrun-git must expose direct host policy");
|
assert.equal(agentrunGit.includes("AGENTRUN_GIT_DIRECT_HOSTS"), true, "tools/agentrun-git must expose direct host policy");
|
||||||
|
|
||||||
const help = await execFileAsync(path.join(context.root, "tools/tran"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
|
||||||
const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; unsupported?: string[]; valuesPrinted?: boolean };
|
|
||||||
assert.equal(parsed.ok, true);
|
|
||||||
assert.equal(parsed.valuesPrinted, false);
|
|
||||||
assert.equal(parsed.supported?.some((line) => line.includes("script")), true);
|
|
||||||
assert.equal(parsed.supported?.some((line) => line.includes("apply-patch")), true);
|
|
||||||
assert.equal(parsed.unsupported?.includes("apply-patch"), false);
|
|
||||||
const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||||
assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true);
|
assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true);
|
||||||
const agentrunGitHelp = await execFileAsync(path.join(context.root, "tools/agentrun-git"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
const agentrunGitHelp = await execFileAsync(path.join(context.root, "tools/agentrun-git"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||||
@@ -74,7 +68,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
|
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
|
||||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
|
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
|
||||||
|
|
||||||
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] };
|
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran delegates to the primary UniDesk workspace", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] };
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createFakeToolBin(dir: string): Promise<string> {
|
async function createFakeToolBin(dir: string): Promise<string> {
|
||||||
|
|||||||
+12
-323
@@ -1,326 +1,15 @@
|
|||||||
#!/usr/bin/env bun
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
const defaultFrontendPort = 18081;
|
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
const sshInputChunkBytes = 32 * 1024;
|
primary_workspace=$(CDPATH= cd -- "$self_dir/../../.." && pwd)
|
||||||
|
shared_tran="$primary_workspace/scripts/tran"
|
||||||
|
|
||||||
function jsonHelp() {
|
if [ ! -x "$shared_tran" ]; then
|
||||||
return {
|
printf '%s\n' '{"ok":false,"failureKind":"tool-unavailable","message":"primary UniDesk workspace does not provide executable scripts/tran","source":"primary-workspace","valuesPrinted":false}' >&2
|
||||||
ok: true,
|
exit 2
|
||||||
tool: "tran",
|
fi
|
||||||
purpose: "AgentRun runner UniDesk SSH passthrough over frontend /ws/ssh",
|
|
||||||
requiredEnv: ["UNIDESK_SSH_CLIENT_TOKEN", "UNIDESK_MAIN_SERVER_IP or UNIDESK_FRONTEND_URL"],
|
|
||||||
supported: [
|
|
||||||
"tran <provider> <command...>",
|
|
||||||
"tran <provider> argv <command...>",
|
|
||||||
"tran <provider> script -- '<shell script>'",
|
|
||||||
"tran <provider>:/absolute/workspace script -- '<shell script>'",
|
|
||||||
"tran <provider>:/absolute/workspace apply-patch < patch.diff",
|
|
||||||
"tran <provider>:k3s kubectl <args...>",
|
|
||||||
"tran <provider>:k3s script -- '<shell script>'",
|
|
||||||
"tran <provider>:k3s:<namespace>:<workload>[:container] argv <command...>",
|
|
||||||
"tran <provider>:k3s:<namespace>:<workload>[:container] script -- '<shell script>'",
|
|
||||||
"tran <provider>:k3s:<namespace>:<workload>[:container] apply-patch < patch.diff",
|
|
||||||
],
|
|
||||||
unsupported: ["upload", "download", "Windows win/ps/cmd routes"],
|
|
||||||
valuesPrinted: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(value, stream = process.stdout) {
|
UNIDESK_TRAN_REPO_ROOT="$primary_workspace"
|
||||||
stream.write(`${JSON.stringify(value)}\n`);
|
export UNIDESK_TRAN_REPO_ROOT
|
||||||
}
|
exec "$shared_tran" "$@"
|
||||||
|
|
||||||
function fail(failureKind, message, details = {}, exitCode = 2) {
|
|
||||||
writeJson({ ok: false, failureKind, message, ...details, valuesPrinted: false }, process.stderr);
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellQuote(value) {
|
|
||||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellArgv(args) {
|
|
||||||
if (args.length === 0) return "";
|
|
||||||
return args.map(shellQuote).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseUrlFromEnv(env) {
|
|
||||||
const explicit = (env.UNIDESK_FRONTEND_URL || env.UNIDESK_MAIN_SERVER_URL || "").trim();
|
|
||||||
if (explicit) return explicit.replace(/\/+$/g, "");
|
|
||||||
const host = (env.UNIDESK_MAIN_SERVER_IP || env.UNIDESK_MAIN_SERVER_HOST || env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST || "").trim();
|
|
||||||
if (!host) return null;
|
|
||||||
if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/g, "");
|
|
||||||
if (/:[0-9]+$/u.test(host)) return `http://${host}`;
|
|
||||||
const port = Number(env.UNIDESK_FRONTEND_PORT || env.UNIDESK_MAIN_SERVER_PORT || defaultFrontendPort);
|
|
||||||
return `http://${host}:${Number.isInteger(port) && port > 0 ? port : defaultFrontendPort}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function websocketUrl(baseUrl) {
|
|
||||||
const url = new URL("/ws/ssh", baseUrl);
|
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRoute(raw) {
|
|
||||||
const parts = String(raw).split(":");
|
|
||||||
const providerId = parts.shift() || "";
|
|
||||||
if (!providerId) fail("schema-invalid", "tran route requires a provider id", { route: raw });
|
|
||||||
if (parts.length === 0) return { providerId, plane: "host", workspace: null, namespace: null, resource: null, container: null, raw };
|
|
||||||
if (parts[0] === "win") fail("unsupported-operation", "AgentRun tran does not support Windows routes yet", { route: raw });
|
|
||||||
if (parts[0] === "k3s") {
|
|
||||||
return {
|
|
||||||
providerId,
|
|
||||||
plane: "k3s",
|
|
||||||
workspace: null,
|
|
||||||
namespace: parts[1] || null,
|
|
||||||
resource: parts[2] || null,
|
|
||||||
container: parts[3] || null,
|
|
||||||
raw,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const workspace = parts.join(":");
|
|
||||||
if (!workspace.startsWith("/")) fail("schema-invalid", "host workspace routes must be absolute paths", { route: raw });
|
|
||||||
return { providerId, plane: "host", workspace, namespace: null, resource: null, container: null, raw };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readStdinText() {
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk));
|
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readApplyPatchHelperSource() {
|
|
||||||
const helperPath = new URL("./apply_patch", import.meta.url);
|
|
||||||
try {
|
|
||||||
const text = await Bun.file(helperPath).text();
|
|
||||||
if (!text.startsWith("#!")) fail("infra-failed", "adjacent apply_patch helper is not executable script shaped", { helper: "tools/apply_patch" });
|
|
||||||
return text;
|
|
||||||
} catch (error) {
|
|
||||||
fail("infra-failed", "adjacent apply_patch helper is required for runner-side tran apply-patch", { helper: "tools/apply_patch", error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPatchToolArgs(args) {
|
|
||||||
const supported = new Set(["--allow-loose", "--help", "-h"]);
|
|
||||||
for (const arg of args) {
|
|
||||||
if (!supported.has(arg)) fail("schema-invalid", `unsupported apply-patch option: ${arg}`, { supported: [...supported].sort() });
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64Text(value) {
|
|
||||||
return Buffer.from(value, "utf8").toString("base64").replace(/.{1,76}/g, "$&\n").trimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyPatchRemoteScript(args) {
|
|
||||||
const toolArgs = applyPatchToolArgs(args);
|
|
||||||
const [helperSource, patchText] = await Promise.all([readApplyPatchHelperSource(), readStdinText()]);
|
|
||||||
if (patchText.trim().length === 0) fail("schema-invalid", "apply-patch requires patch text on stdin");
|
|
||||||
const helperMarker = "__AGENTRUN_TRAN_APPLY_PATCH_HELPER__";
|
|
||||||
const patchMarker = "__AGENTRUN_TRAN_APPLY_PATCH_BODY__";
|
|
||||||
const toolArgText = toolArgs.length > 0 ? ` ${shellArgv(toolArgs)}` : "";
|
|
||||||
return [
|
|
||||||
"set -eu",
|
|
||||||
"helper=$(mktemp \"${TMPDIR:-/tmp}/agentrun-apply-patch.XXXXXX\") || exit 1",
|
|
||||||
"patch_file=$(mktemp \"${TMPDIR:-/tmp}/agentrun-apply-patch-body.XXXXXX\") || exit 1",
|
|
||||||
"cleanup() { rm -f \"$helper\" \"$patch_file\"; }",
|
|
||||||
"trap cleanup EXIT HUP INT TERM",
|
|
||||||
"decode_b64() { base64 -d; }",
|
|
||||||
`decode_b64 > \"$helper\" <<'${helperMarker}'`,
|
|
||||||
base64Text(helperSource),
|
|
||||||
helperMarker,
|
|
||||||
`decode_b64 > \"$patch_file\" <<'${patchMarker}'`,
|
|
||||||
base64Text(patchText),
|
|
||||||
patchMarker,
|
|
||||||
"chmod 700 \"$helper\"",
|
|
||||||
`\"$helper\"${toolArgText} < \"$patch_file\"`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scriptCommand(args) {
|
|
||||||
if (args[0] === "--") {
|
|
||||||
const rest = args.slice(1);
|
|
||||||
if (rest.length === 0) return await readStdinText();
|
|
||||||
if (rest.length === 1) return rest[0];
|
|
||||||
return shellArgv(rest);
|
|
||||||
}
|
|
||||||
if (args.length === 0) return await readStdinText();
|
|
||||||
return shellArgv(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hostCommand(route, args) {
|
|
||||||
if (args.length === 0) return { command: null, cwd: route.workspace, tty: true };
|
|
||||||
const op = args[0];
|
|
||||||
if (op === "apply-patch") return { command: await applyPatchRemoteScript(args.slice(1)), cwd: route.workspace, tty: false };
|
|
||||||
if (op === "upload" || op === "download") {
|
|
||||||
fail("unsupported-operation", `AgentRun tran does not support ${op}; use host/source controlled tools outside the runner for that operation`, { operation: op });
|
|
||||||
}
|
|
||||||
if (op === "script" || op === "shell") return { command: await scriptCommand(args.slice(1)), cwd: route.workspace, tty: false };
|
|
||||||
if (op === "argv") return { command: shellArgv(args.slice(1)), cwd: route.workspace, tty: false };
|
|
||||||
return { command: shellArgv(args), cwd: route.workspace, tty: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
function k3sExecPrefix(route) {
|
|
||||||
const base = ["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec"];
|
|
||||||
if (route.namespace) base.push("-n", route.namespace);
|
|
||||||
if (!route.resource) fail("schema-invalid", "k3s workload routes require namespace and resource", { route: route.raw });
|
|
||||||
base.push(route.resource);
|
|
||||||
if (route.container) base.push("-c", route.container);
|
|
||||||
base.push("--");
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function k3sCommand(route, args) {
|
|
||||||
const op = args[0] || "kubectl";
|
|
||||||
if (op === "upload" || op === "download") {
|
|
||||||
fail("unsupported-operation", `AgentRun tran does not support ${op}; use host/source controlled tools outside the runner for that operation`, { operation: op });
|
|
||||||
}
|
|
||||||
if (!route.resource) {
|
|
||||||
if (op === "apply-patch") fail("schema-invalid", "k3s apply-patch requires namespace and workload route", { route: route.raw });
|
|
||||||
if (op === "kubectl") return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", ...args.slice(1)]), cwd: null, tty: false };
|
|
||||||
if (op === "script" || op === "shell") {
|
|
||||||
const script = await scriptCommand(args.slice(1));
|
|
||||||
return { command: `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; ${script}`, cwd: null, tty: false };
|
|
||||||
}
|
|
||||||
if (op === "argv") return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", ...args.slice(1)]), cwd: null, tty: false };
|
|
||||||
return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", ...args]), cwd: null, tty: false };
|
|
||||||
}
|
|
||||||
if (op === "apply-patch") {
|
|
||||||
const script = await applyPatchRemoteScript(args.slice(1));
|
|
||||||
return { command: shellArgv([...k3sExecPrefix(route), "sh", "-lc", script]), cwd: null, tty: false };
|
|
||||||
}
|
|
||||||
if (op === "script" || op === "shell") {
|
|
||||||
const script = await scriptCommand(args.slice(1));
|
|
||||||
return { command: shellArgv([...k3sExecPrefix(route), "sh", "-lc", script]), cwd: null, tty: false };
|
|
||||||
}
|
|
||||||
if (op === "argv") return { command: shellArgv([...k3sExecPrefix(route), ...args.slice(1)]), cwd: null, tty: false };
|
|
||||||
return { command: shellArgv([...k3sExecPrefix(route), ...args]), cwd: null, tty: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildOpenPayload(argv) {
|
|
||||||
if (argv.length === 0) fail("schema-invalid", "tran requires a route", { help: jsonHelp() });
|
|
||||||
const route = parseRoute(argv[0]);
|
|
||||||
const parsed = route.plane === "k3s" ? await k3sCommand(route, argv.slice(1)) : await hostCommand(route, argv.slice(1));
|
|
||||||
return {
|
|
||||||
providerId: route.providerId,
|
|
||||||
command: parsed.command || undefined,
|
|
||||||
cwd: parsed.cwd || undefined,
|
|
||||||
tty: parsed.tty === true,
|
|
||||||
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
|
||||||
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
|
||||||
openTimeoutMs: Math.max(15000, Math.min(Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000), 60000)),
|
|
||||||
runtimeTimeoutMs: Math.max(1000, Math.min(Number(process.env.UNIDESK_SSH_RUNTIME_TIMEOUT_MS || process.env.UNIDESK_TRAN_RUNTIME_TIMEOUT_MS || 60000), 60000)),
|
|
||||||
stdinEotOnEnd: true,
|
|
||||||
route: route.raw,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const argv = process.argv.slice(2);
|
|
||||||
if (argv[0] === "--help" || argv[0] === "help" || argv[0] === "-h") {
|
|
||||||
writeJson(jsonHelp());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = (process.env.UNIDESK_SSH_CLIENT_TOKEN || "").trim();
|
|
||||||
if (!token) fail("secret-unavailable", "UNIDESK_SSH_CLIENT_TOKEN is required for runner-side tran");
|
|
||||||
const baseUrl = baseUrlFromEnv(process.env);
|
|
||||||
if (!baseUrl) fail("schema-invalid", "UNIDESK_MAIN_SERVER_IP, UNIDESK_MAIN_SERVER_HOST, or UNIDESK_FRONTEND_URL is required for runner-side tran");
|
|
||||||
const open = await buildOpenPayload(argv);
|
|
||||||
await runWebSocket(open, websocketUrl(baseUrl), token);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runWebSocket(open, url, token) {
|
|
||||||
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${token}` } });
|
|
||||||
let exitCode = 255;
|
|
||||||
let canSend = false;
|
|
||||||
let sessionReady = false;
|
|
||||||
let settled = false;
|
|
||||||
const pending = [];
|
|
||||||
const pendingInput = [];
|
|
||||||
|
|
||||||
const send = (value) => {
|
|
||||||
const text = JSON.stringify(value);
|
|
||||||
if (!canSend || ws.readyState !== WebSocket.OPEN) pending.push(text);
|
|
||||||
else ws.send(text);
|
|
||||||
};
|
|
||||||
const sendInput = (value) => {
|
|
||||||
const text = JSON.stringify(value);
|
|
||||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) pendingInput.push(text);
|
|
||||||
else ws.send(text);
|
|
||||||
};
|
|
||||||
const flush = () => {
|
|
||||||
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift());
|
|
||||||
};
|
|
||||||
const flushInput = () => {
|
|
||||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
while (pendingInput.length > 0) ws.send(pendingInput.shift());
|
|
||||||
};
|
|
||||||
const finish = (code) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(openTimer);
|
|
||||||
clearTimeout(runtimeTimer);
|
|
||||||
process.exit(code);
|
|
||||||
};
|
|
||||||
const openTimer = setTimeout(() => {
|
|
||||||
if (sessionReady || settled) return;
|
|
||||||
process.stderr.write("unidesk runner tran timed out waiting for provider session\n");
|
|
||||||
exitCode = 255;
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
}, open.openTimeoutMs);
|
|
||||||
const runtimeTimer = setTimeout(() => {
|
|
||||||
if (settled) return;
|
|
||||||
process.stderr.write(`UNIDESK_TRAN_TIMEOUT_HINT ${JSON.stringify({ code: "tran-runtime-timeout", level: "warning", route: open.route, timeoutMs: open.runtimeTimeoutMs, message: "tran exceeded the runtime limit; use short query plus poll semantics" })}\n`);
|
|
||||||
exitCode = 124;
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
finish(124);
|
|
||||||
}, open.runtimeTimeoutMs);
|
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
|
||||||
canSend = true;
|
|
||||||
send({ type: "ssh.open", providerId: open.providerId, command: open.command, cwd: open.cwd, tty: open.tty, cols: open.cols, rows: open.rows });
|
|
||||||
flush();
|
|
||||||
});
|
|
||||||
ws.addEventListener("message", (event) => {
|
|
||||||
let message;
|
|
||||||
const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf8");
|
|
||||||
try {
|
|
||||||
message = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
process.stderr.write(`${text}\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type === "ssh.dispatched") return;
|
|
||||||
if (message.type === "ssh.opened") {
|
|
||||||
sessionReady = true;
|
|
||||||
clearTimeout(openTimer);
|
|
||||||
sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" });
|
|
||||||
sendInput({ type: "ssh.eof" });
|
|
||||||
flushInput();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type === "ssh.data") {
|
|
||||||
const chunk = Buffer.from(String(message.data || ""), message.encoding === "base64" ? "base64" : "utf8");
|
|
||||||
if (message.stream === "stderr") process.stderr.write(chunk);
|
|
||||||
else process.stdout.write(chunk);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type === "ssh.error") {
|
|
||||||
process.stderr.write(`${String(message.message || "ssh bridge error")}\n`);
|
|
||||||
exitCode = 255;
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type === "ssh.exit") {
|
|
||||||
exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255;
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ws.addEventListener("close", () => finish(exitCode));
|
|
||||||
ws.addEventListener("error", () => {
|
|
||||||
process.stderr.write("unidesk runner tran websocket error\n");
|
|
||||||
finish(255);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await main();
|
|
||||||
|
|||||||
Reference in New Issue
Block a user