Compare commits

..

No commits in common. "1aceabee4498ec81fb3ebd62e1258e3fd6b40e72" and "92feff97e640cbc22175dcae50765dec739c8ffd" have entirely different histories.

14 changed files with 22 additions and 5518 deletions

View File

@ -86,16 +86,6 @@ NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN=...
If `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN` is not set, the service falls back to `NODEDC_INTERNAL_ACCESS_TOKEN`.
Launcher entitlement adapter configuration:
```text
AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL=http://launcher:5173/api/ai-workspace/internal/v1/entitlements
AI_WORKSPACE_LAUNCHER_ENTITLEMENT_TOKEN=...
AI_WORKSPACE_LAUNCHER_ENTITLEMENT_REQUIRED=true
```
The Launcher adapter is evaluated after app-owned adapters and may return `launcher`, `ops`, and `engine` app grants. A Launcher denial is the top-level service-access decision and clears MCP servers for that app in the run profile; app-owned adapters still own app-local scopes and runtime tokens.
The first HUB action ids exposed to assistants are:
- `hub.user.read_admin_summary`
@ -144,9 +134,6 @@ or app-specific variables:
AI_WORKSPACE_OPS_ENTITLEMENT_URL=https://ops.example/api/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=...
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
AI_WORKSPACE_ENGINE_ENTITLEMENT_URL=https://engine.example/api/ai-workspace/internal/v1/entitlements
AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN=...
AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED=false
```
Adapter request body:

View File

@ -155,7 +155,7 @@ Scenarios:
- Engine user with Ops read grant opens an Ops-targeted run and can read the target card.
- Engine user with Ops write grant creates or updates in the target Ops project.
- Engine user without target Ops grant gets preflight denied and no token.
- Ops-origin run can call Engine-side context once `AI_WORKSPACE_ENGINE_ENTITLEMENT_URL` points at Engine `/api/ai-workspace/internal/v1/entitlements`.
- Ops-origin run can call Engine-side context once the Engine entitlement adapter exists.
- Admin grants a user a new project permission; new AI Workspace run receives it without reinstalling the agent.
- Admin revokes a project permission; new run token loses it and existing source-linked token grants stop resolving through deleted grants.

View File

@ -1,32 +0,0 @@
# NODE.DC AI Workspace Bridge
Installs the remote Codex worker used by NODE.DC AI Workspace.
AI Hub tarball form:
```sh
npx --yes --package https://ai-hub.nodedc.ru/api/ai-workspace/bridge-package.tgz ai-workspace-bridge setup <setup-code> --gateway https://ai-hub.nodedc.ru
```
Commands:
```sh
ai-workspace-bridge setup <setup-code> [--gateway <url>] [--install-root <path>] [--workspace <path>] [--port <port>]
ai-workspace-bridge status [--install-root <path>]
ai-workspace-bridge doctor [--install-root <path>]
ai-workspace-bridge update [--install-root <path>] [--no-restart]
ai-workspace-bridge start [--install-root <path>]
ai-workspace-bridge stop [--install-root <path>]
ai-workspace-bridge logs [--install-root <path>]
```
The setup code is one-time and short-lived. The CLI does not print the redeemed pairing code or MCP tokens.
`setup` and `update` install bundled NODE.DC Codex skills into the bridge `CODEX_HOME/skills` directory:
- `ai-workspace-context`
- `engine-context`
- `launcher-context`
- `ops-context`
The worker reports `agentVersion`, `protocolVersion`, and `capabilities` in local health and Hub hello metadata. Server-side version gates are controlled by Assistant environment configuration so legacy PowerShell agents can remain available as a fallback until a deploy explicitly requires a newer npm bridge.

View File

@ -1,21 +0,0 @@
---
name: ai-workspace-context
description: Use when running inside NODE.DC AI Workspace Bridge to understand platform routing, Hub transport, run profiles, app grants, dynamic MCP, and access-denied behavior.
---
# NODE.DC AI Workspace Context
You are running through NODE.DC AI Workspace. Treat the current run profile, app catalog, and tool profile as the source of truth for what this run can do.
Core boundaries:
- AI Workspace Hub is transport only: pairing, relay, websocket/http delivery, and setup-code redeem. Do not treat Hub as the owner of product roles or business permissions.
- Launcher/access plane owns user roles, app availability, company/workspace contours, and entitlement decisions.
- Engine owns workflow, graph, code, run/runtime, and engineering actions.
- Ops owns projects, cards, comments, reports, checkers, labels, and operational task state.
- Worker/bridge is an executor. It must not invent domain permissions, long-lived secrets, or app-specific business policy.
Before acting, infer the target app from the user's intent and the current UI context. Prefer the run profile app catalog and available MCP/action namespaces over guessing from names.
If a requested app or capability is explicitly marked `status: denied` in the current run profile, do not bypass it through another app. Say: `Доступ к модулю ограничен, обратитесь к администратору системы.` If another part of the request is available, complete only that available part and clearly separate the blocked part. Do not say this denial text for granted apps, available tool packs, or ordinary explanations.
Never print MCP tokens, headers, setup codes, pairing codes, gateway tokens, or run-scoped secrets.

View File

@ -1,17 +0,0 @@
---
name: engine-context
description: Use for NODE.DC Engine/InJoin workflow, graph, code, runtime, NDC Agent Core, agent-node, and engineering automation tasks inside AI Workspace.
---
# NODE.DC Engine Context
Use Engine when the user asks to code, inspect or change workflows, work with graph nodes, NDC Agent Core, InJoin, runtime errors, generated code, integrations, or workflow-level automation.
Routing rules:
- Use Engine capabilities only when the run profile exposes Engine or NDC Agent Core tools/actions.
- Treat Ops as a separate product. Creating cards, comments, and operational reports belongs to Ops, even if the request starts from Engine UI.
- Treat Launcher/access as a separate product. Changing user access or roles belongs to Launcher.
For write actions, prefer the structured Engine capability exposed in the current run profile. If the action is risky, present a preview and wait for confirmation when the run profile or action policy requires it.
If Engine is explicitly marked `status: denied` in the current run profile, do not simulate Engine writes with filesystem edits or unrelated tools. Say: `Доступ к модулю ограничен, обратитесь к администратору системы.` Do not say this denial text for ordinary Engine explanations, granted Engine access, or NDC Agent Core access.

View File

@ -1,20 +0,0 @@
---
name: launcher-context
description: Use for NODE.DC Launcher/access-plane tasks: users, app access, admin roles, company/workspace contours, invitations, and entitlement decisions.
---
# NODE.DC Launcher Context
Launcher/access plane is the source of truth for who can access which NODE.DC applications and administrative surfaces.
Use Launcher when the user asks about:
- enabling or disabling access to Ops, Engine, Launcher, or future apps;
- assigning admin scope, user role, company/workspace contour, or application access;
- reading pending access requests, invites, memberships, or access matrix state;
- explaining why an assistant cannot reach an application.
Do not treat AI Workspace Hub as Launcher. Hub only transports bridge traffic and setup-code redeem. It does not decide product access.
Do not delete users, workspaces, projects, or role records through the assistant. Prefer safe actions such as block, disable, revoke access, or create an audit note when those actions are explicitly available.
If Launcher capability is explicitly marked `status: denied` in the current run profile, say: `Доступ к модулю ограничен, обратитесь к администратору системы.` Do not say this denial text for granted Launcher access, available access actions, or ordinary explanations.

View File

@ -1,18 +0,0 @@
---
name: ops-context
description: Use for NODE.DC Ops/Tasker projects, cards, comments, structured blocks, checkers, labels, assignees, reports, and operational task updates inside AI Workspace.
---
# NODE.DC Ops Context
Use Ops when the user asks to create, read, update, summarize, or report on projects, cards, comments, structured blocks, checkers, labels, assignees, or operational task state.
Inside AI Workspace, use the Ops MCP/actions exposed by the current run profile. Do not assume direct local Ops tools exist unless the run profile advertises them.
Card-writing rules:
- Keep titles concise and operational.
- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks when the available Ops capability supports them.
- Put short verifiable work items into checker blocks.
- Do not delete or archive cards unless a safe, explicitly exposed capability and confirmation policy allow the requested operation. Prefer comments or status updates.
If Ops is explicitly marked `status: denied` in the current run profile, say: `Доступ к модулю ограничен, обратитесь к администратору системы.` Do not say this denial text for granted Ops access or ordinary Ops explanations.

View File

@ -1,981 +0,0 @@
#!/usr/bin/env node
import { copyFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import fssync from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const DEFAULT_GATEWAY = "https://ai-hub.nodedc.ru";
const PACKAGE_NAME = "@nodedc/ai-workspace-bridge";
const SERVICE_NAME = "NDC AI Workspace Bridge";
const WINDOWS_TASK_NAME = "NDC AI Workspace Bridge";
const HEALTH_PATH = "/api/ai-workspace/bridge/v1/health";
const BRIDGE_PROTOCOL_VERSION = "ai-workspace-bridge.v1";
const BRIDGE_CAPABILITIES = [
"hub-outbound",
"local-health",
"codex-exec",
"codex-resume",
"codex-stop",
"dynamic-run-profile",
"dynamic-mcp",
"bundled-skills",
"self-update",
];
const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
"--skip-git-repo-check",
"-c",
"model_reasoning_summary=detailed",
"-c",
"model_supports_reasoning_summaries=true",
"-c",
"use_experimental_reasoning_summary=true",
"-c",
"hide_agent_reasoning=false",
"-c",
"show_raw_agent_reasoning=false",
"-",
];
const CLI_FILE = fileURLToPath(import.meta.url);
const PACKAGE_ROOT = path.resolve(path.dirname(CLI_FILE), "..");
const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets");
const PACKAGE_VERSION = readPackageVersion();
class UsageError extends Error {}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp(args.command);
return;
}
if (args.command === "setup") return runSetup(args);
if (args.command === "status") return runStatus(args, { doctor: false });
if (args.command === "doctor") return runStatus(args, { doctor: true });
if (args.command === "update") return runUpdate(args);
if (args.command === "start") return runStart(args);
if (args.command === "stop") return runStop(args);
if (args.command === "logs") return runLogs(args);
throw new UsageError(`Unknown command: ${args.command}`);
}
async function runSetup(args) {
if (!args.setupCode) {
throw new UsageError(`Missing setup code. Use: ai-workspace-bridge setup <setup-code>`);
}
ensureNodeRuntime();
const gateway = cleanHttpEndpoint(args.gateway || process.env.NODEDC_AI_WORKSPACE_GATEWAY || DEFAULT_GATEWAY);
if (!gateway) throw new UsageError("--gateway must be an http(s) URL.");
console.log(`${SERVICE_NAME} setup`);
console.log(`Gateway: ${gateway}`);
const redeemPayload = await redeemSetupCode(gateway, args.setupCode);
const setup = normalizeSetupPayload(redeemPayload);
const bridge = setup.bridge;
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
const workspace = path.resolve(expandHome(args.workspace || bridge.workspace || process.cwd()));
const port = sanitizePort(args.port || bridge.port || 8787);
const codexHome = path.join(installRoot, "codex-home");
const paths = buildInstallPaths(installRoot);
const pathDirs = resolvePathDirs();
await mkdir(paths.logsDir, { recursive: true });
await mkdir(workspace, { recursive: true });
await copyFile(path.join(ASSETS_DIR, "worker.mjs"), paths.workerPath);
await copyFile(path.join(ASSETS_DIR, "ndcAgentMcpServer.mjs"), paths.ndcAgentMcpServerPath);
await writeStartScript(paths.startScriptPath);
await writeConfig(paths.configPath, {
service: SERVICE_NAME,
packageName: PACKAGE_NAME,
packageVersion: PACKAGE_VERSION,
agentVersion: PACKAGE_VERSION,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: BRIDGE_CAPABILITIES,
installedAt: new Date().toISOString(),
gateway,
installRoot,
workspace,
port,
codexHome,
worker: paths.workerPath,
ndcAgentMcpServer: paths.ndcAgentMcpServerPath,
startScript: paths.startScriptPath,
pidPath: paths.pidPath,
logPath: paths.logPath,
healthPath: HEALTH_PATH,
nodePath: process.execPath,
pathDirs,
hubUrl: bridge.hubUrl,
hubUrls: bridge.hubUrls,
pairingCode: bridge.pairingCode,
machineName: bridge.machineName || os.hostname(),
appMcpServers: bridge.appMcpServers,
});
if (!args.noCodexInstall) {
await ensureCodexCli({ pathDirs });
}
await setupCodexHome(codexHome, bridge.appMcpServers);
if (!args.noCodexLogin) {
await ensureCodexLogin(codexHome, { pathDirs });
}
if (!args.noAutostart) {
await installAutostart(paths.startScriptPath, installRoot).catch((error) => {
console.warn(`Autostart was not registered: ${error.message}`);
});
}
await stopBridge(installRoot).catch(() => {});
await startBridge(installRoot);
const health = await waitForHealth(port, 15000);
console.log(`${SERVICE_NAME} setup complete.`);
console.log(`Install root: ${installRoot}`);
console.log(`Workspace: ${workspace}`);
console.log(`Health: ${health.ok ? "online" : "not ready"}`);
console.log(`Run: ai-workspace-bridge doctor`);
}
async function runStatus(args, { doctor }) {
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
const config = await readLocalConfig(installRoot);
if (!config) {
console.log(`${SERVICE_NAME} is not installed at ${installRoot}`);
process.exitCode = 1;
return;
}
const health = await fetchHealth(config.port);
const codex = doctor ? inspectCodex(config.codexHome, config.pathDirs || []) : null;
console.log(`${SERVICE_NAME} status`);
console.log(`Install root: ${config.installRoot || installRoot}`);
console.log(`Workspace: ${config.workspace || ""}`);
console.log(`Port: ${config.port || ""}`);
console.log(`Agent: ${config.agentVersion || config.packageVersion || "unknown"}`);
console.log(`Protocol: ${config.protocolVersion || "unknown"}`);
if (Array.isArray(config.capabilities) && config.capabilities.length) {
console.log(`Capabilities: ${config.capabilities.join(", ")}`);
}
console.log(`Hub: ${health.ok ? "online" : "offline"}`);
if (doctor) {
console.log(`Node: ${process.version}`);
console.log(`Codex CLI: ${codex.codexOk ? codex.codexVersion || "ok" : "missing"}`);
console.log(`Codex auth: ${codex.authOk ? "present" : "missing"}`);
if (codex.error) console.log(`Codex detail: ${codex.error}`);
}
process.exitCode = health.ok && (!doctor || (codex.codexOk && codex.authOk)) ? 0 : 1;
}
async function runStart(args) {
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
await startBridge(installRoot);
console.log(`${SERVICE_NAME} started.`);
}
async function runStop(args) {
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
await stopBridge(installRoot);
console.log(`${SERVICE_NAME} stopped.`);
}
async function runLogs(args) {
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
const config = await readLocalConfig(installRoot);
const logPath = config?.logPath || path.join(installRoot, "logs", "bridge.log");
const text = await readFile(logPath, "utf8").catch(() => "");
console.log(tailLines(text, Number(args.lines || 120)));
}
async function runUpdate(args) {
ensureNodeRuntime();
const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot()));
const config = await readLocalConfig(installRoot);
if (!config) {
console.log(`${SERVICE_NAME} is not installed at ${installRoot}`);
process.exitCode = 1;
return;
}
const paths = buildInstallPaths(installRoot);
const pathDirs = resolvePathDirs();
await mkdir(paths.logsDir, { recursive: true });
await copyFile(path.join(ASSETS_DIR, "worker.mjs"), paths.workerPath);
await copyFile(path.join(ASSETS_DIR, "ndcAgentMcpServer.mjs"), paths.ndcAgentMcpServerPath);
await writeStartScript(paths.startScriptPath);
await setupCodexHome(config.codexHome || path.join(installRoot, "codex-home"), config.appMcpServers);
await writeConfig(paths.configPath, {
...config,
service: SERVICE_NAME,
packageName: PACKAGE_NAME,
packageVersion: PACKAGE_VERSION,
agentVersion: PACKAGE_VERSION,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: BRIDGE_CAPABILITIES,
updatedAt: new Date().toISOString(),
worker: paths.workerPath,
ndcAgentMcpServer: paths.ndcAgentMcpServerPath,
startScript: paths.startScriptPath,
nodePath: process.execPath,
pathDirs,
});
if (!args.noRestart) {
await stopBridge(installRoot).catch(() => {});
await startBridge(installRoot);
const health = await waitForHealth(config.port || 8787, 15000);
console.log(`${SERVICE_NAME} updated to ${PACKAGE_VERSION}.`);
console.log(`Health: ${health.ok ? "online" : "not ready"}`);
return;
}
console.log(`${SERVICE_NAME} updated to ${PACKAGE_VERSION}. Restart skipped.`);
}
function parseArgs(rawArgs) {
let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
let command = "setup";
if (["setup", "status", "doctor", "update", "start", "stop", "logs"].includes(args[0])) {
command = args[0];
args = args.slice(1);
} else if (args[0] === "help") {
return { command: "help", help: true };
}
const parsed = {
command,
gateway: "",
setupCode: "",
installRoot: "",
workspace: "",
port: "",
lines: "",
help: false,
noAutostart: false,
noCodexInstall: false,
noCodexLogin: false,
noRestart: false,
};
const positional = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
parsed.help = true;
continue;
}
if (arg === "--gateway") {
parsed.gateway = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--gateway=")) {
parsed.gateway = arg.slice("--gateway=".length);
continue;
}
if (arg === "--install-root") {
parsed.installRoot = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--install-root=")) {
parsed.installRoot = arg.slice("--install-root=".length);
continue;
}
if (arg === "--workspace") {
parsed.workspace = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--workspace=")) {
parsed.workspace = arg.slice("--workspace=".length);
continue;
}
if (arg === "--port") {
parsed.port = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--port=")) {
parsed.port = arg.slice("--port=".length);
continue;
}
if (arg === "--lines") {
parsed.lines = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--lines=")) {
parsed.lines = arg.slice("--lines=".length);
continue;
}
if (arg === "--no-autostart") {
parsed.noAutostart = true;
continue;
}
if (arg === "--no-codex-install") {
parsed.noCodexInstall = true;
continue;
}
if (arg === "--no-codex-login") {
parsed.noCodexLogin = true;
continue;
}
if (arg === "--no-restart") {
parsed.noRestart = true;
continue;
}
if (arg === "--setup-code" || arg === "--code") {
parsed.setupCode = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--setup-code=")) {
parsed.setupCode = arg.slice("--setup-code=".length);
continue;
}
if (arg.startsWith("--code=")) {
parsed.setupCode = arg.slice("--code=".length);
continue;
}
if (arg.startsWith("-")) throw new UsageError(`Unknown argument: ${arg}`);
positional.push(arg);
}
if (command === "setup") {
parsed.setupCode ||= positional.shift() || "";
}
if (positional.length) throw new UsageError(`Unexpected argument: ${positional[0]}`);
return parsed;
}
function requireValue(args, index, flag) {
const value = args[index];
if (!value || value.startsWith("--")) throw new UsageError(`Missing value for ${flag}.`);
return value;
}
function printHelp() {
console.log(`Install ${SERVICE_NAME}.
Usage:
ai-workspace-bridge setup <setup-code> [--gateway <url>] [--install-root <path>] [--workspace <path>] [--port <port>]
ai-workspace-bridge status [--install-root <path>]
ai-workspace-bridge doctor [--install-root <path>]
ai-workspace-bridge update [--install-root <path>] [--no-restart]
ai-workspace-bridge start [--install-root <path>]
ai-workspace-bridge stop [--install-root <path>]
ai-workspace-bridge logs [--install-root <path>]
AI Hub tarball form:
npx --yes --package ${DEFAULT_GATEWAY}/api/ai-workspace/bridge-package.tgz ai-workspace-bridge setup <setup-code> --gateway ${DEFAULT_GATEWAY}
`);
}
async function redeemSetupCode(gateway, setupCode) {
let response;
try {
response = await fetch(`${gateway}/api/ai-workspace/setup-codes/redeem`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ setupCode, setup_code: setupCode }),
});
} catch (error) {
throw new Error(`Setup code redeem failed: ${error.message}`);
}
const bodyText = await response.text();
let data = {};
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch {
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
}
if (!response.ok || data.ok === false) {
throw new Error(`Setup code redeem failed: ${data.error || `HTTP ${response.status}`}`);
}
return data;
}
function normalizeSetupPayload(payload) {
const setup = isPlainObject(payload?.setup) ? payload.setup : {};
const bridge = isPlainObject(setup.bridge) ? setup.bridge : {};
const pairingCode = cleanString(bridge.pairingCode || bridge.pairing_code, 80);
const hubUrl = cleanWsEndpoint(bridge.hubUrl || bridge.hub_url);
const hubUrls = uniqueStrings([
hubUrl,
...(Array.isArray(bridge.hubUrls) ? bridge.hubUrls : []),
...(Array.isArray(bridge.hub_urls) ? bridge.hub_urls : []),
].map(cleanWsEndpoint));
if (!pairingCode) throw new Error("Setup payload is missing pairing code.");
if (!hubUrls.length) throw new Error("Setup payload is missing Hub URL.");
return {
bridge: {
hubUrl: hubUrls[0],
hubUrls,
pairingCode,
machineName: cleanString(bridge.machineName || bridge.machine_name, 120) || os.hostname(),
workspace: cleanString(bridge.workspace || bridge.workspacePath || bridge.workspace_path, 1000),
port: sanitizePort(bridge.port || 8787),
appMcpServers: sanitizeMcpServers(bridge.appMcpServers || bridge.app_mcp_servers),
},
};
}
async function writeStartScript(startScriptPath) {
const source = `#!/usr/bin/env node
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
import fssync from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.join(root, "config.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
await mkdir(path.dirname(config.logPath), { recursive: true });
await writeFile(config.pidPath, String(process.pid), "utf8").catch(() => {});
let stopping = false;
let child = null;
process.on("SIGTERM", () => stop("SIGTERM"));
process.on("SIGINT", () => stop("SIGINT"));
function stop(signal) {
stopping = true;
if (child && !child.killed) {
try { child.kill(signal); } catch {}
}
setTimeout(() => process.exit(0), 1500).unref();
}
function buildEnv() {
const delimiter = process.platform === "win32" ? ";" : ":";
const pathDirs = Array.isArray(config.pathDirs) ? config.pathDirs.filter(Boolean) : [];
return {
...process.env,
CODEX_HOME: config.codexHome,
AI_BRIDGE_HOST: "0.0.0.0",
AI_BRIDGE_PORT: String(config.port || 8787),
AI_BRIDGE_CODEX_CWD: config.workspace,
AI_BRIDGE_CODEX_BIN: "codex",
AI_BRIDGE_CODEX_ARGS: ${JSON.stringify(JSON.stringify(DEFAULT_CODEX_ARGS))},
AI_BRIDGE_ALLOW_ORIGIN: "*",
AI_BRIDGE_HUB_URL: config.hubUrl || "",
AI_BRIDGE_HUB_URLS: Array.isArray(config.hubUrls) ? config.hubUrls.join(",") : "",
AI_BRIDGE_PAIRING_CODE: config.pairingCode || "",
AI_BRIDGE_MACHINE_NAME: config.machineName || "",
AI_BRIDGE_AGENT_VERSION: config.agentVersion || config.packageVersion || "",
AI_BRIDGE_PROTOCOL_VERSION: config.protocolVersion || "",
AI_BRIDGE_CAPABILITIES: Array.isArray(config.capabilities) ? config.capabilities.join(",") : "",
AI_BRIDGE_ACTIVE_MIRROR_DIR: path.join(config.workspace || root, ".nodedc", "ai-workspace"),
AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE: "1",
PATH: [...pathDirs, process.env.PATH || ""].filter(Boolean).join(delimiter),
Path: [...pathDirs, process.env.Path || process.env.PATH || ""].filter(Boolean).join(delimiter),
};
}
async function runOnce() {
const log = await open(config.logPath, "a");
try {
child = spawn(config.nodePath || process.execPath, [config.worker], {
cwd: config.installRoot || root,
env: buildEnv(),
stdio: ["ignore", log.fd, log.fd],
windowsHide: true,
});
const code = await new Promise((resolve) => {
child.on("exit", (exitCode, signal) => resolve(signal || exitCode || 0));
child.on("error", (error) => resolve(error.message));
});
child = null;
return code;
} finally {
await log.close().catch(() => {});
}
}
while (!stopping) {
const code = await runOnce();
if (stopping) break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
`;
await writeFile(startScriptPath, source, "utf8");
await chmodIfPossible(startScriptPath, 0o755);
}
async function writeConfig(configPath, config) {
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
async function setupCodexHome(codexHome, appMcpServers) {
await mkdir(codexHome, { recursive: true });
await copyIfExists(path.join(os.homedir(), ".codex", "auth.json"), path.join(codexHome, "auth.json"));
await mergeCodexMcpConfig(path.join(codexHome, "config.toml"), appMcpServers);
await installBundledSkills(codexHome);
}
async function installBundledSkills(codexHome) {
const sourceRoot = path.join(ASSETS_DIR, "skills");
const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(() => []);
if (!entries.length) return;
const targetRoot = path.join(codexHome, "skills");
await mkdir(targetRoot, { recursive: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
await cp(path.join(sourceRoot, entry.name), path.join(targetRoot, entry.name), {
recursive: true,
force: true,
});
}
}
function readPackageVersion() {
try {
const parsed = JSON.parse(fssync.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
return cleanString(parsed.version, 80) || "0.0.0";
} catch {
return "0.0.0";
}
}
async function mergeCodexMcpConfig(configPath, appMcpServers) {
const servers = sanitizeMcpServers(appMcpServers);
if (!servers.length) {
await touchFile(configPath);
return;
}
let raw = await readFile(configPath, "utf8").catch(() => "");
for (const server of servers) {
raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}]`);
raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}.headers]`);
raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}.http_headers]`);
const lines = [
`[mcp_servers.${server.serverName}]`,
`url = ${tomlString(server.url)}`,
`startup_timeout_sec = ${server.startupTimeoutSec}`,
`tool_timeout_sec = ${server.toolTimeoutSec}`,
`required = ${server.required ? "true" : "false"}`,
];
if (Object.keys(server.httpHeaders).length) {
lines.push("", `[mcp_servers.${server.serverName}.http_headers]`);
for (const [key, value] of Object.entries(server.httpHeaders)) {
lines.push(`${tomlBareKey(key)} = ${tomlString(value)}`);
}
}
raw = `${raw.trim()}\n\n${lines.join("\n")}\n`;
}
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, raw.trimStart(), "utf8");
}
function removeTomlSection(raw, header) {
const lines = String(raw || "").split(/\r?\n/);
const out = [];
let skipping = false;
const normalizedHeader = normalizeTomlHeader(header);
for (const line of lines) {
const current = normalizeTomlHeader(line);
if (current) {
skipping = current === normalizedHeader;
if (skipping) continue;
}
if (!skipping) out.push(line);
}
return out.join("\n").trimEnd();
}
function normalizeTomlHeader(line) {
const text = String(line || "").trim();
if (!text.startsWith("[") || !text.endsWith("]")) return "";
return text.replace(/\s+/g, "").replace(/["']/g, "");
}
async function ensureCodexCli({ pathDirs }) {
const before = runCommandQuiet("codex", ["--version"], { pathDirs });
if (before.ok) return;
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
console.log("Installing Codex CLI with npm...");
await runCommand(npm, ["install", "-g", "@openai/codex", "--no-audit", "--no-fund"], { pathDirs });
const after = runCommandQuiet("codex", ["--version"], { pathDirs: resolvePathDirs() });
if (!after.ok) throw new Error("Codex CLI was installed, but codex is not available in PATH.");
}
async function ensureCodexLogin(codexHome, { pathDirs }) {
if (fssync.existsSync(path.join(codexHome, "auth.json"))) return;
console.log("Codex auth is missing. Opening codex login...");
await runCommand("codex", ["login"], {
pathDirs,
env: { CODEX_HOME: codexHome },
stdio: "inherit",
}).catch((error) => {
console.warn(`Codex login did not complete: ${error.message}`);
});
}
function inspectCodex(codexHome, pathDirs) {
const version = runCommandQuiet("codex", ["--version"], { pathDirs });
return {
codexOk: version.ok,
codexVersion: version.stdout.trim(),
authOk: fssync.existsSync(path.join(codexHome || "", "auth.json")),
error: version.ok ? "" : version.error,
};
}
async function installAutostart(startScriptPath, installRoot) {
if (process.platform === "win32") return installWindowsTask(startScriptPath);
if (process.platform === "darwin") return installLaunchAgent(startScriptPath);
return installSystemdUserService(startScriptPath, installRoot);
}
async function installWindowsTask(startScriptPath) {
const taskRun = `"${process.execPath}" "${startScriptPath}"`;
runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], {});
await runCommand("schtasks", [
"/Create",
"/TN",
WINDOWS_TASK_NAME,
"/SC",
"ONLOGON",
"/TR",
taskRun,
"/RL",
"HIGHEST",
"/F",
], {});
}
async function installLaunchAgent(startScriptPath) {
const label = "ru.nodedc.ai-workspace-bridge";
const plistDir = path.join(os.homedir(), "Library", "LaunchAgents");
const plistPath = path.join(plistDir, `${label}.plist`);
await mkdir(plistDir, { recursive: true });
await writeFile(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>${label}</string>
<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(startScriptPath)}</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.out.log"))}</string>
<key>StandardErrorPath</key><string>${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.err.log"))}</string>
</dict></plist>
`, "utf8");
runCommandQuiet("launchctl", ["bootout", `gui/${process.getuid?.()}`, plistPath], {});
await runCommand("launchctl", ["bootstrap", `gui/${process.getuid?.()}`, plistPath], {});
await runCommand("launchctl", ["enable", `gui/${process.getuid?.()}/${label}`], {});
}
async function installSystemdUserService(startScriptPath, installRoot) {
const serviceDir = path.join(os.homedir(), ".config", "systemd", "user");
const servicePath = path.join(serviceDir, "nodedc-ai-workspace-bridge.service");
await mkdir(serviceDir, { recursive: true });
await writeFile(servicePath, `[Unit]
Description=NDC AI Workspace Bridge
[Service]
Type=simple
ExecStart=${process.execPath} ${startScriptPath}
Restart=always
RestartSec=5
WorkingDirectory=${installRoot}
[Install]
WantedBy=default.target
`, "utf8");
await runCommand("systemctl", ["--user", "daemon-reload"], {});
await runCommand("systemctl", ["--user", "enable", "--now", "nodedc-ai-workspace-bridge.service"], {});
}
async function startBridge(installRoot) {
const config = await readLocalConfig(installRoot);
if (!config?.startScript) throw new Error(`${SERVICE_NAME} is not installed at ${installRoot}`);
const child = spawn(config.nodePath || process.execPath, [config.startScript], {
cwd: installRoot,
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
}
async function stopBridge(installRoot) {
const config = await readLocalConfig(installRoot);
const pidPath = config?.pidPath || path.join(installRoot, "bridge.pid");
const pidText = await readFile(pidPath, "utf8").catch(() => "");
const pid = Number(pidText.trim());
if (!Number.isInteger(pid) || pid <= 0) return;
if (process.platform === "win32") {
runCommandQuiet("taskkill", ["/PID", String(pid), "/T", "/F"], {});
} else {
try {
process.kill(pid, "SIGTERM");
} catch {}
}
await rm(pidPath, { force: true }).catch(() => {});
}
async function waitForHealth(port, timeoutMs) {
const started = Date.now();
let latest = { ok: false, error: "not_checked" };
while (Date.now() - started < timeoutMs) {
latest = await fetchHealth(port);
if (latest.ok) return latest;
await sleep(700);
}
return latest;
}
async function fetchHealth(port) {
try {
const response = await fetch(`http://127.0.0.1:${sanitizePort(port)}${HEALTH_PATH}`);
const data = await response.json().catch(() => ({}));
return { ok: response.ok && data?.ok !== false, data };
} catch (error) {
return { ok: false, error: String(error?.message || error) };
}
}
async function readLocalConfig(installRoot) {
try {
return JSON.parse(await readFile(path.join(installRoot, "config.json"), "utf8"));
} catch {
return null;
}
}
function buildInstallPaths(installRoot) {
return {
logsDir: path.join(installRoot, "logs"),
logPath: path.join(installRoot, "logs", "bridge.log"),
pidPath: path.join(installRoot, "bridge.pid"),
configPath: path.join(installRoot, "config.json"),
workerPath: path.join(installRoot, "worker.mjs"),
ndcAgentMcpServerPath: path.join(installRoot, "ndcAgentMcpServer.mjs"),
startScriptPath: path.join(installRoot, "start-bridge.mjs"),
};
}
function defaultInstallRoot() {
if (process.platform === "win32") {
return path.join(process.env.LOCALAPPDATA || os.homedir(), "NDC", "AIWorkspaceBridge");
}
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support", "NDC", "AIWorkspaceBridge");
}
return path.join(os.homedir(), ".local", "share", "nodedc", "ai-workspace-bridge");
}
function resolvePathDirs() {
const dirs = [path.dirname(process.execPath)];
const prefix = runCommandQuiet(process.platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], {}).stdout.trim();
if (prefix) {
dirs.push(process.platform === "win32" ? prefix : path.join(prefix, "bin"));
}
return uniqueStrings(dirs);
}
function runCommandQuiet(command, args, options = {}) {
try {
const result = spawnSync(command, args, {
env: commandEnv(options),
encoding: "utf8",
shell: process.platform === "win32",
windowsHide: true,
});
return {
ok: result.status === 0,
stdout: result.stdout || "",
stderr: result.stderr || "",
error: result.error ? result.error.message : result.stderr || "",
};
} catch (error) {
return { ok: false, stdout: "", stderr: "", error: String(error?.message || error) };
}
}
function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: commandEnv(options),
stdio: options.stdio || "inherit",
shell: process.platform === "win32",
windowsHide: true,
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code}`));
});
});
}
function commandEnv(options = {}) {
const delimiter = process.platform === "win32" ? ";" : ":";
const pathDirs = Array.isArray(options.pathDirs) ? options.pathDirs.filter(Boolean) : [];
const basePath = process.env.PATH || process.env.Path || "";
return {
...process.env,
...(options.env || {}),
PATH: [...pathDirs, basePath].filter(Boolean).join(delimiter),
Path: [...pathDirs, process.env.Path || basePath].filter(Boolean).join(delimiter),
};
}
function ensureNodeRuntime() {
const major = Number(process.versions.node.split(".")[0]);
if (!Number.isInteger(major) || major < 22) {
throw new Error(`${SERVICE_NAME} requires Node.js 22+ for WebSocket bridge mode. Current: ${process.version}`);
}
}
function sanitizeMcpServers(value) {
const items = Array.isArray(value) ? value : [];
return items.map((item) => {
if (!isPlainObject(item)) return null;
const serverName = safeMcpServerName(item.serverName || item.server_name || item.name);
const url = cleanHttpEndpoint(item.url);
if (!serverName || !url) return null;
return {
serverName,
url,
required: item.required === true,
startupTimeoutSec: sanitizeInteger(item.startupTimeoutSec || item.startup_timeout_sec, 20, 1, 300),
toolTimeoutSec: sanitizeInteger(item.toolTimeoutSec || item.tool_timeout_sec, 60, 1, 3600),
httpHeaders: sanitizeHeaders(item.httpHeaders || item.http_headers || item.headers),
};
}).filter(Boolean);
}
function sanitizeHeaders(value) {
if (!isPlainObject(value)) return {};
const headers = {};
for (const [key, raw] of Object.entries(value)) {
const name = cleanString(key, 120);
const text = cleanString(raw, 2000);
if (!name || !text || /[\r\n\0]/.test(name) || /[\r\n\0]/.test(text)) continue;
headers[name] = text;
}
return headers;
}
function safeMcpServerName(value) {
const text = String(value || "").trim().replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80);
return text || "";
}
function cleanHttpEndpoint(value) {
const text = cleanString(value, 1000);
if (!text) return "";
try {
const url = new URL(text);
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
url.username = "";
url.password = "";
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
}
function cleanWsEndpoint(value) {
const text = cleanString(value, 1000);
if (!text) return "";
try {
const url = new URL(text);
if (url.protocol !== "ws:" && url.protocol !== "wss:") return "";
url.username = "";
url.password = "";
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
}
function sanitizePort(value) {
const port = Number(value || 8787);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError("port_invalid");
return port;
}
function sanitizeInteger(value, fallback, min, max) {
const number = Number(value || fallback);
if (!Number.isFinite(number)) return fallback;
return Math.min(Math.max(Math.trunc(number), min), max);
}
function cleanString(value, max) {
return String(value || "").trim().slice(0, max);
}
function uniqueStrings(values) {
return Array.from(new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean)));
}
function expandHome(value) {
const text = String(value || "");
if (text === "~") return os.homedir();
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2));
return text;
}
function tomlString(value) {
return JSON.stringify(String(value || ""));
}
function tomlBareKey(value) {
const key = String(value || "");
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
}
function xmlEscape(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function copyIfExists(from, to) {
try {
await stat(from);
await mkdir(path.dirname(to), { recursive: true });
await copyFile(from, to);
} catch {}
}
async function touchFile(filePath) {
await mkdir(path.dirname(filePath), { recursive: true });
if (!fssync.existsSync(filePath)) await writeFile(filePath, "", "utf8");
}
async function chmodIfPossible(filePath, mode) {
try {
await fssync.promises.chmod(filePath, mode);
} catch {}
}
function tailLines(text, count) {
return String(text || "").split(/\r?\n/).slice(-Math.max(1, count || 120)).join("\n");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isPlainObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
main().catch((error) => {
console.error(error instanceof UsageError ? error.message : `Error: ${error.message || error}`);
process.exitCode = 1;
});

View File

@ -1,29 +0,0 @@
{
"name": "@nodedc/ai-workspace-bridge",
"version": "0.1.0",
"description": "Install NODE.DC AI Workspace Bridge worker.",
"type": "module",
"bin": {
"ai-workspace-bridge": "./bin/nodedc-ai-workspace-bridge.mjs",
"nodedc-ai-workspace-bridge": "./bin/nodedc-ai-workspace-bridge.mjs"
},
"engines": {
"node": ">=22"
},
"files": [
"assets/",
"bin/",
"README.md"
],
"keywords": [
"nodedc",
"ai-workspace",
"codex",
"bridge"
],
"license": "UNLICENSED",
"private": false,
"publishConfig": {
"access": "public"
}
}

View File

@ -4,18 +4,6 @@ import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";
const SECRET_TOKEN = "secret-run-token-for-smoke";
const ACCESS_DENIED_TEXT = "Доступ к модулю ограничен, обратитесь к администратору системы.";
const RUN_APP_ID_ALIASES = new Map([
["hub", "launcher"],
["nodedc-hub", "launcher"],
["nodedc_launcher", "launcher"],
["nodedc-launcher", "launcher"],
]);
const APP_ROUTING_CATALOG = [
{ appId: "launcher", appTitle: "NODE.DC Launcher", surface: "launcher", skillId: "launcher-context", appAliases: ["hub"], actionIdPrefixes: ["hub.", "launcher.", "access."], mcpServerNames: [], deniedText: ACCESS_DENIED_TEXT },
{ appId: "engine", appTitle: "NODE.DC Engine / InJoin", surface: "engine", skillId: "engine-context", mcpServerNames: ["nodedc-engine", "nodedc-agent-core"], deniedText: ACCESS_DENIED_TEXT },
{ appId: "ops", appTitle: "NODE.DC Ops / Tasker", surface: "ops", skillId: "ops-context", mcpServerNames: ["nodedc_ops_agent"], deniedText: ACCESS_DENIED_TEXT },
];
const adapter = {
id: "ops",
@ -64,44 +52,6 @@ const assistantActions = {
actionIds: ["hub.access_request.list_pending", "hub.user.read_admin_summary"],
phases: ["preview", "execute"],
};
const appCatalog = buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions });
const appAccess = summarizeRunAppAccess(appCatalog);
const launcherAdapter = { id: "launcher", appId: "launcher", title: "NODE.DC Launcher" };
const launcherGrants = normalizeEntitlementAdapterAppGrants({
ok: true,
appGrants: {
hub: {
appTitle: "NODE.DC Launcher",
surface: "launcher",
status: "granted",
scopes: ["launcher:access:read", "launcher_admin_scope.any"],
},
},
}, launcherAdapter);
const catalogWithLauncherGrant = buildRunProfileAppCatalog({
appGrants: { ...appGrants, ...launcherGrants },
mcpServers,
assistantActions,
});
const deniedByLauncher = mergeRunAppGrants(appGrants, normalizeEntitlementAdapterAppGrants({
ok: true,
appGrants: {
ops: {
appTitle: "NODE.DC Ops",
surface: "ops",
status: "denied",
allowed: false,
reason: "launcher_service_access_denied",
mcpServers: [],
},
},
}, launcherAdapter));
const deniedMcpServers = runProfileMcpServersFromAppGrants({}, deniedByLauncher);
const explicitDeniedCatalog = buildRunProfileAppCatalog({
appGrants: deniedByLauncher,
mcpServers: deniedMcpServers,
assistantActions,
});
const runProfile = {
schemaVersion: "ai-workspace.run-profile.v1",
runId: randomUUID(),
@ -123,8 +73,6 @@ const runProfile = {
},
enabledToolPacks: ["engine", "ops", "ndc-agent-core"],
appGrants: appGrantSummary,
appCatalog,
appAccess,
toolProfile: {
schemaVersion: "ai-workspace.tool-profile.v1",
enabledToolPacks: ["engine", "ops", "ndc-agent-core"],
@ -143,10 +91,6 @@ const runProfile = {
adapters: [{ appId: "ops", status: "ok", required: true }],
},
mcpServerNames: mcpServers.map((server) => server.serverName),
appCatalogIds: appCatalog.map((app) => app.appId).sort(),
grantedAppIds: appAccess.grantedAppIds,
deniedAppIds: appAccess.deniedAppIds,
notGrantedAppIds: appAccess.notGrantedAppIds,
},
};
runProfile.diagnostics.profileHash = runProfileHash(runProfile);
@ -158,8 +102,6 @@ assert.equal(appGrants.ops.appId, "ops");
assert.deepEqual(appGrantSummary.ops.scopes, ["workspace:read", "project:read", "issue:read"]);
assert.equal(appGrantSummary.ops.hasMcpServers, true);
assert.deepEqual(appGrantSummary.ops.mcpServerNames, ["nodedc_ops_agent"]);
assert.equal(appGrantSummary.ops.status, "granted");
assert.equal(appGrantSummary.ops.denied, false);
assert.equal(mcpServers.length, 1);
assert.equal(mcpServers[0].appId, "ops");
@ -177,24 +119,6 @@ assert.equal(publicProfile.toolProfile.assistantActions.endpoint, "/api/ai-works
assert.equal(publicProfile.toolProfile.assistantActions.gatewayUrl, "https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions");
assert.equal(publicProfile.toolProfile.assistantActions.gatewayToken, "<redacted>");
assert.deepEqual(publicProfile.toolProfile.assistantActions.actionIds, ["hub.access_request.list_pending", "hub.user.read_admin_summary"]);
assert.equal(runProfile.appCatalog.find((app) => app.appId === "ops")?.status, "granted");
assert.equal(runProfile.appCatalog.find((app) => app.appId === "ops")?.denied, false);
assert.deepEqual(runProfile.appCatalog.find((app) => app.appId === "ops")?.mcpServerNames, ["nodedc_ops_agent"]);
assert.equal(runProfile.appCatalog.find((app) => app.appId === "launcher")?.status, "not-granted");
assert.equal(runProfile.appCatalog.find((app) => app.appId === "launcher")?.denied, false);
assert.equal(runProfile.appCatalog.find((app) => app.appId === "launcher")?.notGranted, true);
assert.equal(runProfile.appCatalog.find((app) => app.appId === "launcher")?.deniedText, null);
assert.equal(launcherGrants.launcher.appId, "launcher");
assert.equal(launcherGrants.hub, undefined);
assert.deepEqual(catalogWithLauncherGrant.find((app) => app.appId === "launcher")?.actionIds, ["hub.access_request.list_pending", "hub.user.read_admin_summary"]);
assert.equal(deniedByLauncher.ops.status, "denied");
assert.deepEqual(deniedByLauncher.ops.mcpServers, []);
assert.deepEqual(deniedMcpServers, []);
assert.equal(explicitDeniedCatalog.find((app) => app.appId === "ops")?.denied, true);
assert.equal(explicitDeniedCatalog.find((app) => app.appId === "ops")?.deniedText, ACCESS_DENIED_TEXT);
assert.deepEqual(runProfile.appAccess.grantedAppIds, ["ops"]);
assert.deepEqual(runProfile.appAccess.deniedAppIds, []);
assert.deepEqual(runProfile.appAccess.notGrantedAppIds, ["engine", "launcher"]);
assert.equal(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false);
assert.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/);
@ -204,11 +128,6 @@ console.log(JSON.stringify({
"adapter_grant_normalized",
"token_scoped_ops_mcp_in_run_profile",
"assistant_action_relay_in_run_profile",
"app_routing_catalog_in_run_profile",
"hub_actions_route_to_launcher",
"launcher_denial_removes_ops_mcp",
"only_explicit_denied_apps_have_standard_text",
"not_granted_apps_do_not_claim_denied_access",
"public_run_profile_redacts_mcp_headers",
"public_run_profile_redacts_assistant_action_gateway_token",
"stable_public_profile_hash",
@ -246,7 +165,7 @@ function normalizeEntitlementAdapterAppGrants(payload, currentAdapter) {
function normalizeEntitlementAdapterGrant(value, currentAdapter) {
if (!isPlainObject(value)) return null;
const appId = canonicalRunAppId(value.appId || value.app_id || currentAdapter.appId);
const appId = normalizeKey(value.appId || value.app_id || currentAdapter.appId);
if (!appId) return null;
return {
...value,
@ -258,60 +177,13 @@ function normalizeEntitlementAdapterGrant(value, currentAdapter) {
};
}
function canonicalRunAppId(value) {
const appId = normalizeKey(value);
if (!appId) return "";
return RUN_APP_ID_ALIASES.get(appId) || appId;
}
function normalizeRunAppGrantsObject(value) {
const out = {};
if (!isPlainObject(value)) return out;
for (const [key, rawGrant] of Object.entries(value)) {
if (!isPlainObject(rawGrant)) continue;
const appId = canonicalRunAppId(rawGrant.appId || rawGrant.app_id || key);
if (!appId) continue;
const existing = isPlainObject(out[appId]) ? out[appId] : {};
const grant = {
...existing,
...rawGrant,
appId,
};
grant.mcpServers = isRunAppGrantDenied(grant)
? []
: Object.hasOwn(rawGrant, "mcpServers") ? rawGrant.mcpServers : existing.mcpServers;
out[appId] = grant;
}
return out;
}
function mergeRunAppGrants(base, next) {
const out = normalizeRunAppGrantsObject(base);
for (const [rawAppId, grant] of Object.entries(normalizeRunAppGrantsObject(next))) {
const appId = canonicalRunAppId(grant.appId || rawAppId);
if (!appId) continue;
const existing = isPlainObject(out[appId]) ? out[appId] : {};
const merged = {
...existing,
...grant,
appId,
};
merged.mcpServers = isRunAppGrantDenied(merged)
? []
: Object.hasOwn(grant, "mcpServers") ? grant.mcpServers : existing.mcpServers;
out[appId] = merged;
}
return out;
}
function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) {
const servers = [];
const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {};
collectInstallerMcpServers(servers, metadata.mcpServers, {});
const grants = normalizeRunAppGrantsObject(appGrantsInput);
const grants = isPlainObject(appGrantsInput) ? appGrantsInput : {};
for (const [appId, grant] of Object.entries(grants)) {
if (!isPlainObject(grant)) continue;
if (isRunAppGrantDenied(grant)) continue;
collectInstallerMcpServers(servers, grant.mcpServers, {
appId: optionalString(grant.appId) || normalizeKey(appId),
appTitle: optionalString(grant.appTitle || grant.title),
@ -327,13 +199,12 @@ function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) {
}
function summarizeRunAppGrants(metadata) {
const grants = normalizeRunAppGrantsObject(metadata?.appGrants);
const grants = isPlainObject(metadata?.appGrants) ? metadata.appGrants : {};
const out = {};
for (const [key, value] of Object.entries(grants)) {
if (!isPlainObject(value)) continue;
const appId = canonicalRunAppId(value.appId || key);
const appId = optionalString(value.appId) || normalizeKey(key);
if (!appId) continue;
const denied = isRunAppGrantDenied(value);
const mcpServers = Array.isArray(value.mcpServers)
? value.mcpServers
: isPlainObject(value.mcpServers)
@ -343,15 +214,10 @@ function summarizeRunAppGrants(metadata) {
appId,
appTitle: optionalString(value.appTitle || value.title),
surface: optionalString(value.surface) || appId,
status: denied ? "denied" : "granted",
granted: !denied,
denied,
deniedReason: denied ? optionalString(value.reason || value.deniedReason || value.denied_reason || value.status) : null,
deniedText: denied ? optionalString(value.deniedText || value.denied_text) || ACCESS_DENIED_TEXT : null,
updatedAt: optionalString(value.updatedAt || value.updated_at),
context: redactForPublicDiagnostics(isPlainObject(value.context) ? value.context : {}),
scopes: uniqueStrings(Array.isArray(value.scopes) ? value.scopes : []),
hasMcpServers: !denied && mcpServers.length > 0,
hasMcpServers: mcpServers.length > 0,
mcpServerNames: mcpServers
.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))
.filter(Boolean),
@ -360,69 +226,6 @@ function summarizeRunAppGrants(metadata) {
return out;
}
function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) {
const grants = normalizeRunAppGrantsObject(appGrants);
const allMcpServerNames = uniqueStrings((Array.isArray(mcpServers) ? mcpServers : [])
.map((server) => server?.serverName)
.filter(Boolean));
const actionIds = Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds : [];
return APP_ROUTING_CATALOG.map((entry) => {
const grant = isPlainObject(grants[entry.appId]) ? grants[entry.appId] : null;
const grantedByLegacyMcp = !grant && entry.mcpServerNames.some((name) => allMcpServerNames.includes(name));
const denied = grant ? isRunAppGrantDenied(grant) : !grantedByLegacyMcp;
const status = grant ? (denied ? "denied" : "granted") : grantedByLegacyMcp ? "granted" : "not-granted";
return {
schemaVersion: "ai-workspace.app-route.v1",
appId: entry.appId,
appTitle: entry.appTitle,
surface: entry.surface,
skillId: entry.skillId,
status,
granted: status === "granted",
denied: status === "denied",
notGranted: status === "not-granted",
deniedText: status === "denied" ? entry.deniedText : null,
actionIds: actionIds.filter((actionId) => actionIdPrefixesForEntry(entry).some((prefix) => String(actionId || "").startsWith(prefix))),
mcpServerNames: status === "granted"
? uniqueStrings([
...entry.mcpServerNames,
...((Array.isArray(grant?.mcpServers) ? grant.mcpServers : []).map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))),
])
: [],
scopes: uniqueStrings(Array.isArray(grant?.scopes) ? grant.scopes : []),
};
});
}
function actionIdPrefixesForEntry(entry) {
return uniqueStrings([
...(Array.isArray(entry.actionIdPrefixes) ? entry.actionIdPrefixes : []),
`${entry.appId}.`,
...(Array.isArray(entry.appAliases) ? entry.appAliases.map((alias) => `${normalizeKey(alias)}.`) : []),
].filter(Boolean));
}
function summarizeRunAppAccess(appCatalog) {
const apps = Array.isArray(appCatalog) ? appCatalog : [];
return {
schemaVersion: "ai-workspace.app-access.v1",
grantedAppIds: apps.filter((app) => app?.granted === true).map((app) => app.appId).sort(),
deniedAppIds: apps.filter((app) => app?.status === "denied" || app?.denied === true).map((app) => app.appId).sort(),
notGrantedAppIds: apps.filter((app) => app?.status === "not-granted").map((app) => app.appId).sort(),
availableSkillIds: apps.filter((app) => app?.granted === true).map((app) => app.skillId).filter(Boolean).sort(),
};
}
function isRunAppGrantDenied(grant) {
if (!isPlainObject(grant)) return false;
const status = normalizeKey(grant.status || grant.state || grant.accessStatus || grant.access_status);
return grant.enabled === false ||
grant.allowed === false ||
grant.granted === false ||
grant.denied === true ||
["denied", "disabled", "blocked", "revoked", "not-granted", "not_granted", "forbidden"].includes(status);
}
function collectInstallerMcpServers(target, value, defaults = {}) {
const items = Array.isArray(value)
? value

File diff suppressed because it is too large Load Diff

View File

@ -67,8 +67,6 @@ app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInterna
});
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions));
app.post("/api/ai-workspace/setup-codes/redeem", asyncRoute(proxySetupCodeRedeem));
app.get("/api/ai-workspace/bridge-package.tgz", asyncRoute(proxyBridgePackage));
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/actions", requireInternalApi, asyncRoute(callAssistantRelayAction));
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInternalApi, asyncRoute(pollAssistantRelay));
@ -140,9 +138,6 @@ function attachAgent(socket, req) {
pairingCode,
machineName: cleanString(url.searchParams.get("machineName"), 120),
host: "",
agentVersion: "",
protocolVersion: "",
capabilities: [],
runtimeStatus: "",
runtimeError: "",
runtimeCheckedAt: "",
@ -186,9 +181,6 @@ function handleAgentMessage(agent, raw) {
if (message?.type === "hello") {
agent.machineName = cleanString(message.machineName || agent.machineName, 120);
agent.host = cleanString(message.host, 120);
agent.agentVersion = cleanString(message.agentVersion, 80);
agent.protocolVersion = cleanString(message.protocolVersion, 120);
agent.capabilities = cleanStringList(message.capabilities, 80, 80);
agent.runtimeStatus = cleanString(message.runtimeStatus, 40);
agent.runtimeError = cleanString(message.runtimeError, 1000);
agent.runtimeCheckedAt = cleanString(message.runtimeCheckedAt, 80);
@ -196,8 +188,6 @@ function handleAgentMessage(agent, raw) {
kind: "hello",
message: "Bridge agent hello received.",
cwd: cleanString(message.cwd, 500),
agentVersion: agent.agentVersion,
protocolVersion: agent.protocolVersion,
});
return;
}
@ -302,61 +292,6 @@ async function proxyAssistantActions(req, res) {
res.send(text);
}
async function proxySetupCodeRedeem(req, res) {
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
res.status(503).json({ ok: false, error: "assistant_setup_proxy_not_configured" });
return;
}
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/setup-codes/redeem`;
const upstream = await fetch(targetUrl, {
method: "POST",
redirect: "manual",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
},
body: JSON.stringify(req.body || {}),
});
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
const text = await upstream.text();
res.status(upstream.status);
res.setHeader("content-type", contentType);
res.send(text);
}
async function proxyBridgePackage(_req, res) {
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
res.status(503).json({ ok: false, error: "assistant_bridge_package_proxy_not_configured" });
return;
}
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/bridge-package.tgz`;
const upstream = await fetch(targetUrl, {
method: "GET",
redirect: "manual",
headers: {
Accept: "application/octet-stream",
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
},
});
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
const body = Buffer.from(await upstream.arrayBuffer());
res.status(upstream.status);
res.setHeader("content-type", contentType);
for (const header of [
"cache-control",
"pragma",
"expires",
"surrogate-control",
"content-disposition",
"x-ai-workspace-bridge-package-sha256",
]) {
const value = upstream.headers.get(header);
if (value) res.setHeader(header, value);
}
res.send(body);
}
async function callAssistantRelayAction(req, res) {
const relayId = cleanRelayId(req.params.relayId);
if (!relayId) {
@ -605,9 +540,6 @@ function publicAgent(agent) {
pairingCode: agent.pairingCode,
machineName: agent.machineName,
host: agent.host,
agentVersion: agent.agentVersion,
protocolVersion: agent.protocolVersion,
capabilities: agent.capabilities,
runtimeStatus: agent.runtimeStatus,
runtimeError: agent.runtimeError,
runtimeCheckedAt: agent.runtimeCheckedAt,
@ -679,20 +611,6 @@ function cleanString(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function cleanStringList(value, maxItems = 80, maxLength = 120) {
const source = Array.isArray(value)
? value
: String(value || "").split(/[\n,;]+/);
const out = [];
for (const item of source) {
const text = cleanString(item, maxLength);
if (!text || out.includes(text)) continue;
out.push(text);
if (out.length >= maxItems) break;
}
return out;
}
function sanitizeTimeoutMs(value, fallback) {
const numeric = Number(value || fallback);
if (!Number.isFinite(numeric)) return fallback;