feat: harden enterprise control plane
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { Client } from "pg";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -7,6 +7,8 @@ import process from "node:process";
|
||||
const snapshotKey = process.env.BOSS_STATE_POSTGRES_KEY?.trim() || "default";
|
||||
const defaultStateFile = process.env.BOSS_STATE_FILE || path.join(process.cwd(), "data", "boss-state.json");
|
||||
const defaultBackupDir = process.env.BOSS_STATE_BACKUP_DIR || path.join(path.dirname(defaultStateFile), "backups");
|
||||
const defaultSchemaFile = path.join(process.cwd(), "scripts", "postgres-state-schema.sql");
|
||||
const postgresTable = "boss_state_snapshots";
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
@@ -14,13 +16,16 @@ function usage() {
|
||||
"",
|
||||
"Commands:",
|
||||
" describe",
|
||||
" validate-schema [--schema <file>]",
|
||||
" backup-file --input <file> [--output <file>] [--dry-run]",
|
||||
" export-file --input <file> --output <file> [--dry-run]",
|
||||
" migrate-file-to-postgres --input <file> [--dry-run]",
|
||||
" export-postgres-backup --output <file> [--dry-run]",
|
||||
" restore-postgres-backup --input <file> [--dry-run]",
|
||||
" rollback-postgres-to-file --output <file> [--dry-run]",
|
||||
"",
|
||||
"Environment:",
|
||||
" BOSS_STATE_FILE, BOSS_DATABASE_URL, BOSS_STATE_POSTGRES_KEY, BOSS_STATE_BACKUP_DIR",
|
||||
" BOSS_STATE_FILE, BOSS_STATE_STORE, BOSS_DATABASE_URL, BOSS_STATE_POSTGRES_KEY, BOSS_STATE_BACKUP_DIR",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -43,6 +48,11 @@ function parseArgs(argv) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (item === "--schema") {
|
||||
options.schema = items[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`UNKNOWN_OPTION:${item}`);
|
||||
}
|
||||
return options;
|
||||
@@ -56,6 +66,30 @@ function timestampSegment() {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function postgresModeEnabled() {
|
||||
return process.env.BOSS_STATE_STORE?.trim().toLowerCase() === "postgres";
|
||||
}
|
||||
|
||||
function postgresConfigured() {
|
||||
return Boolean(process.env.BOSS_DATABASE_URL?.trim());
|
||||
}
|
||||
|
||||
function requirePostgresMode() {
|
||||
if (!postgresModeEnabled()) {
|
||||
throw new Error("BOSS_STATE_STORE_POSTGRES_REQUIRED");
|
||||
}
|
||||
}
|
||||
|
||||
function requirePostgresDatabaseUrl() {
|
||||
if (!postgresConfigured()) {
|
||||
throw new Error("BOSS_DATABASE_URL_REQUIRED");
|
||||
}
|
||||
}
|
||||
|
||||
function validateStateText(text, source) {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
@@ -70,6 +104,37 @@ async function readStateText(filePath) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function validatePostgresSchemaText(text, source) {
|
||||
const compact = text.replace(/\s+/g, " ").toLowerCase();
|
||||
const required = [
|
||||
[/create table if not exists boss_state_snapshots/, "table"],
|
||||
[/snapshot_key\s+text\s+primary key/, "snapshot_key_primary_key"],
|
||||
[/state\s+jsonb\s+not null/, "state_jsonb"],
|
||||
[/created_at\s+timestamptz\s+not null\s+default now\(\)/, "created_at"],
|
||||
[/updated_at\s+timestamptz\s+not null\s+default now\(\)/, "updated_at"],
|
||||
[/create index if not exists boss_state_snapshots_updated_at_idx/, "updated_at_index"],
|
||||
];
|
||||
const missing = required.filter(([pattern]) => !pattern.test(compact)).map(([, name]) => name);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`POSTGRES_SCHEMA_INVALID:${source}:${missing.join(",")}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
source,
|
||||
table: postgresTable,
|
||||
sha256: sha256(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function validatePostgresSchema(options) {
|
||||
const schema = path.resolve(options.schema || defaultSchemaFile);
|
||||
const text = await fs.readFile(schema, "utf8");
|
||||
return {
|
||||
action: "validate-schema",
|
||||
...validatePostgresSchemaText(text, schema),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePostgresSchema(client) {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS boss_state_snapshots (
|
||||
@@ -79,13 +144,17 @@ async function ensurePostgresSchema(client) {
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS boss_state_snapshots_updated_at_idx
|
||||
ON boss_state_snapshots (updated_at DESC)
|
||||
`);
|
||||
}
|
||||
|
||||
async function withPostgres(handler) {
|
||||
const connectionString = process.env.BOSS_DATABASE_URL?.trim();
|
||||
if (!connectionString) {
|
||||
throw new Error("BOSS_DATABASE_URL_REQUIRED");
|
||||
}
|
||||
requirePostgresMode();
|
||||
requirePostgresDatabaseUrl();
|
||||
const connectionString = process.env.BOSS_DATABASE_URL.trim();
|
||||
const { Client } = await import("pg");
|
||||
const client = new Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
@@ -135,8 +204,11 @@ async function exportFile(options) {
|
||||
}
|
||||
|
||||
async function migrateFileToPostgres(options) {
|
||||
requirePostgresMode();
|
||||
requirePostgresDatabaseUrl();
|
||||
const source = path.resolve(options.input || defaultStateFile);
|
||||
const text = await readStateText(source);
|
||||
const schema = await validatePostgresSchema({ schema: options.schema });
|
||||
if (!options.dryRun) {
|
||||
await withPostgres(async (client) => {
|
||||
await ensurePostgresSchema(client);
|
||||
@@ -157,11 +229,18 @@ async function migrateFileToPostgres(options) {
|
||||
dryRun: options.dryRun,
|
||||
source,
|
||||
snapshotKey,
|
||||
postgresConfigured: postgresConfigured(),
|
||||
wouldConnect: !options.dryRun,
|
||||
schemaValid: schema.ok,
|
||||
schemaSha256: schema.sha256,
|
||||
bytes: Buffer.byteLength(text),
|
||||
stateSha256: sha256(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function rollbackPostgresToFile(options) {
|
||||
requirePostgresMode();
|
||||
requirePostgresDatabaseUrl();
|
||||
const output = path.resolve(options.output || defaultStateFile);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
@@ -170,6 +249,8 @@ async function rollbackPostgresToFile(options) {
|
||||
dryRun: true,
|
||||
output,
|
||||
snapshotKey,
|
||||
postgresConfigured: postgresConfigured(),
|
||||
wouldConnect: false,
|
||||
};
|
||||
}
|
||||
const text = await withPostgres(async (client) => {
|
||||
@@ -190,6 +271,115 @@ async function rollbackPostgresToFile(options) {
|
||||
output,
|
||||
snapshotKey,
|
||||
bytes: Buffer.byteLength(text),
|
||||
stateSha256: sha256(text),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackupPayload(text, source) {
|
||||
const parsed = validateStateText(text, source);
|
||||
if (parsed.metadata?.format === "boss-state-postgres-backup/v1" && parsed.state) {
|
||||
return {
|
||||
metadata: parsed.metadata,
|
||||
state: parsed.state,
|
||||
stateText: JSON.stringify(parsed.state, null, 2),
|
||||
bundled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
metadata: null,
|
||||
state: parsed,
|
||||
stateText: JSON.stringify(parsed, null, 2),
|
||||
bundled: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportPostgresBackup(options) {
|
||||
requirePostgresMode();
|
||||
const output = path.resolve(options.output || path.join(defaultBackupDir, `boss-postgres-state-${timestampSegment()}.json`));
|
||||
if (options.dryRun) {
|
||||
requirePostgresDatabaseUrl();
|
||||
return {
|
||||
ok: true,
|
||||
action: "export-postgres-backup",
|
||||
dryRun: true,
|
||||
output,
|
||||
snapshotKey,
|
||||
postgresConfigured: true,
|
||||
wouldConnect: false,
|
||||
};
|
||||
}
|
||||
const stateText = await withPostgres(async (client) => {
|
||||
await ensurePostgresSchema(client);
|
||||
const result = await client.query("SELECT state FROM boss_state_snapshots WHERE snapshot_key = $1", [snapshotKey]);
|
||||
const state = result.rows[0]?.state;
|
||||
if (!state) {
|
||||
throw new Error("BOSS_POSTGRES_STATE_NOT_FOUND");
|
||||
}
|
||||
return JSON.stringify(state, null, 2);
|
||||
});
|
||||
const state = validateStateText(stateText, `${postgresTable}:${snapshotKey}`);
|
||||
const backup = {
|
||||
metadata: {
|
||||
format: "boss-state-postgres-backup/v1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
snapshotKey,
|
||||
table: postgresTable,
|
||||
stateSha256: sha256(stateText),
|
||||
stateBytes: Buffer.byteLength(stateText),
|
||||
},
|
||||
state,
|
||||
};
|
||||
const backupText = `${JSON.stringify(backup, null, 2)}\n`;
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.writeFile(output, backupText, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: "export-postgres-backup",
|
||||
dryRun: false,
|
||||
output,
|
||||
snapshotKey,
|
||||
bytes: Buffer.byteLength(backupText),
|
||||
stateSha256: backup.metadata.stateSha256,
|
||||
};
|
||||
}
|
||||
|
||||
async function restorePostgresBackup(options) {
|
||||
requirePostgresMode();
|
||||
requirePostgresDatabaseUrl();
|
||||
if (!options.input) {
|
||||
throw new Error("INPUT_REQUIRED");
|
||||
}
|
||||
const source = path.resolve(options.input);
|
||||
const text = await fs.readFile(source, "utf8");
|
||||
const backup = normalizeBackupPayload(text, source);
|
||||
validateStateText(backup.stateText, source);
|
||||
|
||||
if (!options.dryRun) {
|
||||
await withPostgres(async (client) => {
|
||||
await ensurePostgresSchema(client);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO boss_state_snapshots (snapshot_key, state, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (snapshot_key)
|
||||
DO UPDATE SET state = EXCLUDED.state, updated_at = now()
|
||||
`,
|
||||
[snapshotKey, backup.stateText],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: "restore-postgres-backup",
|
||||
dryRun: options.dryRun,
|
||||
source,
|
||||
snapshotKey,
|
||||
postgresConfigured: postgresConfigured(),
|
||||
wouldConnect: !options.dryRun,
|
||||
bundled: backup.bundled,
|
||||
bytes: Buffer.byteLength(backup.stateText),
|
||||
stateSha256: sha256(backup.stateText),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,14 +390,17 @@ async function main() {
|
||||
jsonOut({
|
||||
ok: true,
|
||||
action: "describe",
|
||||
mode: process.env.BOSS_STATE_STORE === "postgres" ? "postgres" : "file",
|
||||
mode: postgresModeEnabled() ? "postgres" : "file",
|
||||
stateFile: path.resolve(defaultStateFile),
|
||||
backupDir: path.resolve(defaultBackupDir),
|
||||
postgresConfigured: Boolean(process.env.BOSS_DATABASE_URL?.trim()),
|
||||
postgresTable: "boss_state_snapshots",
|
||||
postgresConfigured: postgresConfigured(),
|
||||
postgresTable,
|
||||
snapshotKey,
|
||||
});
|
||||
return;
|
||||
case "validate-schema":
|
||||
jsonOut(await validatePostgresSchema(options));
|
||||
return;
|
||||
case "backup-file":
|
||||
jsonOut(await backupFile(options));
|
||||
return;
|
||||
@@ -217,6 +410,12 @@ async function main() {
|
||||
case "migrate-file-to-postgres":
|
||||
jsonOut(await migrateFileToPostgres(options));
|
||||
return;
|
||||
case "export-postgres-backup":
|
||||
jsonOut(await exportPostgresBackup(options));
|
||||
return;
|
||||
case "restore-postgres-backup":
|
||||
jsonOut(await restorePostgresBackup(options));
|
||||
return;
|
||||
case "rollback-postgres-to-file":
|
||||
jsonOut(await rollbackPostgresToFile(options));
|
||||
return;
|
||||
|
||||
@@ -39,8 +39,53 @@ function normalizePayload(raw) {
|
||||
}
|
||||
|
||||
function extractTargetUrl(objective) {
|
||||
const match = String(objective || "").match(/https?:\/\/[^\s,。;、))]+/i);
|
||||
return match?.[0] || undefined;
|
||||
const text = String(objective || "");
|
||||
const fullUrl = text.match(/https?:\/\/[^\s,。;、))]+/i)?.[0];
|
||||
if (fullUrl) {
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
const bareDomain = text.match(
|
||||
/(?:访问|打开|进入|看一下|visit|open)?\s*((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s,。;、))]*)?)/i,
|
||||
)?.[1];
|
||||
return bareDomain ? `https://${bareDomain}` : undefined;
|
||||
}
|
||||
|
||||
function cleanupSearchQuery(value) {
|
||||
return String(value || "")
|
||||
.replace(/打开\s*(?:youtube|油管)/gi, " ")
|
||||
.replace(/(?:youtube|油管)/gi, " ")
|
||||
.replace(/用浏览器打开/gi, " ")
|
||||
.replace(/打开浏览器/gi, " ")
|
||||
.replace(/找一个|找一下|搜索|搜一下|搜|播放/gi, " ")
|
||||
.replace(/的\s*mv/gi, " MV")
|
||||
.replace(/mv/gi, " MV")
|
||||
.replace(/[,。;;、]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function deriveYouTubeSearchUrl(objective) {
|
||||
const text = String(objective || "").trim();
|
||||
if (!/(youtube|油管)/i.test(text)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queryPatterns = [
|
||||
/(?:找一个|找一下|搜索|搜一下|搜|播放)\s*([^,。;;]+)/i,
|
||||
/(?:youtube|油管)\s*([^,。;;]+)/i,
|
||||
];
|
||||
for (const pattern of queryPatterns) {
|
||||
const query = cleanupSearchQuery(text.match(pattern)?.[1]);
|
||||
if (query) {
|
||||
return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackQuery = cleanupSearchQuery(text);
|
||||
return fallbackQuery
|
||||
? `https://www.youtube.com/results?search_query=${encodeURIComponent(fallbackQuery)}`
|
||||
: "https://www.youtube.com";
|
||||
}
|
||||
|
||||
async function writeArtifact(payload) {
|
||||
@@ -84,6 +129,55 @@ function parseArgsJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function detectRequestedBrowserApp(objective) {
|
||||
const text = String(objective || "").toLowerCase();
|
||||
if (text.includes("chrome") || text.includes("谷歌浏览器")) {
|
||||
return "Google Chrome";
|
||||
}
|
||||
if (text.includes("safari")) {
|
||||
return "Safari";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBrowserOpenArgs(command, objective) {
|
||||
const configured =
|
||||
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
|
||||
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
|
||||
if (configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
const requestedApp = detectRequestedBrowserApp(objective);
|
||||
const commandName = path.basename(command || "").toLowerCase();
|
||||
return requestedApp && commandName === "open" ? ["-a", requestedApp] : [];
|
||||
}
|
||||
|
||||
function escapeAppleScriptString(value) {
|
||||
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
async function openTargetUrl(targetUrl, objective) {
|
||||
const commandOverride = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim();
|
||||
const command = commandOverride || "open";
|
||||
const requestedApp = detectRequestedBrowserApp(objective);
|
||||
|
||||
if (!commandOverride && process.platform === "darwin" && requestedApp) {
|
||||
const app = escapeAppleScriptString(requestedApp);
|
||||
const url = escapeAppleScriptString(targetUrl);
|
||||
const script = [
|
||||
`tell application "${app}" to activate`,
|
||||
`tell application "${app}" to open location "${url}"`,
|
||||
].join("\n");
|
||||
await runCommand("osascript", ["-e", script]);
|
||||
return "osascript_open_url_executed";
|
||||
}
|
||||
|
||||
const prefixArgs = resolveBrowserOpenArgs(command, objective);
|
||||
await runCommand(command, [...prefixArgs, targetUrl]);
|
||||
return "open_url_executed";
|
||||
}
|
||||
|
||||
function getBrowserAutomationMode() {
|
||||
const raw = String(process.env.BOSS_BROWSER_AUTOMATION_MODE || "").trim().toLowerCase();
|
||||
if (raw === "off" || raw === "fetch" || raw === "playwright" || raw === "auto") {
|
||||
@@ -92,6 +186,11 @@ function getBrowserAutomationMode() {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function shouldOpenVisibleBrowserAfterAutomation() {
|
||||
const raw = String(process.env.BOSS_BROWSER_VISIBLE_OPEN_AFTER_AUTOMATION || "").trim().toLowerCase();
|
||||
return !["0", "false", "off", "no"].includes(raw);
|
||||
}
|
||||
|
||||
function resolveCodexHome() {
|
||||
return String(process.env.CODEX_HOME || "").trim() || path.join(process.env.HOME || "", ".codex");
|
||||
}
|
||||
@@ -195,7 +294,7 @@ const objective =
|
||||
typeof payload.objective === "string" && payload.objective.trim()
|
||||
? payload.objective.trim()
|
||||
: "浏览器控制 smoke 链路正常";
|
||||
const targetUrl = extractTargetUrl(objective);
|
||||
const targetUrl = extractTargetUrl(objective) || deriveYouTubeSearchUrl(objective);
|
||||
const riskLevel =
|
||||
typeof payload.context?.riskLevel === "string" && payload.context.riskLevel.trim()
|
||||
? payload.context.riskLevel.trim()
|
||||
@@ -209,23 +308,27 @@ const automationMode =
|
||||
? "playwright"
|
||||
: "fetch"
|
||||
: configuredAutomationMode;
|
||||
const automatedTitle =
|
||||
targetUrl && !dryRun && automationMode === "playwright"
|
||||
? await runBrowserAutomation(targetUrl, currentRequestId)
|
||||
: undefined;
|
||||
let automationError;
|
||||
let automatedTitle;
|
||||
if (targetUrl && !dryRun && automationMode === "playwright") {
|
||||
try {
|
||||
automatedTitle = await runBrowserAutomation(targetUrl, currentRequestId);
|
||||
} catch (error) {
|
||||
automationError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
const pageTitle =
|
||||
automatedTitle ||
|
||||
(automationMode !== "off" ? await inspectPageTitle(targetUrl) : undefined);
|
||||
if (targetUrl && !dryRun) {
|
||||
if (automationMode === "playwright" && automatedTitle) {
|
||||
action = "browser_automation_executed";
|
||||
if (shouldOpenVisibleBrowserAfterAutomation()) {
|
||||
const visibleAction = await openTargetUrl(targetUrl, objective);
|
||||
action = `${action}+${visibleAction}`;
|
||||
}
|
||||
} else {
|
||||
const command = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim() || "open";
|
||||
const prefixArgs =
|
||||
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
|
||||
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
|
||||
await runCommand(command, [...prefixArgs, targetUrl]);
|
||||
action = "open_url_executed";
|
||||
action = await openTargetUrl(targetUrl, objective);
|
||||
}
|
||||
}
|
||||
const artifacts = await writeArtifact({
|
||||
@@ -246,8 +349,8 @@ writeJson({
|
||||
? `浏览器控制已完成:${objective}。页面标题:${pageTitle}`
|
||||
: `浏览器控制已完成:${objective}`,
|
||||
executionSummary: pageTitle
|
||||
? `${action} completed (risk=${riskLevel}, title=${pageTitle})`
|
||||
: `${action} completed (risk=${riskLevel})`,
|
||||
? `${action} completed (risk=${riskLevel}, title=${pageTitle}${automationError ? ", automationFallback=true" : ""})`
|
||||
: `${action} completed (risk=${riskLevel}${automationError ? ", automationFallback=true" : ""})`,
|
||||
targetUrl,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
55
scripts/build-boss-agent-mac-app.sh
Normal file → Executable file
55
scripts/build-boss-agent-mac-app.sh
Normal file → Executable file
@@ -11,6 +11,11 @@ BINARY_PATH="$MACOS_DIR/boss-agent"
|
||||
ICONSET_DIR="$RESOURCES_DIR/BossAgent.iconset"
|
||||
ICON_PATH="$RESOURCES_DIR/BossAgent.icns"
|
||||
SIGNING_IDENTITY="${BOSS_AGENT_CODESIGN_IDENTITY:-}"
|
||||
NOTARIZE="${BOSS_AGENT_NOTARIZE:-0}"
|
||||
NOTARY_PROFILE="${BOSS_AGENT_NOTARY_PROFILE:-}"
|
||||
NOTARY_APPLE_ID="${BOSS_AGENT_NOTARY_APPLE_ID:-}"
|
||||
NOTARY_TEAM_ID="${BOSS_AGENT_NOTARY_TEAM_ID:-}"
|
||||
NOTARY_PASSWORD="${BOSS_AGENT_NOTARY_PASSWORD:-}"
|
||||
|
||||
if ! command -v swiftc >/dev/null 2>&1; then
|
||||
echo "swiftc not found. Install Xcode Command Line Tools first." >&2
|
||||
@@ -23,13 +28,24 @@ if ! command -v iconutil >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
if [[ -z "$SIGNING_IDENTITY" ]] && command -v security >/dev/null 2>&1; then
|
||||
SIGNING_IDENTITY="$(
|
||||
security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/"Apple Development:|Developer ID Application:|Mac Developer:|Boss Agent/ { print $2; exit }'
|
||||
)"
|
||||
if [[ "$NOTARIZE" == "1" ]]; then
|
||||
SIGNING_IDENTITY="$(
|
||||
security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/"Developer ID Application:/ { print $2; exit }'
|
||||
)"
|
||||
else
|
||||
SIGNING_IDENTITY="$(
|
||||
security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/"Apple Development:|Developer ID Application:|Mac Developer:|Boss Agent/ { print $2; exit }'
|
||||
)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$SIGNING_IDENTITY" ]]; then
|
||||
if [[ "$NOTARIZE" == "1" ]]; then
|
||||
echo "boss-agent: BOSS_AGENT_NOTARIZE=1 requires a Developer ID Application signing identity." >&2
|
||||
exit 1
|
||||
fi
|
||||
SIGNING_IDENTITY="-"
|
||||
echo "boss-agent: no stable code signing identity found; falling back to ad-hoc signing." >&2
|
||||
else
|
||||
@@ -172,5 +188,34 @@ cat > "$CONTENTS_DIR/Info.plist" <<'PLIST'
|
||||
PLIST
|
||||
|
||||
plutil -lint "$CONTENTS_DIR/Info.plist" >/dev/null
|
||||
codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null
|
||||
if [[ "$NOTARIZE" == "1" ]]; then
|
||||
if ! command -v xcrun >/dev/null 2>&1; then
|
||||
echo "boss-agent: xcrun is required for notarization." >&2
|
||||
exit 1
|
||||
fi
|
||||
codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null
|
||||
|
||||
NOTARY_ZIP="$ROOT_DIR/dist/boss-agent-notary.zip"
|
||||
rm -f "$NOTARY_ZIP"
|
||||
(
|
||||
cd "$ROOT_DIR/dist"
|
||||
ditto -c -k --keepParent "boss-agent.app" "$NOTARY_ZIP"
|
||||
)
|
||||
|
||||
NOTARY_ARGS=()
|
||||
if [[ -n "$NOTARY_PROFILE" ]]; then
|
||||
NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE")
|
||||
elif [[ -n "$NOTARY_APPLE_ID" && -n "$NOTARY_TEAM_ID" && -n "$NOTARY_PASSWORD" ]]; then
|
||||
NOTARY_ARGS=(--apple-id "$NOTARY_APPLE_ID" --team-id "$NOTARY_TEAM_ID" --password "$NOTARY_PASSWORD")
|
||||
else
|
||||
echo "boss-agent: notarization requires BOSS_AGENT_NOTARY_PROFILE or Apple ID/team/password env vars." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcrun notarytool submit "$NOTARY_ZIP" "${NOTARY_ARGS[@]}" --wait >/dev/null
|
||||
xcrun stapler staple "$APP_DIR" >/dev/null
|
||||
rm -f "$NOTARY_ZIP"
|
||||
else
|
||||
codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null
|
||||
fi
|
||||
echo "$APP_DIR"
|
||||
|
||||
178
scripts/codex-computer-use-runtime.mjs
Normal file
178
scripts/codex-computer-use-runtime.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
executeCodexAppServerTask,
|
||||
getCodexAppServerRunnerConfig,
|
||||
} from "../local-agent/codex-app-server-runner.mjs";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function parseArgs(value) {
|
||||
const args = String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
return args.length > 0 ? args : undefined;
|
||||
}
|
||||
|
||||
function parseArgsJson(value) {
|
||||
const raw = normalizeText(value);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function writeJson(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||||
}
|
||||
return chunks.join("").trim();
|
||||
}
|
||||
|
||||
function parseJsonPayload(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "{}"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function detectTargetApp(objective) {
|
||||
const text = normalizeText(objective).toLowerCase();
|
||||
const candidates = [
|
||||
["Codex", ["codex"]],
|
||||
["Google Chrome", ["chrome", "google chrome", "谷歌"]],
|
||||
["Safari", ["safari"]],
|
||||
["QQ", ["qq"]],
|
||||
["微信", ["微信", "wechat"]],
|
||||
["飞书", ["飞书", "lark", "feishu"]],
|
||||
["Telegram", ["telegram", "tg"]],
|
||||
["Finder", ["finder", "访达"]],
|
||||
["系统设置", ["系统设置", "system settings", "settings"]],
|
||||
];
|
||||
const matched = candidates.find(([, aliases]) => aliases.some((alias) => text.includes(alias)));
|
||||
return matched?.[0];
|
||||
}
|
||||
|
||||
function buildComputerUsePrompt(payload) {
|
||||
const objective = normalizeText(payload.objective);
|
||||
const targetApp = detectTargetApp(objective);
|
||||
return [
|
||||
"你是 Boss 的 Codex Computer Use 执行器,正在被要求控制当前这台 macOS 电脑。",
|
||||
"请优先使用 Codex 自带的 Computer Use / Browser / Desktop 能力完成用户目标。",
|
||||
"只执行当前目标直接需要的动作;不要扩展需求,不要改动无关文件。",
|
||||
"遇到发送、提交、删除、支付、授权等高风险动作时,必须停下来要求用户确认,不要静默点击。",
|
||||
targetApp ? `目标应用:${targetApp}` : "目标应用:如果用户没有明确说明,请先根据目标判断最小必要应用。",
|
||||
`用户目标:${objective}`,
|
||||
"完成后用中文返回简短小结,说明已做的动作、结果和是否需要用户下一步确认。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildRunnerConfig(env, payload) {
|
||||
const cwd =
|
||||
normalizeText(env.BOSS_CODEX_COMPUTER_USE_WORKDIR) ||
|
||||
normalizeText(payload?.context?.projectCwd) ||
|
||||
process.cwd();
|
||||
return getCodexAppServerRunnerConfig(env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: normalizeText(env.BOSS_CODEX_COMPUTER_USE_CODEX_COMMAND) || "codex",
|
||||
codexAppServerArgs:
|
||||
parseArgsJson(env.BOSS_CODEX_COMPUTER_USE_CODEX_ARGS_JSON) ??
|
||||
parseArgs(env.BOSS_CODEX_COMPUTER_USE_CODEX_ARGS) ??
|
||||
["app-server"],
|
||||
codexAppServerWorkdir: cwd,
|
||||
codexAppServerTimeoutMs: parseTimeoutMs(env.BOSS_CODEX_COMPUTER_USE_TIMEOUT_MS),
|
||||
codexAppServerClientName: "boss_codex_computer_use",
|
||||
codexAppServerClientTitle: "Boss Codex Computer Use",
|
||||
codexAppServerClientVersion: "0.1.0",
|
||||
masterAgentWorkdir: cwd,
|
||||
masterAgentModel: normalizeText(env.BOSS_CODEX_COMPUTER_USE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCodexComputerUseTask(payload, options = {}) {
|
||||
const env = options.env || process.env;
|
||||
const requestId = normalizeText(payload?.requestId);
|
||||
const objective = normalizeText(payload?.objective);
|
||||
if (!objective) {
|
||||
return {
|
||||
status: "failed",
|
||||
requestId: requestId || undefined,
|
||||
error: "CODEX_COMPUTER_USE_OBJECTIVE_REQUIRED",
|
||||
computerUseProvider: "codex-computer-use",
|
||||
};
|
||||
}
|
||||
|
||||
const runnerConfig = buildRunnerConfig(env, payload);
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: requestId || "codex-computer-use",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: normalizeText(payload?.context?.codexComputerUseThreadId),
|
||||
targetCodexFolderRef: runnerConfig.cwd,
|
||||
executionPrompt: buildComputerUsePrompt(payload),
|
||||
});
|
||||
|
||||
if (result.status !== "completed") {
|
||||
return {
|
||||
status: "failed",
|
||||
requestId: requestId || undefined,
|
||||
error: result.errorMessage || "CODEX_COMPUTER_USE_FAILED",
|
||||
detail: result.stderr,
|
||||
computerUseProvider: "codex-computer-use",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "completed",
|
||||
requestId: requestId || undefined,
|
||||
replyBody: result.replyBody || "Codex Computer Use 已完成本轮桌面控制任务。",
|
||||
targetApp: detectTargetApp(objective),
|
||||
executionSummary: "codex app-server computer use",
|
||||
computerUseProvider: "codex-computer-use",
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const raw = await readStdin();
|
||||
const payload = parseJsonPayload(raw);
|
||||
const result = await runCodexComputerUseTask(payload, {
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
writeJson(result);
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(currentFile)) {
|
||||
main().catch((error) => {
|
||||
writeJson({
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
computerUseProvider: "codex-computer-use",
|
||||
});
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -64,6 +64,26 @@ function detectTargetApp(objective) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePlatformAppName(targetApp) {
|
||||
if (process.platform === "darwin" && targetApp === "Chrome") {
|
||||
return "Google Chrome";
|
||||
}
|
||||
return targetApp;
|
||||
}
|
||||
|
||||
function isBrowserApp(targetApp) {
|
||||
return ["Chrome", "Google Chrome", "Safari"].includes(String(targetApp || ""));
|
||||
}
|
||||
|
||||
function extractTargetUrl(objective) {
|
||||
const text = String(objective || "");
|
||||
const quotedText = extractQuotedText(text);
|
||||
if (/^https?:\/\//i.test(String(quotedText || ""))) {
|
||||
return quotedText;
|
||||
}
|
||||
return text.match(/https?:\/\/[^\s,。;、))"”]+/i)?.[0];
|
||||
}
|
||||
|
||||
function detectDesktopAction(objective) {
|
||||
const text = String(objective || "").toLowerCase();
|
||||
if (text.includes("系统设置") || text.includes("settings")) {
|
||||
@@ -342,6 +362,25 @@ async function runAppleScript(targetApp, objective) {
|
||||
return `osascript activated ${targetApp}`;
|
||||
}
|
||||
|
||||
async function runOpenApp(targetApp) {
|
||||
const command = String(process.env.BOSS_COMPUTER_USE_OPEN_APP_COMMAND || "").trim() || "open";
|
||||
const prefixArgs = resolveOpenAppPrefixArgs(command);
|
||||
await runCommand(command, [...prefixArgs, targetApp]);
|
||||
return `open activated ${targetApp}`;
|
||||
}
|
||||
|
||||
async function runOpenBrowserUrl(targetApp, targetUrl) {
|
||||
const command = String(process.env.BOSS_COMPUTER_USE_OPEN_APP_COMMAND || "").trim() || "open";
|
||||
const prefixArgs = resolveOpenAppPrefixArgs(command);
|
||||
const commandName = path.basename(command || "").toLowerCase();
|
||||
const args =
|
||||
commandName === "open" || prefixArgs.includes("-a")
|
||||
? [...prefixArgs, targetApp, targetUrl]
|
||||
: [...prefixArgs, targetUrl];
|
||||
await runCommand(command, args);
|
||||
return `open url in ${targetApp}`;
|
||||
}
|
||||
|
||||
const raw = await readStdin();
|
||||
const normalized = normalizePayload(raw);
|
||||
|
||||
@@ -359,6 +398,8 @@ const objective =
|
||||
? payload.objective.trim()
|
||||
: "桌面控制 smoke 链路正常";
|
||||
const targetApp = detectTargetApp(objective);
|
||||
const automationTargetApp = resolvePlatformAppName(targetApp);
|
||||
const targetUrl = extractTargetUrl(objective);
|
||||
const desktopAction = detectDesktopAction(objective);
|
||||
const riskLevel =
|
||||
typeof payload.context?.riskLevel === "string" && payload.context.riskLevel.trim()
|
||||
@@ -387,13 +428,14 @@ const configuredMode = getDesktopAutomationMode();
|
||||
const automationMode =
|
||||
configuredMode === "auto" ? (process.platform === "darwin" ? "osascript" : "open") : configuredMode;
|
||||
if (targetApp && !dryRun) {
|
||||
if (automationMode === "osascript") {
|
||||
await runAppleScript(targetApp, objective);
|
||||
if (targetUrl && isBrowserApp(automationTargetApp)) {
|
||||
await runOpenBrowserUrl(automationTargetApp, targetUrl);
|
||||
action = `${desktopAction}_url_executed`;
|
||||
} else if (automationMode === "osascript") {
|
||||
await runAppleScript(automationTargetApp, objective);
|
||||
action = `${desktopAction}_executed`;
|
||||
} else if (automationMode !== "off") {
|
||||
const command = String(process.env.BOSS_COMPUTER_USE_OPEN_APP_COMMAND || "").trim() || "open";
|
||||
const prefixArgs = resolveOpenAppPrefixArgs(command);
|
||||
await runCommand(command, [...prefixArgs, targetApp]);
|
||||
await runOpenApp(automationTargetApp);
|
||||
action = `${desktopAction}_executed`;
|
||||
}
|
||||
}
|
||||
@@ -404,6 +446,8 @@ const artifacts = await writeArtifact({
|
||||
action,
|
||||
objective,
|
||||
targetApp,
|
||||
automationTargetApp,
|
||||
targetUrl,
|
||||
typedText: extractQuotedText(objective),
|
||||
dryRun,
|
||||
riskLevel,
|
||||
@@ -420,6 +464,8 @@ writeJson({
|
||||
dialogGuardState.decision?.disposition ? `, dialogGuard=${dialogGuardState.decision.disposition}` : ""
|
||||
})`,
|
||||
targetApp,
|
||||
automationTargetApp,
|
||||
targetUrl,
|
||||
typedText: extractQuotedText(objective),
|
||||
dialogGuard: dialogGuardState.decision
|
||||
? {
|
||||
|
||||
528
scripts/cua-driver-computer-use-runtime.mjs
Executable file
528
scripts/cua-driver-computer-use-runtime.mjs
Executable file
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_CUA_TIMEOUT_MS = 45000;
|
||||
|
||||
const TARGET_APPS = [
|
||||
{
|
||||
label: "Google Chrome",
|
||||
name: "Google Chrome",
|
||||
bundleId: "com.google.Chrome",
|
||||
browser: true,
|
||||
aliases: ["chrome", "google chrome", "谷歌浏览器", "谷歌"],
|
||||
},
|
||||
{
|
||||
label: "Safari",
|
||||
name: "Safari",
|
||||
bundleId: "com.apple.Safari",
|
||||
browser: true,
|
||||
aliases: ["safari"],
|
||||
},
|
||||
{
|
||||
label: "QQ",
|
||||
name: "QQ",
|
||||
aliases: ["qq"],
|
||||
},
|
||||
{
|
||||
label: "微信",
|
||||
name: "微信",
|
||||
aliases: ["微信", "wechat"],
|
||||
},
|
||||
{
|
||||
label: "飞书",
|
||||
name: "飞书",
|
||||
aliases: ["飞书", "lark", "feishu"],
|
||||
},
|
||||
{
|
||||
label: "Telegram",
|
||||
name: "Telegram",
|
||||
aliases: ["telegram", "tg"],
|
||||
},
|
||||
{
|
||||
label: "Finder",
|
||||
name: "Finder",
|
||||
bundleId: "com.apple.finder",
|
||||
aliases: ["finder", "访达"],
|
||||
},
|
||||
{
|
||||
label: "系统设置",
|
||||
name: "System Settings",
|
||||
bundleId: "com.apple.systempreferences",
|
||||
aliases: ["系统设置", "system settings", "settings"],
|
||||
},
|
||||
{
|
||||
label: "终端",
|
||||
name: "Terminal",
|
||||
bundleId: "com.apple.Terminal",
|
||||
aliases: ["terminal", "终端"],
|
||||
},
|
||||
{
|
||||
label: "Codex",
|
||||
name: "Codex",
|
||||
aliases: ["codex"],
|
||||
},
|
||||
];
|
||||
|
||||
function writeJson(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||||
}
|
||||
return chunks.join("").trim();
|
||||
}
|
||||
|
||||
function parseJsonPayload(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "{}"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseArgsJson(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CUA_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function normalizePlatform(value) {
|
||||
const platform = normalizeText(value).toLowerCase();
|
||||
return !platform || platform === "macos" || platform === "darwin" ? "macos" : platform;
|
||||
}
|
||||
|
||||
function normalizeProvider(value) {
|
||||
const provider = normalizeText(value);
|
||||
return provider || "cua-driver-computer-use";
|
||||
}
|
||||
|
||||
export function detectCuaTargetApp(objective) {
|
||||
const text = normalizeText(objective).toLowerCase();
|
||||
if (!text) return undefined;
|
||||
return TARGET_APPS.find((candidate) =>
|
||||
candidate.aliases.some((alias) => text.includes(alias.toLowerCase())),
|
||||
);
|
||||
}
|
||||
|
||||
function extractTargetUrl(objective) {
|
||||
const text = normalizeText(objective);
|
||||
return text.match(/https?:\/\/[^\s,。;、))"”]+/i)?.[0];
|
||||
}
|
||||
|
||||
function extractQuotedText(objective) {
|
||||
const text = normalizeText(objective);
|
||||
const patterns = [
|
||||
/[“"]([^“”"]+)[”"]/,
|
||||
/[「『]([^」』]+)[」』]/,
|
||||
/输入[::]\s*([^\n。;;]+)/,
|
||||
/打字[::]\s*([^\n。;;]+)/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
const value = match?.[1]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSubmitLikeObjective(objective) {
|
||||
const text = normalizeText(objective).toLowerCase();
|
||||
return [
|
||||
"发送",
|
||||
"提交",
|
||||
"发出去",
|
||||
"回车发送",
|
||||
"删除",
|
||||
"购买",
|
||||
"下单",
|
||||
"支付",
|
||||
"转账",
|
||||
"send",
|
||||
"submit",
|
||||
"delete",
|
||||
"purchase",
|
||||
"pay",
|
||||
].some((keyword) => text.includes(keyword));
|
||||
}
|
||||
|
||||
function isSubmitAllowed(env, payload) {
|
||||
if (String(env.BOSS_CUA_ALLOW_SUBMIT || "").trim().toLowerCase() === "true") {
|
||||
return true;
|
||||
}
|
||||
return payload?.context?.desktopActionConfirmed === true || payload?.desktopActionConfirmed === true;
|
||||
}
|
||||
|
||||
export function buildCuaLaunchArgs(targetApp, objective) {
|
||||
if (!targetApp) return {};
|
||||
const launchArgs = targetApp.bundleId ? { bundle_id: targetApp.bundleId } : { name: targetApp.name };
|
||||
const url = extractTargetUrl(objective);
|
||||
if (targetApp.browser) {
|
||||
launchArgs.urls = [url || "about:blank"];
|
||||
} else if (url) {
|
||||
launchArgs.urls = [url];
|
||||
}
|
||||
return launchArgs;
|
||||
}
|
||||
|
||||
function selectWindow(windows) {
|
||||
const candidates = Array.isArray(windows) ? windows : [];
|
||||
return (
|
||||
candidates.find((window) => window?.is_on_screen === true && window?.on_current_space !== false) ||
|
||||
candidates.find((window) => window?.on_current_space !== false) ||
|
||||
candidates[0]
|
||||
);
|
||||
}
|
||||
|
||||
function getPid(launchResult) {
|
||||
const value = launchResult?.structured?.pid ?? launchResult?.structured?.process_id ?? launchResult?.pid;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function getWindowId(window) {
|
||||
const value = window?.window_id ?? window?.id;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function extractTextContent(parsed, raw) {
|
||||
if (Array.isArray(parsed?.content)) {
|
||||
return parsed.content
|
||||
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
if (typeof parsed?.text === "string") return parsed.text.trim();
|
||||
if (typeof raw === "string") return raw.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeCuaToolOutput(rawOutput) {
|
||||
const raw = String(rawOutput || "").trim();
|
||||
if (!raw) {
|
||||
return { raw: "", text: "", structured: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const structured =
|
||||
parsed?.structuredContent && typeof parsed.structuredContent === "object"
|
||||
? parsed.structuredContent
|
||||
: parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: {};
|
||||
return {
|
||||
raw,
|
||||
text: extractTextContent(parsed, raw),
|
||||
structured,
|
||||
isError: parsed?.isError === true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
raw,
|
||||
text: raw,
|
||||
structured: {},
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildExecutableCandidates(command, env, cwd) {
|
||||
const normalizedCommand = normalizeText(command);
|
||||
if (!normalizedCommand) return [];
|
||||
if (normalizedCommand.includes("/") || path.isAbsolute(normalizedCommand)) {
|
||||
return [path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd || process.cwd(), normalizedCommand)];
|
||||
}
|
||||
|
||||
const pathCandidates = String(env.PATH || "")
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean)
|
||||
.map((item) => path.join(item, normalizedCommand));
|
||||
const home = normalizeText(env.HOME);
|
||||
return [
|
||||
...pathCandidates,
|
||||
home ? path.join(home, ".local", "bin", normalizedCommand) : undefined,
|
||||
path.join("/usr/local/bin", normalizedCommand),
|
||||
path.join("/opt/homebrew/bin", normalizedCommand),
|
||||
normalizedCommand === "cua-driver" ? "/Applications/CuaDriver.app/Contents/MacOS/cua-driver" : undefined,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
async function resolveExecutableCommand(command, env, cwd) {
|
||||
for (const candidate of buildExecutableCandidates(command, env, cwd)) {
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try the next well-known install location.
|
||||
}
|
||||
}
|
||||
throw new Error("CUA_DRIVER_COMMAND_NOT_FOUND");
|
||||
}
|
||||
|
||||
async function callCuaTool(toolName, args, options) {
|
||||
const env = options.env || process.env;
|
||||
const command = await resolveExecutableCommand(
|
||||
normalizeText(env.BOSS_CUA_DRIVER_COMMAND) || "cua-driver",
|
||||
env,
|
||||
options.cwd || process.cwd(),
|
||||
);
|
||||
const prefixArgs = parseArgsJson(env.BOSS_CUA_DRIVER_ARGS_JSON) ?? parseArgs(env.BOSS_CUA_DRIVER_ARGS);
|
||||
const timeoutMs = parseTimeoutMs(env.BOSS_CUA_DRIVER_TIMEOUT_MS);
|
||||
const childArgs = [...prefixArgs, "call", toolName, JSON.stringify(args || {}), "--raw", "--compact"];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, childArgs, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
if (error?.code === "ENOENT") {
|
||||
reject(new Error("CUA_DRIVER_COMMAND_NOT_FOUND"));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
reject(new Error("CUA_DRIVER_TIMEOUT"));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
const detail = stderr.trim() || stdout.trim() || `cua-driver exit code ${code}`;
|
||||
reject(new Error(`CUA_DRIVER_TOOL_FAILED: ${toolName}: ${detail}`));
|
||||
return;
|
||||
}
|
||||
const result = normalizeCuaToolOutput(stdout);
|
||||
if (result.isError) {
|
||||
reject(new Error(`CUA_DRIVER_TOOL_ERROR: ${toolName}: ${result.text || result.raw}`));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function matchesTargetApp(app, targetApp) {
|
||||
const bundleId = normalizeText(app?.bundle_id).toLowerCase();
|
||||
const name = normalizeText(app?.name).toLowerCase();
|
||||
const targetBundleId = normalizeText(targetApp?.bundleId).toLowerCase();
|
||||
const targetName = normalizeText(targetApp?.name).toLowerCase();
|
||||
const targetLabel = normalizeText(targetApp?.label).toLowerCase();
|
||||
if (targetBundleId && bundleId === targetBundleId) return true;
|
||||
if (targetName && name === targetName) return true;
|
||||
if (targetLabel && name === targetLabel) return true;
|
||||
return targetApp?.aliases?.some((alias) => name.includes(alias.toLowerCase())) === true;
|
||||
}
|
||||
|
||||
function selectRunningApp(apps, targetApp) {
|
||||
const candidates = Array.isArray(apps) ? apps : [];
|
||||
return candidates.find((app) => app?.running === true && matchesTargetApp(app, targetApp));
|
||||
}
|
||||
|
||||
async function resolveTargetAppSession(targetApp, objective, options, toolTrace) {
|
||||
try {
|
||||
const launchResult = await callCuaTool("launch_app", buildCuaLaunchArgs(targetApp, objective), options);
|
||||
toolTrace.push("launch_app");
|
||||
const pid = getPid(launchResult);
|
||||
return {
|
||||
pid,
|
||||
window: selectWindow(launchResult.structured?.windows),
|
||||
sourceText: launchResult.text || launchResult.raw,
|
||||
};
|
||||
} catch (error) {
|
||||
toolTrace.push("launch_app_failed");
|
||||
const appsResult = await callCuaTool("list_apps", {}, options);
|
||||
toolTrace.push("list_apps");
|
||||
const runningApp = selectRunningApp(appsResult.structured?.apps, targetApp);
|
||||
const pid = getPid({ structured: runningApp });
|
||||
if (!pid) {
|
||||
throw error;
|
||||
}
|
||||
const windowsResult = await callCuaTool("list_windows", { pid }, options);
|
||||
toolTrace.push("list_windows");
|
||||
return {
|
||||
pid,
|
||||
window: selectWindow(windowsResult.structured?.windows),
|
||||
sourceText: windowsResult.text || appsResult.text || error?.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfirmationResult(payload, targetApp) {
|
||||
return {
|
||||
status: "needs_user_action",
|
||||
requestId: normalizeText(payload.requestId) || undefined,
|
||||
kind: "desktop_submit_confirmation_required",
|
||||
risk: "high",
|
||||
summary: "这条指令会在桌面应用里发送、提交或删除内容,需要你先确认。",
|
||||
recommendedAction: "allow_once",
|
||||
availableActions: ["allow_once", "deny"],
|
||||
platform: "macos",
|
||||
appName: targetApp?.label || targetApp?.name,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailure(requestId, error, detail) {
|
||||
return {
|
||||
status: "failed",
|
||||
requestId: normalizeText(requestId) || undefined,
|
||||
error,
|
||||
detail: normalizeText(detail) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCuaDriverComputerUseTask(payload, options = {}) {
|
||||
const env = options.env || process.env;
|
||||
const requestId = normalizeText(payload?.requestId);
|
||||
const objective = normalizeText(payload?.objective);
|
||||
const platform = normalizePlatform(payload?.platform || payload?.context?.controlPlatform);
|
||||
const provider = normalizeProvider(payload?.provider || payload?.context?.computerUseProvider);
|
||||
|
||||
if (platform !== "macos") {
|
||||
return buildFailure(requestId, "UNSUPPORTED_CONTROL_PLATFORM");
|
||||
}
|
||||
if (provider !== "cua-driver-computer-use") {
|
||||
return buildFailure(requestId, "UNSUPPORTED_COMPUTER_USE_PROVIDER");
|
||||
}
|
||||
if (!objective) {
|
||||
return buildFailure(requestId, "CUA_OBJECTIVE_REQUIRED");
|
||||
}
|
||||
|
||||
const targetApp = detectCuaTargetApp(objective);
|
||||
if (!targetApp) {
|
||||
return buildFailure(
|
||||
requestId,
|
||||
"CUA_TARGET_APP_REQUIRED",
|
||||
"请在指令里明确要控制的 macOS 应用,例如 Chrome、Safari、QQ、微信、Finder 或系统设置。",
|
||||
);
|
||||
}
|
||||
|
||||
if (isSubmitLikeObjective(objective) && !isSubmitAllowed(env, payload)) {
|
||||
return buildConfirmationResult(payload, targetApp);
|
||||
}
|
||||
|
||||
const toolTrace = [];
|
||||
try {
|
||||
const targetSession = await resolveTargetAppSession(targetApp, objective, {
|
||||
...options,
|
||||
env,
|
||||
}, toolTrace);
|
||||
|
||||
const pid = targetSession.pid;
|
||||
if (!pid) {
|
||||
return buildFailure(requestId, "CUA_TARGET_PID_NOT_FOUND", targetSession.sourceText);
|
||||
}
|
||||
|
||||
let window = targetSession.window;
|
||||
if (!window) {
|
||||
const windowsResult = await callCuaTool("list_windows", { pid }, { ...options, env });
|
||||
toolTrace.push("list_windows");
|
||||
window = selectWindow(windowsResult.structured?.windows);
|
||||
}
|
||||
const windowId = getWindowId(window);
|
||||
if (!windowId) {
|
||||
return buildFailure(requestId, "CUA_TARGET_WINDOW_NOT_FOUND", targetSession.sourceText);
|
||||
}
|
||||
|
||||
const beforeState = await callCuaTool("get_window_state", { pid, window_id: windowId }, { ...options, env });
|
||||
toolTrace.push("get_window_state");
|
||||
|
||||
const typedText = extractQuotedText(objective);
|
||||
if (typedText) {
|
||||
await callCuaTool("type_text", { pid, window_id: windowId, text: typedText, delay_ms: 20 }, { ...options, env });
|
||||
toolTrace.push("type_text");
|
||||
if (isSubmitLikeObjective(objective) && isSubmitAllowed(env, payload)) {
|
||||
await callCuaTool("press_key", { pid, window_id: windowId, key: "return" }, { ...options, env });
|
||||
toolTrace.push("press_key");
|
||||
}
|
||||
await callCuaTool("get_window_state", { pid, window_id: windowId }, { ...options, env });
|
||||
toolTrace.push("get_window_state");
|
||||
}
|
||||
|
||||
const observation = beforeState.text ? `窗口观测:${beforeState.text.split(/\r?\n/)[0]}` : "已完成窗口观测。";
|
||||
const actionSummary = typedText ? `并已向目标应用写入 ${typedText.length} 个字符。` : "已打开并读取目标窗口。";
|
||||
return {
|
||||
status: "completed",
|
||||
requestId: requestId || undefined,
|
||||
replyBody: `已通过 Cua Driver 接入 ${targetApp.label},${actionSummary}${observation}`,
|
||||
targetApp: targetApp.label,
|
||||
executionSummary: toolTrace.join(" -> "),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildFailure(requestId, error?.message || "CUA_DRIVER_EXECUTION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const raw = await readStdin();
|
||||
const payload = parseJsonPayload(raw);
|
||||
const result = await runCuaDriverComputerUseTask(payload, {
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
writeJson(result);
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
|
||||
main().catch((error) => {
|
||||
writeJson({
|
||||
status: "failed",
|
||||
error: error?.message || "CUA_DRIVER_RUNTIME_FAILED",
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,32 +1,118 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
PLIST_SOURCE="/Users/kris/code/boss/deployment/launchd/com.hyzq.boss.local-agent.plist"
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PLIST_SOURCE="$ROOT_DIR/deployment/launchd/com.hyzq.boss.local-agent.plist"
|
||||
PLIST_TARGET="$HOME/Library/LaunchAgents/com.hyzq.boss.local-agent.plist"
|
||||
BRIDGE_PLIST_SOURCE="/Users/kris/code/boss/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist"
|
||||
BRIDGE_PLIST_SOURCE="$ROOT_DIR/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist"
|
||||
BRIDGE_PLIST_TARGET="$HOME/Library/LaunchAgents/com.hyzq.boss.codex-desktop-bridge.plist"
|
||||
CONFIG_PATH="${1:-/Users/kris/code/boss/local-agent/config.cloud.json}"
|
||||
CONFIG_SOURCE_ARG="${1:-}"
|
||||
|
||||
if [[ "$CONFIG_PATH" != /* ]]; then
|
||||
CONFIG_PATH="/Users/kris/code/boss/${CONFIG_PATH}"
|
||||
config_has_device_identity() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
try:
|
||||
config = json.loads(Path(sys.argv[1]).read_text())
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if config.get("deviceId") and config.get("token") else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_default_config_source() {
|
||||
local ACTIVE_CONFIG_PATH=""
|
||||
local default_config_path="$ROOT_DIR/local-agent/config.cloud.json"
|
||||
|
||||
if [[ -f "$PLIST_TARGET" ]]; then
|
||||
ACTIVE_CONFIG_PATH="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:2' "$PLIST_TARGET" 2>/dev/null || true)"
|
||||
if [[ -n "$ACTIVE_CONFIG_PATH" && "$ACTIVE_CONFIG_PATH" == "$ROOT_DIR/local-agent/"*.json && -f "$ACTIVE_CONFIG_PATH" ]]; then
|
||||
printf '%s\n' "$ACTIVE_CONFIG_PATH"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local custom_config=""
|
||||
local custom_name=""
|
||||
for custom_config in "$ROOT_DIR"/local-agent/config*.json(N); do
|
||||
custom_name="$(basename "$custom_config")"
|
||||
case "$custom_name" in
|
||||
config.installed.json|config.cloud.json|config.example.json)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if config_has_device_identity "$custom_config"; then
|
||||
printf '%s\n' "$custom_config"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f "$ROOT_DIR/local-agent/config.installed.json" ]]; then
|
||||
printf '%s\n' "$ROOT_DIR/local-agent/config.installed.json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$default_config_path"
|
||||
}
|
||||
|
||||
if [[ -n "$CONFIG_SOURCE_ARG" ]]; then
|
||||
CONFIG_SOURCE_PATH="$CONFIG_SOURCE_ARG"
|
||||
else
|
||||
CONFIG_SOURCE_PATH="$(resolve_default_config_source)"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
echo "Config file not found: $CONFIG_PATH" >&2
|
||||
if [[ "$CONFIG_SOURCE_PATH" != /* ]]; then
|
||||
CONFIG_SOURCE_PATH="$ROOT_DIR/${CONFIG_SOURCE_PATH}"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_SOURCE_PATH" ]]; then
|
||||
echo "Config file not found: $CONFIG_SOURCE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG_PATH="$ROOT_DIR/local-agent/config.installed.json"
|
||||
python3 - <<'PY' "$CONFIG_SOURCE_PATH" "$CONFIG_PATH" "$ROOT_DIR"
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
source_path = Path(sys.argv[1])
|
||||
target_path = Path(sys.argv[2])
|
||||
root_dir = sys.argv[3]
|
||||
config = json.loads(source_path.read_text())
|
||||
for key in (
|
||||
"masterAgentWorkdir",
|
||||
"codexAppServerWorkdir",
|
||||
"codexComputerUseWorkdir",
|
||||
"browserControlWorkdir",
|
||||
"computerUseWorkdir",
|
||||
"codexDesktopRefreshWorkdir",
|
||||
"omxWorkdir",
|
||||
):
|
||||
config[key] = root_dir
|
||||
target_path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n")
|
||||
PY
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
cp "$BRIDGE_PLIST_SOURCE" "$BRIDGE_PLIST_TARGET"
|
||||
cp "$PLIST_SOURCE" "$PLIST_TARGET"
|
||||
python3 - <<'PY' "$PLIST_TARGET" "$CONFIG_PATH"
|
||||
python3 - <<'PY' "$PLIST_TARGET" "$BRIDGE_PLIST_TARGET" "$CONFIG_PATH" "$ROOT_DIR"
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
plist_path = Path(sys.argv[1])
|
||||
config_path = sys.argv[2]
|
||||
text = plist_path.read_text()
|
||||
plist_path.write_text(text.replace("__BOSS_AGENT_CONFIG__", config_path))
|
||||
local_plist_path = Path(sys.argv[1])
|
||||
bridge_plist_path = Path(sys.argv[2])
|
||||
config_path = sys.argv[3]
|
||||
root_dir = sys.argv[4]
|
||||
for plist_path in (local_plist_path, bridge_plist_path):
|
||||
text = plist_path.read_text()
|
||||
text = text.replace("__BOSS_AGENT_CONFIG__", config_path)
|
||||
text = text.replace("__BOSS_AGENT_ROOT__", root_dir)
|
||||
# Keep older generated plists installable if a package contains a pre-placeholder file.
|
||||
text = text.replace("/Users/kris/code/boss", root_dir)
|
||||
plist_path.write_text(text)
|
||||
PY
|
||||
plutil -lint "$PLIST_TARGET" >/dev/null
|
||||
plutil -lint "$BRIDGE_PLIST_TARGET" >/dev/null
|
||||
|
||||
311
scripts/package-boss-agent-mac-runtime.sh
Executable file
311
scripts/package-boss-agent-mac-runtime.sh
Executable file
@@ -0,0 +1,311 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSION="${BOSS_AGENT_PACKAGE_VERSION:-$(date +%Y%m%d%H%M%S)}"
|
||||
PACKAGE_NAME="boss-agent-mac-runtime-${VERSION}"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
STAGE_DIR="$DIST_DIR/$PACKAGE_NAME"
|
||||
RUNTIME_DIR="$STAGE_DIR/runtime"
|
||||
ARCHIVE_PATH="$DIST_DIR/${PACKAGE_NAME}.zip"
|
||||
|
||||
rm -rf "$STAGE_DIR" "$ARCHIVE_PATH"
|
||||
"$ROOT_DIR/scripts/build-boss-agent-mac-app.sh" >/dev/null
|
||||
|
||||
mkdir -p "$RUNTIME_DIR/scripts" "$RUNTIME_DIR/local-agent" "$RUNTIME_DIR/deployment/launchd"
|
||||
cp -R "$ROOT_DIR/dist/boss-agent.app" "$STAGE_DIR/boss-agent.app"
|
||||
rsync -a "$ROOT_DIR/local-agent/" "$RUNTIME_DIR/local-agent/"
|
||||
cp "$ROOT_DIR/scripts/start-local-agent.sh" "$RUNTIME_DIR/scripts/start-local-agent.sh"
|
||||
cp "$ROOT_DIR/scripts/install-local-launchagent.sh" "$RUNTIME_DIR/scripts/install-local-launchagent.sh"
|
||||
cp "$ROOT_DIR/scripts/browser-control-smoke.mjs" "$RUNTIME_DIR/scripts/browser-control-smoke.mjs"
|
||||
cp "$ROOT_DIR/scripts/codex-computer-use-runtime.mjs" "$RUNTIME_DIR/scripts/codex-computer-use-runtime.mjs"
|
||||
cp "$ROOT_DIR/scripts/computer-use-smoke.mjs" "$RUNTIME_DIR/scripts/computer-use-smoke.mjs"
|
||||
cp "$ROOT_DIR/scripts/cua-driver-computer-use-runtime.mjs" "$RUNTIME_DIR/scripts/cua-driver-computer-use-runtime.mjs"
|
||||
cp "$ROOT_DIR/scripts/codex-desktop-refresh-hint.mjs" "$RUNTIME_DIR/scripts/codex-desktop-refresh-hint.mjs"
|
||||
cp "$ROOT_DIR/scripts/codex-desktop-refresh-bridge-daemon.mjs" "$RUNTIME_DIR/scripts/codex-desktop-refresh-bridge-daemon.mjs"
|
||||
cp "$ROOT_DIR/deployment/launchd/com.hyzq.boss.local-agent.plist" "$RUNTIME_DIR/deployment/launchd/com.hyzq.boss.local-agent.plist"
|
||||
cp "$ROOT_DIR/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist" "$RUNTIME_DIR/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist"
|
||||
|
||||
node - <<'NODE' "$RUNTIME_DIR" "$VERSION"
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const runtimeDir = process.argv[2];
|
||||
const version = process.argv[3];
|
||||
for (const name of ["config.cloud.json", "config.example.json"]) {
|
||||
const configPath = path.join(runtimeDir, "local-agent", name);
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
config.bossAgentOtaEnabled = true;
|
||||
config.bossAgentVersion = version;
|
||||
config.bossAgentOtaAutoInstall = false;
|
||||
config.bossAgentOtaCheckIntervalMs = config.bossAgentOtaCheckIntervalMs || 300000;
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
NODE
|
||||
|
||||
cat > "$RUNTIME_DIR/package.json" <<'JSON'
|
||||
{
|
||||
"name": "boss-agent-runtime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
node - <<'NODE' "$ROOT_DIR" "$RUNTIME_DIR"
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const rootDir = process.argv[2];
|
||||
const runtimeDir = process.argv[3];
|
||||
const sourceModules = path.join(rootDir, "node_modules");
|
||||
const targetModules = path.join(runtimeDir, "node_modules");
|
||||
const seen = new Set();
|
||||
|
||||
function readPackage(name) {
|
||||
return JSON.parse(fs.readFileSync(path.join(sourceModules, name, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
function walk(name) {
|
||||
if (seen.has(name)) return;
|
||||
seen.add(name);
|
||||
const pkg = readPackage(name);
|
||||
for (const dep of Object.keys(pkg.dependencies || {})) {
|
||||
walk(dep);
|
||||
}
|
||||
}
|
||||
|
||||
walk("qrcode");
|
||||
fs.mkdirSync(targetModules, { recursive: true });
|
||||
for (const name of seen) {
|
||||
fs.cpSync(path.join(sourceModules, name), path.join(targetModules, name), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (source) => !/\/(test|tests|example|examples|\.github)\b/.test(source),
|
||||
});
|
||||
}
|
||||
NODE
|
||||
|
||||
cat > "$STAGE_DIR/install.command" <<'SH'
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_ROOT="${BOSS_AGENT_INSTALL_ROOT:-$HOME/boss-agent/current}"
|
||||
APP_TARGET_DIR="${BOSS_AGENT_APP_TARGET_DIR:-$HOME/Applications}"
|
||||
APP_TARGET="$APP_TARGET_DIR/boss-agent.app"
|
||||
CONFIG_BACKUP_DIR=""
|
||||
ACTIVE_CONFIG_BASENAME="config.cloud.json"
|
||||
ACTIVE_PLIST="$HOME/Library/LaunchAgents/com.hyzq.boss.local-agent.plist"
|
||||
NODE_BIN="${BOSS_NODE_BIN:-}"
|
||||
|
||||
if [[ -f "$ACTIVE_PLIST" ]]; then
|
||||
ACTIVE_CONFIG_PATH="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:2' "$ACTIVE_PLIST" 2>/dev/null || true)"
|
||||
if [[ -n "$ACTIVE_CONFIG_PATH" && "$ACTIVE_CONFIG_PATH" == "$INSTALL_ROOT/local-agent/"*.json ]]; then
|
||||
ACTIVE_CONFIG_BASENAME="$(basename "$ACTIVE_CONFIG_PATH")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_BIN" ]]; then
|
||||
NODE_CANDIDATES=("$HOME"/.boss-runtime/node-*/bin/node(N) /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node)
|
||||
for candidate in "${NODE_CANDIDATES[@]}"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
NODE_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_BIN" || ! -x "$NODE_BIN" ]]; then
|
||||
echo "Node.js is required. Install Node.js 22 or newer first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_MAJOR="$("$NODE_BIN" -p 'Number(process.versions.node.split(".")[0])')"
|
||||
if [[ "$NODE_MAJOR" -lt 22 ]]; then
|
||||
echo "Node.js 22 or newer is required. Current: $("$NODE_BIN" -v)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_CONFIGS=("$INSTALL_ROOT"/local-agent/config*.json(N))
|
||||
if [[ "${#EXISTING_CONFIGS[@]}" -gt 0 ]]; then
|
||||
CONFIG_BACKUP_DIR="$(mktemp -d)"
|
||||
cp "${EXISTING_CONFIGS[@]}" "$CONFIG_BACKUP_DIR"/
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$INSTALL_ROOT")" "$APP_TARGET_DIR"
|
||||
rsync -a --delete "$SOURCE_DIR/runtime/" "$INSTALL_ROOT/"
|
||||
|
||||
if [[ -n "$CONFIG_BACKUP_DIR" && -d "$CONFIG_BACKUP_DIR" ]]; then
|
||||
cp "$CONFIG_BACKUP_DIR"/config*.json "$INSTALL_ROOT/local-agent/"
|
||||
rm -rf "$CONFIG_BACKUP_DIR"
|
||||
fi
|
||||
|
||||
PACKAGE_VERSION="__BOSS_AGENT_PACKAGE_VERSION__"
|
||||
"$NODE_BIN" - <<'NODE' "$INSTALL_ROOT/local-agent" "$INSTALL_ROOT" "$PACKAGE_VERSION"
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const configDir = process.argv[2];
|
||||
const installRoot = process.argv[3];
|
||||
const version = process.argv[4];
|
||||
const home = os.homedir();
|
||||
const configPaths = fs.readdirSync(configDir)
|
||||
.filter((name) => /^config.*\.json$/.test(name))
|
||||
.map((name) => path.join(configDir, name));
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const nodeCommand = config.computerUseCommand && path.isAbsolute(config.computerUseCommand)
|
||||
? config.computerUseCommand
|
||||
: "node";
|
||||
|
||||
config.bossAgentOtaEnabled = true;
|
||||
config.bossAgentVersion = version;
|
||||
config.bossAgentInstallRoot = installRoot;
|
||||
config.bossAgentOtaDownloadDir = path.join(home, "boss-agent", "updates");
|
||||
config.bossAgentOtaCheckIntervalMs = config.bossAgentOtaCheckIntervalMs || 300000;
|
||||
config.bossAgentOtaAutoInstall = false;
|
||||
config.skillsDir = config.skillsDir || path.join(home, ".codex", "skills");
|
||||
|
||||
config.codexAppServerEnabled = config.codexAppServerEnabled !== false;
|
||||
config.codexAppServerCommand = config.codexAppServerCommand || "codex";
|
||||
config.codexAppServerArgs = Array.isArray(config.codexAppServerArgs) ? config.codexAppServerArgs : ["app-server"];
|
||||
config.codexAppServerTimeoutMs = config.codexAppServerTimeoutMs || 120000;
|
||||
config.codexAppServerFallbackToCli = config.codexAppServerFallbackToCli !== false;
|
||||
config.codexComputerUseEnabled = config.codexComputerUseEnabled !== false;
|
||||
config.codexComputerUseCommand = config.codexComputerUseCommand || nodeCommand;
|
||||
config.codexComputerUseArgs = Array.isArray(config.codexComputerUseArgs)
|
||||
? config.codexComputerUseArgs
|
||||
: ["scripts/codex-computer-use-runtime.mjs"];
|
||||
config.codexComputerUseTimeoutMs = config.codexComputerUseTimeoutMs || 120000;
|
||||
config.codexComputerUseFallbackToCua = config.codexComputerUseFallbackToCua !== false;
|
||||
|
||||
if (!Array.isArray(config.computerUseArgs) || config.computerUseArgs.length === 0 || config.computerUseArgs.includes("scripts/computer-use-smoke.mjs")) {
|
||||
config.computerUseArgs = ["scripts/cua-driver-computer-use-runtime.mjs"];
|
||||
}
|
||||
config.cuaDriverCommand = config.cuaDriverCommand || "cua-driver";
|
||||
config.cuaDriverArgs = Array.isArray(config.cuaDriverArgs) ? config.cuaDriverArgs : [];
|
||||
config.cuaDriverTimeoutMs = config.cuaDriverTimeoutMs || 45000;
|
||||
|
||||
for (const key of [
|
||||
"masterAgentWorkdir",
|
||||
"codexAppServerWorkdir",
|
||||
"codexComputerUseWorkdir",
|
||||
"browserControlWorkdir",
|
||||
"computerUseWorkdir",
|
||||
"codexDesktopRefreshWorkdir",
|
||||
"omxWorkdir"
|
||||
]) {
|
||||
config[key] = installRoot;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
NODE
|
||||
|
||||
if [[ "$ACTIVE_CONFIG_BASENAME" == "config.installed.json" || "$ACTIVE_CONFIG_BASENAME" == "config.cloud.json" || "$ACTIVE_CONFIG_BASENAME" == "config.example.json" ]]; then
|
||||
CUSTOM_CONFIGS=("$INSTALL_ROOT"/local-agent/config*.json(N))
|
||||
for custom_config in "${CUSTOM_CONFIGS[@]}"; do
|
||||
custom_name="$(basename "$custom_config")"
|
||||
case "$custom_name" in
|
||||
config.installed.json|config.cloud.json|config.example.json)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if "$NODE_BIN" - "$custom_config" <<'NODE'; then
|
||||
const fs = require("fs");
|
||||
const configPath = process.argv[2];
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
process.exit(config.deviceId && config.token ? 0 : 1);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
ACTIVE_CONFIG_BASENAME="$custom_name"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ! -f "$INSTALL_ROOT/local-agent/$ACTIVE_CONFIG_BASENAME" ]]; then
|
||||
ACTIVE_CONFIG_BASENAME="config.cloud.json"
|
||||
fi
|
||||
|
||||
chmod +x "$INSTALL_ROOT/scripts/start-local-agent.sh"
|
||||
chmod +x "$INSTALL_ROOT/scripts/install-local-launchagent.sh"
|
||||
chmod +x "$INSTALL_ROOT/scripts/"*.mjs
|
||||
|
||||
rm -rf "$APP_TARGET"
|
||||
cp -R "$SOURCE_DIR/boss-agent.app" "$APP_TARGET"
|
||||
|
||||
"$INSTALL_ROOT/scripts/install-local-launchagent.sh" "$INSTALL_ROOT/local-agent/$ACTIVE_CONFIG_BASENAME"
|
||||
|
||||
echo "boss-agent installed:"
|
||||
echo " runtime: $INSTALL_ROOT"
|
||||
echo " app: $APP_TARGET"
|
||||
echo "Open $APP_TARGET to view status."
|
||||
SH
|
||||
perl -0pi -e "s/__BOSS_AGENT_PACKAGE_VERSION__/$VERSION/g" "$STAGE_DIR/install.command"
|
||||
chmod +x "$STAGE_DIR/install.command"
|
||||
|
||||
cat > "$STAGE_DIR/README_INSTALL.txt" <<'TXT'
|
||||
Boss Agent macOS runtime package
|
||||
|
||||
Install:
|
||||
1. Unzip this package on the controlled Mac.
|
||||
2. Double click install.command.
|
||||
3. Open ~/Applications/boss-agent.app.
|
||||
|
||||
Notes:
|
||||
- Existing ~/boss-agent/current/local-agent/config.cloud.json is preserved.
|
||||
- The installer updates bossAgentVersion and local runtime paths after preserving binding credentials.
|
||||
- boss-agent OTA downloads future macOS runtime packages from the Boss server, verifies sha256, then launches this installer flow again.
|
||||
- Node.js 22 or newer is required because the local agent uses node:sqlite.
|
||||
- This build includes the Cua Driver desktop control runtime. Install and authorize `cua-driver` on the controlled Mac before enabling full desktop GUI control.
|
||||
TXT
|
||||
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$PACKAGE_NAME" "$ARCHIVE_PATH"
|
||||
)
|
||||
|
||||
node - <<'NODE' "$ARCHIVE_PATH" "$ROOT_DIR" "$VERSION"
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const archivePath = process.argv[2];
|
||||
const rootDir = process.argv[3];
|
||||
const version = process.argv[4];
|
||||
const downloadsDir = path.join(rootDir, "public", "downloads");
|
||||
const latestPath = path.join(downloadsDir, "boss-agent-mac-latest.zip");
|
||||
const latestMetaPath = path.join(downloadsDir, "boss-agent-mac-latest.json");
|
||||
|
||||
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||
fs.copyFileSync(archivePath, latestPath);
|
||||
const content = fs.readFileSync(latestPath);
|
||||
const stat = fs.statSync(latestPath);
|
||||
const meta = {
|
||||
packageType: "boss_agent_macos",
|
||||
version,
|
||||
fileName: "boss-agent-mac-latest.zip",
|
||||
archiveFileName: path.basename(archivePath),
|
||||
sizeBytes: stat.size,
|
||||
sha256: crypto.createHash("sha256").update(content).digest("hex"),
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
downloadUrl: "/api/v1/boss-agent/ota/package"
|
||||
};
|
||||
fs.writeFileSync(latestMetaPath, `${JSON.stringify(meta, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
echo "$ARCHIVE_PATH"
|
||||
@@ -3,6 +3,26 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG_PATH="${1:-$ROOT_DIR/local-agent/config.example.json}"
|
||||
NODE_BIN="${BOSS_NODE_BIN:-}"
|
||||
|
||||
if [[ -z "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_BIN" ]]; then
|
||||
NODE_CANDIDATES=("$HOME"/.boss-runtime/node-*/bin/node(N) /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node)
|
||||
for candidate in "${NODE_CANDIDATES[@]}"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
NODE_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_BIN" || ! -x "$NODE_BIN" ]]; then
|
||||
echo "Node.js 22 or newer is required. Set BOSS_NODE_BIN or install Node.js." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
exec node ./local-agent/server.mjs "$CONFIG_PATH"
|
||||
exec "$NODE_BIN" ./local-agent/server.mjs "$CONFIG_PATH"
|
||||
|
||||
Reference in New Issue
Block a user