fix(code-queue): preflight runner skills availability
fix(code-queue): preflight runner skills availability
This commit is contained in:
@@ -163,6 +163,19 @@ bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base m
|
||||
|
||||
该脚本只读调用 `gh auth status`,并执行 `gh pr create --dry-run` 与 `gh pr comment --dry-run`。它检查当前 shell 的 `GH_TOKEN/GITHUB_TOKEN` 是否存在、GitHub REST egress 是否可达、repo 是否可见,并且只输出 token 来源和存在性,不输出 token 值。它不能证明 Code Queue default scheduler 已注入 token;跨 queue 派单 admission 应使用 `codex pr-preflight`。
|
||||
|
||||
### Runner Skill 可用性
|
||||
|
||||
D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/.agents/skills`,生产和 dev Code Queue Pod 都必须只读挂载到容器内 `/root/.agents/skills`,并设置 `UNIDESK_SKILLS_PATH=/root/.agents/skills`。不要使用或传播任何拼错的 skills 路径;诊断中只能把这类问题作为 forbidden path risk 暴露。
|
||||
|
||||
执行面 `/health`、`/api/dev-ready` 和 `/api/runtime-preflight` 必须输出同一份只读 skill availability report。稳定字段包括 `source`、`target`、`requiredSkills`、`missingSkills`、`degraded`、`blocker` 和 `valuesPrinted=false`。`requiredSkills` 至少覆盖 `docs-spec`、`cli-spec`、`frontend-design` 与 `playwright-cli`;如果目标目录缺失、不是只读挂载、必需 skill 缺失,或拼写错误路径存在,报告必须显示 `ok=false` 和结构化 `blocker`,不能把 runner 能力缺口伪装成业务任务失败。
|
||||
|
||||
受控加载路径是更新宿主 `/home/ubuntu/.agents/skills` 后让 Code Queue Pod 通过 hostPath 读取;在线热更新只能作为临时 runbook,长期验收必须以 manifest/source-of-truth、结构化 health/preflight 和合同测试为准。需要验证时优先运行:
|
||||
|
||||
```bash
|
||||
bun scripts/code-queue-runner-skills-contract-test.ts
|
||||
bun scripts/cli.ts codex pr-preflight --remote --issue <issue-number>
|
||||
```
|
||||
|
||||
指挥官审查 checklist:
|
||||
|
||||
- PR base 是声明的目标分支,head branch 命名可追踪,远端 head commit 可 fetch。
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { collectSkillAvailability } from "../src/components/microservices/code-queue/src/skill-availability";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, label: string): JsonRecord {
|
||||
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
const productionManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml", "utf8");
|
||||
const devManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", "utf8");
|
||||
const runtimePreflight = readFileSync("src/components/microservices/code-queue/src/runtime-preflight.ts", "utf8");
|
||||
const indexSource = readFileSync("src/components/microservices/code-queue/src/index.ts", "utf8");
|
||||
const skillModule = readFileSync("src/components/microservices/code-queue/src/skill-availability.ts", "utf8");
|
||||
const promptSource = readFileSync("src/components/microservices/code-queue/src/prompts.ts", "utf8");
|
||||
const docsReference = readFileSync("docs/reference/code-queue-supervision.md", "utf8");
|
||||
const forbiddenPathLiteral = [".ag", "nets/skills"].join("");
|
||||
|
||||
assertCondition(!productionManifest.includes(forbiddenPathLiteral), "production manifest must not propagate misspelled skills path");
|
||||
assertCondition(!devManifest.includes(forbiddenPathLiteral), "dev manifest must not propagate misspelled skills path");
|
||||
assertCondition(!promptSource.includes(forbiddenPathLiteral), "runner prompt must not mention misspelled skills path");
|
||||
assertCondition(!skillModule.includes(forbiddenPathLiteral), "skill availability implementation must not propagate misspelled skills path literal");
|
||||
assertCondition(!docsReference.includes(forbiddenPathLiteral), "reference docs must not propagate misspelled skills path literal");
|
||||
assertCondition(countOccurrences(productionManifest, "name: UNIDESK_SKILLS_PATH") === 3, "production read/write/scheduler must set UNIDESK_SKILLS_PATH", {
|
||||
count: countOccurrences(productionManifest, "name: UNIDESK_SKILLS_PATH"),
|
||||
});
|
||||
assertCondition(countOccurrences(productionManifest, "mountPath: /root/.agents/skills") === 3, "production read/write/scheduler must mount skills target", {
|
||||
count: countOccurrences(productionManifest, "mountPath: /root/.agents/skills"),
|
||||
});
|
||||
assertCondition(countOccurrences(productionManifest, "path: /home/ubuntu/.agents/skills") === 3, "production read/write/scheduler must use hostPath source of truth", {
|
||||
count: countOccurrences(productionManifest, "path: /home/ubuntu/.agents/skills"),
|
||||
});
|
||||
assertCondition(countOccurrences(productionManifest, "name: skills-dir") >= 6, "production manifest must define skills-dir mounts and volumes", {
|
||||
count: countOccurrences(productionManifest, "name: skills-dir"),
|
||||
});
|
||||
assertCondition(devManifest.includes("path: /home/ubuntu/.agents/skills"), "dev manifest should keep the same hostPath source of truth");
|
||||
|
||||
const available = collectSkillAvailability({
|
||||
source: "/home/ubuntu/.agents/skills",
|
||||
target: "/home/ubuntu/.agents/skills",
|
||||
requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"],
|
||||
});
|
||||
assertCondition(available.source === "/home/ubuntu/.agents/skills", "skill report must expose source");
|
||||
assertCondition(available.target === "/home/ubuntu/.agents/skills", "skill report must expose target");
|
||||
assertCondition(Array.isArray(available.requiredSkills) && available.requiredSkills.includes("docs-spec"), "skill report must expose requiredSkills");
|
||||
assertCondition(Array.isArray(available.missingSkills), "skill report must expose missingSkills");
|
||||
assertCondition(available.valuesPrinted === false, "skill report must declare valuesPrinted=false");
|
||||
assertCondition(asRecord(available.pathSpelling, "pathSpelling").forbiddenPathMustNotBeUsed === true, "skill report must flag misspelled path risk without spreading the literal path");
|
||||
assertCondition(!JSON.stringify(available).includes(forbiddenPathLiteral), "skill report must not propagate misspelled path literal");
|
||||
assertCondition(!JSON.stringify(available).includes("GH_TOKEN"), "skill report must not include secret environment names unrelated to skills");
|
||||
|
||||
const missing = collectSkillAvailability({
|
||||
source: "/home/ubuntu/.agents/skills",
|
||||
target: "/path/that/does/not/exist/for-code-queue-skills-test",
|
||||
requiredSkills: ["docs-spec", "cli-spec"],
|
||||
});
|
||||
assertCondition(missing.ok === false, "missing target should fail");
|
||||
assertCondition(missing.degraded === true, "missing target should be degraded");
|
||||
assertCondition(missing.blocker === "skills-target-missing", "missing target should expose blocker", missing);
|
||||
assertCondition(missing.missingSkills.includes("docs-spec") && missing.missingSkills.includes("cli-spec"), "missing target should list required missing skills", missing);
|
||||
assertCondition(missing.valuesPrinted === false, "missing report must also declare valuesPrinted=false");
|
||||
|
||||
assertCondition(runtimePreflight.includes("skills: SkillAvailabilityReport"), "runtime preflight type must include skills report");
|
||||
assertCondition(runtimePreflight.includes("collectSkillAvailability"), "runtime preflight must collect skills availability");
|
||||
assertCondition(runtimePreflight.includes("skills.ok && ports.codex.ok"), "runtime preflight ok must depend on skills.ok");
|
||||
assertCondition(indexSource.includes("const skillsReady = skills.ok === true"), "dev-ready must gate on structured skills ok");
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"production Code Queue mounts /home/ubuntu/.agents/skills read-only at /root/.agents/skills",
|
||||
"skill availability report exposes source, target, requiredSkills, missingSkills, degraded/blocker and valuesPrinted=false",
|
||||
"runtime-preflight and dev-ready use the same structured skill report",
|
||||
"misspelled skills paths are only surfaced as a forbidden diagnostic risk",
|
||||
],
|
||||
observedRunner: {
|
||||
source: available.source,
|
||||
target: available.target,
|
||||
ok: available.ok,
|
||||
missingSkills: available.missingSkills,
|
||||
valuesPrinted: available.valuesPrinted,
|
||||
},
|
||||
}, null, 2)}\n`);
|
||||
@@ -303,6 +303,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
fileItem("scripts/src/code-queue-liveness-fixtures.ts"),
|
||||
fileItem("scripts/code-queue-trace-summary-contract-test.ts"),
|
||||
fileItem("scripts/code-queue-pr-preflight-contract-test.ts"),
|
||||
fileItem("scripts/code-queue-runner-skills-contract-test.ts"),
|
||||
fileItem("scripts/code-queue-submit-routing-contract-test.ts"),
|
||||
fileItem("scripts/code-queue-supervisor-disclosure-contract-test.ts"),
|
||||
fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"),
|
||||
@@ -339,6 +340,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000));
|
||||
@@ -364,6 +366,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:runner-skills-contract", "Code Queue runner skill availability contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:supervisor-disclosure-contract", "Code Queue supervisor disclosure contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
|
||||
@@ -2593,13 +2593,21 @@ function compactSkillsStatus(value: unknown): Record<string, unknown> | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) return null;
|
||||
return {
|
||||
ok: record.ok ?? false,
|
||||
path: record.path ?? null,
|
||||
source: record.source ?? null,
|
||||
target: record.target ?? null,
|
||||
mountPoint: record.mountPoint ?? null,
|
||||
exists: record.exists ?? false,
|
||||
available: record.available ?? false,
|
||||
degraded: record.degraded ?? true,
|
||||
blocker: record.blocker ?? null,
|
||||
readonly: record.readonly ?? false,
|
||||
skillCount: record.skillCount ?? 0,
|
||||
cliSpecAvailable: record.cliSpecAvailable ?? false,
|
||||
requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills : [],
|
||||
missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills : [],
|
||||
valuesPrinted: record.valuesPrinted ?? false,
|
||||
pathSpelling: record.pathSpelling ?? null,
|
||||
repairHint: record.repairHint ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
readOaTraceStepsForTask,
|
||||
} from "./oa-events";
|
||||
import { collectRuntimePreflight, runtimePreflightJson } from "./runtime-preflight";
|
||||
import { collectSkillAvailability, skillAvailabilityJson } from "./skill-availability";
|
||||
import { configureSelfTests, runJudgeInfraSelfTest, runQueueClaimMoveSelfTest, runQueueOrderingSelfTest, runReferenceInjectionSelfTest, runTracePortSelfTest, runTraceSummaryContractSelfTest } from "./self-tests";
|
||||
import {
|
||||
codexToolLifecycleStartedBeforeIn,
|
||||
@@ -2420,72 +2421,8 @@ function runProbe(command: string, args: string[], timeout = 3_000): { ok: boole
|
||||
return { ok: result.status === 0, output };
|
||||
}
|
||||
|
||||
function decodeMountInfoPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
|
||||
}
|
||||
|
||||
function mountInfoForPath(path: string): { mountPoint: string | null; readonly: boolean | null } {
|
||||
try {
|
||||
const target = resolve(path);
|
||||
let best: { mountPoint: string; readonly: boolean } | null = null;
|
||||
for (const line of readFileSync("/proc/self/mountinfo", "utf8").split(/\r?\n/u)) {
|
||||
if (line.trim().length === 0) continue;
|
||||
const fields = line.split(" ");
|
||||
const mountPoint = decodeMountInfoPath(fields[4] ?? "");
|
||||
const options = (fields[5] ?? "").split(",");
|
||||
if (mountPoint.length === 0) continue;
|
||||
const matches = target === mountPoint || target.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`);
|
||||
if (!matches) continue;
|
||||
if (best === null || mountPoint.length > best.mountPoint.length) best = { mountPoint, readonly: options.includes("ro") };
|
||||
}
|
||||
return best ?? { mountPoint: null, readonly: null };
|
||||
} catch {
|
||||
return { mountPoint: null, readonly: null };
|
||||
}
|
||||
}
|
||||
|
||||
function collectSkillsStatus(): JsonValue {
|
||||
const path = config.skillsPath;
|
||||
const exists = existsSync(path);
|
||||
const mountInfo = mountInfoForPath(path);
|
||||
let directory = false;
|
||||
let skillCount = 0;
|
||||
let cliSpecAvailable = false;
|
||||
let readonly = mountInfo.readonly === true;
|
||||
let error: string | null = null;
|
||||
if (exists) {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
directory = stat.isDirectory();
|
||||
if (directory) {
|
||||
const entries = readdirSync(path, { withFileTypes: true });
|
||||
skillCount = entries.filter((entry) => entry.isDirectory()).length;
|
||||
cliSpecAvailable = existsSync(resolve(path, "cli-spec", "SKILL.md"));
|
||||
if (mountInfo.readonly === null) {
|
||||
const writeProbe = runProbe("sh", ["-lc", `test ! -w ${shellQuote(path)}`], 2_000);
|
||||
readonly = writeProbe.ok;
|
||||
}
|
||||
}
|
||||
} catch (probeError) {
|
||||
error = probeError instanceof Error ? probeError.message : String(probeError);
|
||||
}
|
||||
}
|
||||
const available = exists && directory;
|
||||
return {
|
||||
path,
|
||||
mountPoint: mountInfo.mountPoint,
|
||||
exists,
|
||||
directory,
|
||||
available,
|
||||
readonly,
|
||||
skillCount,
|
||||
cliSpecAvailable,
|
||||
expectedMount: "host ~/.agents/skills mounted read-only to UNIDESK_SKILLS_PATH",
|
||||
repairHint: available && readonly && cliSpecAvailable
|
||||
? null
|
||||
: "DEV code-queue should mount /home/ubuntu/.agents/skills read-only at /root/.agents/skills and set UNIDESK_SKILLS_PATH=/root/.agents/skills.",
|
||||
error,
|
||||
} as unknown as JsonValue;
|
||||
return skillAvailabilityJson(collectSkillAvailability({ target: config.skillsPath }));
|
||||
}
|
||||
|
||||
function collectDevReady(): JsonValue {
|
||||
@@ -2534,7 +2471,7 @@ function collectDevReady(): JsonValue {
|
||||
const githubKnownHostProbe = runProbe("ssh-keygen", ["-F", "github.com", "-f", "/root/.ssh/known_hosts"]);
|
||||
const sshSharedReady = existsSync("/root/.ssh") && sshKeyProbe.ok && sshKeyProbe.output.trim().length > 0;
|
||||
const skills = collectSkillsStatus() as Record<string, JsonValue>;
|
||||
const skillsReady = skills.available === true && skills.readonly === true && skills.cliSpecAvailable === true;
|
||||
const skillsReady = skills.ok === true;
|
||||
const runtimePreflight = runtimePreflightJson(collectRuntimePreflight({ includeRemote: false, includePushDryRun: false }));
|
||||
const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady;
|
||||
const value: JsonValue = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { JsonValue } from "./types";
|
||||
import { collectSkillAvailability, type SkillAvailabilityReport } from "./skill-availability";
|
||||
|
||||
export type RuntimePreflightAgentPort = "codex" | "opencode";
|
||||
|
||||
@@ -51,6 +52,7 @@ export interface RuntimePreflightReport {
|
||||
version: string;
|
||||
};
|
||||
path: string;
|
||||
skills: SkillAvailabilityReport;
|
||||
ports: Record<RuntimePreflightAgentPort, RuntimePreflightPortStatus>;
|
||||
pullRequestDelivery: PullRequestDeliveryPreflight;
|
||||
}
|
||||
@@ -645,13 +647,14 @@ function opencodeStatus(path: string, checkedAt: string): RuntimePreflightPortSt
|
||||
export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): RuntimePreflightReport {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const path = process.env.PATH ?? "";
|
||||
const skills = collectSkillAvailability({ target: process.env.UNIDESK_SKILLS_PATH || "/root/.agents/skills" });
|
||||
const ports = {
|
||||
codex: codexStatus(path, checkedAt),
|
||||
opencode: opencodeStatus(path, checkedAt),
|
||||
};
|
||||
const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt);
|
||||
return {
|
||||
ok: ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok,
|
||||
ok: skills.ok && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok,
|
||||
checkedAt,
|
||||
cwd: process.cwd(),
|
||||
pid: process.pid,
|
||||
@@ -661,6 +664,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}):
|
||||
version: process.version,
|
||||
},
|
||||
path,
|
||||
skills,
|
||||
ports,
|
||||
pullRequestDelivery,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { JsonValue } from "./types";
|
||||
|
||||
export interface SkillAvailabilityOptions {
|
||||
target: string;
|
||||
source?: string;
|
||||
requiredSkills?: string[];
|
||||
}
|
||||
|
||||
export interface SkillAvailabilityReport {
|
||||
ok: boolean;
|
||||
available: boolean;
|
||||
degraded: boolean;
|
||||
blocker: string | null;
|
||||
checkedAt: string;
|
||||
source: string;
|
||||
target: string;
|
||||
path: string;
|
||||
mountPoint: string | null;
|
||||
exists: boolean;
|
||||
directory: boolean;
|
||||
readonly: boolean;
|
||||
skillCount: number;
|
||||
requiredSkills: string[];
|
||||
missingSkills: string[];
|
||||
skills: Array<{ name: string; present: boolean; skillMdPresent: boolean; path: string }>;
|
||||
pathSpelling: {
|
||||
expectedTarget: string;
|
||||
forbiddenPathChecked: true;
|
||||
forbiddenPathExists: boolean;
|
||||
forbiddenPathMustNotBeUsed: true;
|
||||
};
|
||||
expectedMount: string;
|
||||
repairHint: string | null;
|
||||
error: string | null;
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"];
|
||||
const defaultSource = "/home/ubuntu/.agents/skills";
|
||||
const expectedTarget = "/root/.agents/skills";
|
||||
const forbiddenSkillsDirName = [".ag", "nets"].join("");
|
||||
const forbiddenTargets = [`/root/${forbiddenSkillsDirName}/skills`, `/home/ubuntu/${forbiddenSkillsDirName}/skills`];
|
||||
const skillDirectoryAliases: Record<string, string[]> = {
|
||||
"playwright-cli": ["playwright-cli", "playwright"],
|
||||
};
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/gu, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function commandOk(command: string, args: string[], timeoutMs = 2000): boolean {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 64 * 1024,
|
||||
shell: false,
|
||||
});
|
||||
return result.error === undefined && result.status === 0;
|
||||
}
|
||||
|
||||
function decodeMountInfoPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
|
||||
}
|
||||
|
||||
function mountInfoForPath(path: string): { mountPoint: string | null; readonly: boolean | null } {
|
||||
try {
|
||||
const target = resolve(path);
|
||||
let best: { mountPoint: string; readonly: boolean } | null = null;
|
||||
for (const line of readFileSync("/proc/self/mountinfo", "utf8").split(/\r?\n/u)) {
|
||||
if (line.trim().length === 0) continue;
|
||||
const fields = line.split(" ");
|
||||
const mountPoint = decodeMountInfoPath(fields[4] ?? "");
|
||||
const options = (fields[5] ?? "").split(",");
|
||||
if (mountPoint.length === 0) continue;
|
||||
const matches = target === mountPoint || target.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`);
|
||||
if (!matches) continue;
|
||||
if (best === null || mountPoint.length > best.mountPoint.length) best = { mountPoint, readonly: options.includes("ro") };
|
||||
}
|
||||
return best ?? { mountPoint: null, readonly: null };
|
||||
} catch {
|
||||
return { mountPoint: null, readonly: null };
|
||||
}
|
||||
}
|
||||
|
||||
function candidateDirs(name: string): string[] {
|
||||
return skillDirectoryAliases[name] ?? [name];
|
||||
}
|
||||
|
||||
function skillStatus(target: string, requiredSkills: string[]): SkillAvailabilityReport["skills"] {
|
||||
return requiredSkills.map((name) => {
|
||||
const candidates = candidateDirs(name).map((candidate) => resolve(target, candidate));
|
||||
const existingPath = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isDirectory());
|
||||
const path = existingPath ?? candidates[0] ?? resolve(target, name);
|
||||
return {
|
||||
name,
|
||||
present: existingPath !== undefined,
|
||||
skillMdPresent: existsSync(resolve(path, "SKILL.md")),
|
||||
path,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function collectSkillAvailability(options: SkillAvailabilityOptions): SkillAvailabilityReport {
|
||||
const target = options.target;
|
||||
const source = options.source ?? defaultSource;
|
||||
const requiredSkills = options.requiredSkills ?? defaultRequiredSkills;
|
||||
const checkedAt = new Date().toISOString();
|
||||
const mountInfo = mountInfoForPath(target);
|
||||
const exists = existsSync(target);
|
||||
const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path));
|
||||
let directory = false;
|
||||
let skillCount = 0;
|
||||
let readonly = mountInfo.readonly === true;
|
||||
let error: string | null = null;
|
||||
let skills: SkillAvailabilityReport["skills"] = requiredSkills.map((name) => ({
|
||||
name,
|
||||
present: false,
|
||||
skillMdPresent: false,
|
||||
path: resolve(target, name),
|
||||
}));
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
const stat = statSync(target);
|
||||
directory = stat.isDirectory();
|
||||
if (directory) {
|
||||
const entries = readdirSync(target, { withFileTypes: true });
|
||||
skillCount = entries.filter((entry) => entry.isDirectory()).length;
|
||||
skills = skillStatus(target, requiredSkills);
|
||||
if (mountInfo.readonly === null) {
|
||||
readonly = commandOk("sh", ["-lc", `test ! -w ${shellQuote(target)}`]);
|
||||
}
|
||||
}
|
||||
} catch (probeError) {
|
||||
error = probeError instanceof Error ? probeError.message : String(probeError);
|
||||
}
|
||||
}
|
||||
|
||||
const missingSkills = skills.filter((skill) => !skill.present || !skill.skillMdPresent).map((skill) => skill.name);
|
||||
const available = exists && directory;
|
||||
const blocker = !available
|
||||
? "skills-target-missing"
|
||||
: !readonly
|
||||
? "skills-target-not-readonly"
|
||||
: missingSkills.length > 0
|
||||
? "required-skills-missing"
|
||||
: forbiddenPathExists
|
||||
? "forbidden-skills-path-present"
|
||||
: null;
|
||||
const ok = blocker === null;
|
||||
return {
|
||||
ok,
|
||||
available,
|
||||
degraded: !ok,
|
||||
blocker,
|
||||
checkedAt,
|
||||
source,
|
||||
target,
|
||||
path: target,
|
||||
mountPoint: mountInfo.mountPoint,
|
||||
exists,
|
||||
directory,
|
||||
readonly,
|
||||
skillCount,
|
||||
requiredSkills,
|
||||
missingSkills,
|
||||
skills,
|
||||
pathSpelling: {
|
||||
expectedTarget,
|
||||
forbiddenPathChecked: true,
|
||||
forbiddenPathExists,
|
||||
forbiddenPathMustNotBeUsed: true,
|
||||
},
|
||||
expectedMount: `${source} mounted read-only to ${target}`,
|
||||
repairHint: ok
|
||||
? null
|
||||
: `Mount ${source} read-only at ${target}, set UNIDESK_SKILLS_PATH=${target}, and remove any forbidden skills path spelling.`,
|
||||
error,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function skillAvailabilityJson(report: SkillAvailabilityReport): JsonValue {
|
||||
return report as unknown as JsonValue;
|
||||
}
|
||||
@@ -80,6 +80,8 @@ spec:
|
||||
value: "/var/lib/unidesk/code-queue/opencode-xdg"
|
||||
- name: CODE_QUEUE_SOURCE_CODEX_CONFIG
|
||||
value: "/root/.codex/config.toml"
|
||||
- name: UNIDESK_SKILLS_PATH
|
||||
value: "/root/.agents/skills"
|
||||
- name: CODE_QUEUE_DEFAULT_MODEL
|
||||
value: "gpt-5.5"
|
||||
- name: CODE_QUEUE_MODELS
|
||||
@@ -177,6 +179,9 @@ spec:
|
||||
- name: codex-auth
|
||||
mountPath: /root/.codex/auth.json
|
||||
readOnly: true
|
||||
- name: skills-dir
|
||||
mountPath: /root/.agents/skills
|
||||
readOnly: true
|
||||
- name: ssh-dir
|
||||
mountPath: /root/.ssh
|
||||
readOnly: true
|
||||
@@ -232,6 +237,10 @@ spec:
|
||||
hostPath:
|
||||
path: /home/ubuntu/.codex/auth.json
|
||||
type: File
|
||||
- name: skills-dir
|
||||
hostPath:
|
||||
path: /home/ubuntu/.agents/skills
|
||||
type: Directory
|
||||
- name: ssh-dir
|
||||
hostPath:
|
||||
path: /home/ubuntu/.ssh
|
||||
@@ -317,6 +326,8 @@ spec:
|
||||
value: "/var/lib/unidesk/code-queue/opencode-xdg"
|
||||
- name: CODE_QUEUE_SOURCE_CODEX_CONFIG
|
||||
value: "/root/.codex/config.toml"
|
||||
- name: UNIDESK_SKILLS_PATH
|
||||
value: "/root/.agents/skills"
|
||||
- name: CODE_QUEUE_DEFAULT_MODEL
|
||||
value: "gpt-5.5"
|
||||
- name: CODE_QUEUE_MODELS
|
||||
@@ -414,6 +425,9 @@ spec:
|
||||
- name: codex-auth
|
||||
mountPath: /root/.codex/auth.json
|
||||
readOnly: true
|
||||
- name: skills-dir
|
||||
mountPath: /root/.agents/skills
|
||||
readOnly: true
|
||||
- name: ssh-dir
|
||||
mountPath: /root/.ssh
|
||||
readOnly: true
|
||||
@@ -469,6 +483,10 @@ spec:
|
||||
hostPath:
|
||||
path: /home/ubuntu/.codex/auth.json
|
||||
type: File
|
||||
- name: skills-dir
|
||||
hostPath:
|
||||
path: /home/ubuntu/.agents/skills
|
||||
type: Directory
|
||||
- name: ssh-dir
|
||||
hostPath:
|
||||
path: /home/ubuntu/.ssh
|
||||
@@ -1013,6 +1031,8 @@ spec:
|
||||
value: "/var/lib/unidesk/code-queue/opencode-xdg"
|
||||
- name: CODE_QUEUE_SOURCE_CODEX_CONFIG
|
||||
value: "/root/.codex/config.toml"
|
||||
- name: UNIDESK_SKILLS_PATH
|
||||
value: "/root/.agents/skills"
|
||||
- name: CODE_QUEUE_DEFAULT_MODEL
|
||||
value: "gpt-5.5"
|
||||
- name: CODE_QUEUE_MODELS
|
||||
@@ -1110,6 +1130,9 @@ spec:
|
||||
- name: codex-auth
|
||||
mountPath: /root/.codex/auth.json
|
||||
readOnly: true
|
||||
- name: skills-dir
|
||||
mountPath: /root/.agents/skills
|
||||
readOnly: true
|
||||
- name: ssh-dir
|
||||
mountPath: /root/.ssh
|
||||
readOnly: true
|
||||
@@ -1165,6 +1188,10 @@ spec:
|
||||
hostPath:
|
||||
path: /home/ubuntu/.codex/auth.json
|
||||
type: File
|
||||
- name: skills-dir
|
||||
hostPath:
|
||||
path: /home/ubuntu/.agents/skills
|
||||
type: Directory
|
||||
- name: ssh-dir
|
||||
hostPath:
|
||||
path: /home/ubuntu/.ssh
|
||||
|
||||
Reference in New Issue
Block a user