This branch snapshots the in-progress desktop workspace so GitHub-hosted Windows and Linux runners can package the same source tree that already passes local verification on macOS. Constraint: Local Apple Silicon Docker/Wine packaging crashes before Windows artifacts are produced Rejected: Publish from the incomplete base repo state | remote typecheck failed because current source additions were missing Confidence: medium Scope-risk: broad Directive: Treat this branch as a build snapshot only; do not merge it without reviewing the unrelated workspace changes it contains Tested: local typecheck, lint, workspace typecheck, unit tests, integration tests, e2e smoke, macOS signed release upload Not-tested: Native Windows/Linux artifacts before this CI run
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { homedir } from "node:os";
|
|
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
|
import type { AppDatabase } from "./database";
|
|
import { getConnectorDefinition } from "./connectors";
|
|
import { buildSanitizedRuntimeEnv } from "./runtime-env";
|
|
import type { ConnectorId } from "@shared/types";
|
|
|
|
interface ConnectorHealthDependencies {
|
|
db: AppDatabase;
|
|
logger: {
|
|
info: (...args: unknown[]) => void;
|
|
error: (...args: unknown[]) => void;
|
|
};
|
|
publishDomainChanged: () => void;
|
|
}
|
|
|
|
export function createConnectorHealthService({
|
|
db,
|
|
logger: _logger,
|
|
publishDomainChanged,
|
|
}: ConnectorHealthDependencies) {
|
|
function randomFallbackCompanyId() {
|
|
const snapshot = db.listSnapshot();
|
|
return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000";
|
|
}
|
|
|
|
function resolveProbeCwd() {
|
|
const snapshot = db.listSnapshot();
|
|
const currentCompanyId = db.getCurrentCompanyId();
|
|
const preferredWorkspace = snapshot.workspaces.find((workspace) => workspace.companyId === currentCompanyId)
|
|
?? snapshot.workspaces[0]
|
|
?? null;
|
|
return preferredWorkspace?.localPath ?? process.env.USERPROFILE ?? process.env.HOME ?? homedir() ?? process.cwd();
|
|
}
|
|
|
|
function parseVersion(command: string): Promise<string | null> {
|
|
return new Promise((resolve) => {
|
|
let resolved = false;
|
|
const normalizedCommand = normalizeCommandValue(command);
|
|
const child = spawn(normalizedCommand, ["--version"], {
|
|
env: buildSanitizedRuntimeEnv(process.env, {}),
|
|
shell: false,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let buffer = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
buffer += String(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
buffer += String(chunk);
|
|
});
|
|
child.on("error", () => {
|
|
if (!resolved) { resolved = true; resolve(null); }
|
|
});
|
|
child.on("close", () => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
const versionLine = buffer.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null;
|
|
resolve(versionLine);
|
|
}
|
|
});
|
|
// Kill the child after 8 seconds to avoid blocking startup.
|
|
setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
try { child.kill("SIGKILL"); } catch { /* process may have already exited */ }
|
|
resolve(null);
|
|
}
|
|
}, 8_000);
|
|
});
|
|
}
|
|
|
|
async function checkConnector(id: ConnectorId) {
|
|
const connector = db.getConnector(id);
|
|
const definition = getConnectorDefinition(id);
|
|
const companyId = db.getCurrentCompanyId() ?? randomFallbackCompanyId();
|
|
const probeCwd = resolveProbeCwd();
|
|
const runtimeOverrides = db.resolveSecretEnv(connector.envBindingText, companyId);
|
|
|
|
db.updateConnectorHealth({
|
|
id,
|
|
status: "detected",
|
|
authState: connector.authState,
|
|
version: connector.version,
|
|
lastError: null,
|
|
});
|
|
publishDomainChanged();
|
|
|
|
try {
|
|
const testPromise = definition.module.testEnvironment({
|
|
companyId,
|
|
adapterType: id,
|
|
config: {
|
|
command: connector.command,
|
|
model: connector.model,
|
|
cwd: probeCwd,
|
|
env: buildSanitizedRuntimeEnv(process.env, runtimeOverrides),
|
|
approvalMode: "default",
|
|
sandbox: true,
|
|
},
|
|
});
|
|
const result = await Promise.race([
|
|
testPromise,
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)),
|
|
]);
|
|
|
|
// Check if the CLI command itself is missing before inspecting auth status.
|
|
// Without this, info-level hints like "run `gemini auth login`" would wrongly
|
|
// trigger auth_required even when the CLI binary doesn't exist.
|
|
const commandMissing = result.checks.some((check) =>
|
|
/command_unresolvable|command_not_found/i.test(check.code) && check.level === "error",
|
|
);
|
|
|
|
// Only consider warn/error-level checks for auth detection.
|
|
// Info-level checks (like gemini_api_key_missing) carry advisory hints
|
|
// that match auth patterns but do NOT indicate actual auth failure.
|
|
const authRequired = !commandMissing && result.checks.some((check) =>
|
|
check.level !== "info" && (
|
|
/auth_required|login_required/i.test(check.code) ||
|
|
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
|
|
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? "")
|
|
),
|
|
);
|
|
let authState: "ok" | "required" | "error" = "ok";
|
|
if (commandMissing) {
|
|
authState = "error";
|
|
} else if (authRequired) {
|
|
authState = "required";
|
|
} else if (result.status === "fail") {
|
|
authState = "error";
|
|
}
|
|
const version = commandMissing ? null : await parseVersion(connector.command);
|
|
|
|
// claude_compatible requires explicit env bindings (API key + base URL) to be
|
|
// considered ready. Without them, it just uses the official claude CLI auth
|
|
// which is not what the user intends for a compatible provider.
|
|
const needsCompatConfig = id.startsWith("claude_compat")
|
|
&& !connector.envBindingText.includes("ANTHROPIC_BASE_URL");
|
|
|
|
db.updateConnectorHealth({
|
|
id,
|
|
status:
|
|
commandMissing
|
|
? "not_installed"
|
|
: needsCompatConfig
|
|
? "auth_required"
|
|
: authRequired
|
|
? "auth_required"
|
|
: result.status === "pass" || result.status === "warn"
|
|
? "ready"
|
|
: "error",
|
|
authState,
|
|
version,
|
|
lastError:
|
|
result.status === "fail" || result.status === "warn"
|
|
? result.checks.map((check) => check.message).join(" | ")
|
|
: null,
|
|
});
|
|
db.addActivity({
|
|
companyId,
|
|
actor: "system",
|
|
action: "connector.tested",
|
|
entityType: "connector",
|
|
entityId: id,
|
|
detail: result.status,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
_logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
|
|
const isNotInstalled = /command.*not found|not executable|ENOENT/i.test(errorMessage);
|
|
const status = isNotInstalled ? "not_installed" : "error";
|
|
db.updateConnectorHealth({
|
|
id,
|
|
status,
|
|
authState: isNotInstalled ? "error" : "unknown",
|
|
version: null,
|
|
lastError: errorMessage,
|
|
});
|
|
}
|
|
publishDomainChanged();
|
|
}
|
|
|
|
return {
|
|
checkConnector,
|
|
randomFallbackCompanyId,
|
|
};
|
|
}
|