elizaos--eliza
426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1189 行
38 KiB
JavaScript
1189 行
38 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Registry of every credential/auth path the LifeOps HITL intake surface can
|
|
* offer per connector family (#11632). Where CONNECTOR_GROUPS (in
|
|
* collect-11632-live-validation-status.mjs) answers "is this family ready",
|
|
* CONNECTOR_PATHS answers "by which routes could it become ready": each entry
|
|
* is one way to authenticate one family — bot token, user OAuth, local bridge,
|
|
* PAT, cloud session, api key — with its env slots, its cheapest authenticated
|
|
* probe, an optional one-click acquisition hint, and a declarative
|
|
* availability check. Unavailable paths SKIP with a reason; they never error
|
|
* and never hide.
|
|
*
|
|
* Owner/agent identity follows the two coexisting repo conventions instead of
|
|
* inventing a third: (A) runtime-role families (Google, X, the
|
|
* LIFEOPS_PERMISSION_MATRIX suites) carry roles via OAuth
|
|
* metadata.requestedRole or separate real accounts with NO dedicated env
|
|
* names — doc of record plugins/plugin-personal-assistant/docs/
|
|
* owner-agent-validation-matrix.md §3; (B) GitHub is the one family with
|
|
* concrete two-slot env names (plugins/plugin-github/src/accounts.ts):
|
|
* OWNER -> role 'user' via GITHUB_USER_PAT (ELIZA_E2E_GITHUB_USER_PAT
|
|
* fallback), AGENT -> role 'agent' via GITHUB_AGENT_PAT
|
|
* (ELIZA_E2E_GITHUB_AGENT_PAT fallback), with plain GITHUB_TOKEN as the
|
|
* ownerless legacy single-token key. Entries encode this via rolesVia +
|
|
* ownerVars/agentVars.
|
|
*
|
|
* probeId values reference credential-probes.mjs families plus exactly two
|
|
* registry-introduced ids ('github', 'elizacloud'); paths with no wired probe
|
|
* carry probeId null but still document their free/cheap check in
|
|
* probeEndpoint. Availability specs are data, evaluated by checkAvailability
|
|
* with an injectable ctx so machine-state scenarios are unit-testable.
|
|
*/
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { delimiter, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
PROBE_FAMILIES,
|
|
parseSignalAccountLines,
|
|
} from "./credential-probes.mjs";
|
|
|
|
/** Path kinds — how the credential is obtained/held, not which provider. */
|
|
export const CONNECTOR_PATH_KINDS = [
|
|
"bot",
|
|
"user-oauth",
|
|
"user-client",
|
|
"local-bridge",
|
|
"pat",
|
|
"cloud-session",
|
|
"api-key",
|
|
];
|
|
|
|
/** Probe ids valid on paths beyond the credential-probes.mjs family sweep. */
|
|
export const EXTRA_PROBE_IDS = ["github", "elizacloud", "imessage"];
|
|
|
|
export const DEFAULT_APP_BASE = "http://localhost:2138";
|
|
|
|
/** Dashboard/app base for deep links; mirrors the v1 dashboard's APP_BASE. */
|
|
export function appBase(env = process.env) {
|
|
return env.ELIZA_APP_BASE_URL ?? DEFAULT_APP_BASE;
|
|
}
|
|
|
|
/** Resolve a deep-link oneClick's hrefPath against the app base, else null. */
|
|
export function resolveDeepLink(pathEntry, env = process.env) {
|
|
const oneClick = pathEntry.oneClick;
|
|
if (oneClick?.type !== "deep-link" || !oneClick.hrefPath) return null;
|
|
return `${appBase(env)}${oneClick.hrefPath}`;
|
|
}
|
|
|
|
const ONE_CLICK_TYPES = [
|
|
"gh-token",
|
|
"github-device",
|
|
"deep-link",
|
|
"shell",
|
|
"siwe",
|
|
];
|
|
const ROLES_VIA = [
|
|
"env-slots",
|
|
"oauth-requested-role",
|
|
"separate-real-accounts",
|
|
];
|
|
const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
|
|
const CONNECTORS_SECTION_PATH = "/settings?section=connectors";
|
|
|
|
function definePath(entry) {
|
|
return {
|
|
group: null,
|
|
requiredAll: [],
|
|
requiredAny: [],
|
|
optional: [],
|
|
ownerVars: [],
|
|
agentVars: [],
|
|
rolesVia: null,
|
|
oneClick: null,
|
|
notes: null,
|
|
...entry,
|
|
};
|
|
}
|
|
|
|
export const CONNECTOR_PATHS = [
|
|
// --- model providers -------------------------------------------------------
|
|
definePath({
|
|
id: "model.openai-key",
|
|
family: "model",
|
|
group: "model",
|
|
kind: "api-key",
|
|
label: "OpenAI API key",
|
|
requiredAll: ["OPENAI_API_KEY"],
|
|
optional: ["OPENAI_BASE_URL"],
|
|
probeId: "model",
|
|
probeEndpoint:
|
|
"GET {OPENAI_BASE_URL|https://api.openai.com/v1}/models (Bearer)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "model.cerebras-key",
|
|
family: "model",
|
|
group: "model",
|
|
kind: "api-key",
|
|
label: "Cerebras API key",
|
|
requiredAll: ["CEREBRAS_API_KEY"],
|
|
probeId: "model",
|
|
probeEndpoint: "GET https://api.cerebras.ai/v1/models (Bearer)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "model.anthropic-key",
|
|
family: "model",
|
|
group: "model",
|
|
kind: "api-key",
|
|
label: "Anthropic API key",
|
|
requiredAll: ["ANTHROPIC_API_KEY"],
|
|
probeId: "model",
|
|
probeEndpoint: "GET https://api.anthropic.com/v1/models (x-api-key)",
|
|
availability: { type: "always" },
|
|
}),
|
|
|
|
// --- Eliza Cloud ------------------------------------------------------------
|
|
definePath({
|
|
id: "elizacloud.siwe-session",
|
|
family: "elizacloud",
|
|
kind: "cloud-session",
|
|
label: "Eliza Cloud session via headless SIWE",
|
|
requiredAny: ["ELIZA_CLOUD_API_KEY", "ELIZAOS_CLOUD_API_KEY"],
|
|
optional: ["PRIVATE_KEY", "SIWE_BASE"],
|
|
probeId: "elizacloud",
|
|
probeEndpoint:
|
|
"GET {SIWE_BASE|https://api.elizacloud.ai}/api/v1/credits/balance (Bearer apiKey)",
|
|
oneClick: {
|
|
type: "siwe",
|
|
detail:
|
|
"bun run cloud:login:test-wallet [--json] — SIWE nonce/sign/verify returns an apiKey (Bearer); PRIVATE_KEY pins the wallet, SIWE_BASE overrides the API base",
|
|
},
|
|
availability: { type: "always" },
|
|
notes:
|
|
"The session artifact is the API key, not the browser's steward_session_token JWT (that one only lives in dashboard localStorage).",
|
|
}),
|
|
definePath({
|
|
id: "elizacloud.api-key",
|
|
family: "elizacloud",
|
|
kind: "api-key",
|
|
label: "Eliza Cloud API key paste",
|
|
requiredAny: ["ELIZA_CLOUD_API_KEY", "ELIZAOS_CLOUD_API_KEY"],
|
|
optional: ["SIWE_BASE"],
|
|
probeId: "elizacloud",
|
|
probeEndpoint:
|
|
"GET {SIWE_BASE|https://api.elizacloud.ai}/api/v1/credits/balance (Bearer apiKey)",
|
|
availability: { type: "always" },
|
|
}),
|
|
|
|
// --- GitHub -----------------------------------------------------------------
|
|
definePath({
|
|
id: "github.gh-cli",
|
|
family: "github",
|
|
kind: "cloud-session",
|
|
label: "Reuse gh CLI keyring token",
|
|
requiredAny: ["GITHUB_TOKEN"],
|
|
probeId: "github",
|
|
probeEndpoint:
|
|
"GET https://api.github.com/user (Authorization: Bearer <token>)",
|
|
oneClick: {
|
|
type: "gh-token",
|
|
detail:
|
|
"gh auth token — emits the keyring gho_ token; save it to GITHUB_TOKEN (ownerless legacy slot) or GITHUB_USER_PAT (owner slot)",
|
|
},
|
|
availability: {
|
|
type: "all-of",
|
|
specs: [
|
|
{
|
|
type: "command-in-path",
|
|
command: "gh",
|
|
reason: "gh CLI not in PATH",
|
|
},
|
|
{
|
|
type: "command-ok",
|
|
command: "gh",
|
|
args: ["auth", "token"],
|
|
reason: "gh CLI present but not authenticated (gh auth login)",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
definePath({
|
|
id: "github.pat",
|
|
family: "github",
|
|
kind: "pat",
|
|
label: "GitHub PATs (owner + agent slots)",
|
|
requiredAny: ["GITHUB_USER_PAT", "GITHUB_AGENT_PAT", "GITHUB_TOKEN"],
|
|
ownerVars: ["GITHUB_USER_PAT", "ELIZA_E2E_GITHUB_USER_PAT"],
|
|
agentVars: ["GITHUB_AGENT_PAT", "ELIZA_E2E_GITHUB_AGENT_PAT"],
|
|
rolesVia: "env-slots",
|
|
probeId: "github",
|
|
probeEndpoint:
|
|
"GET https://api.github.com/user (Authorization: Bearer <token>)",
|
|
availability: { type: "always" },
|
|
notes:
|
|
"OWNER label maps to plugin-github role 'user', AGENT to 'agent' (plugins/plugin-github/src/accounts.ts); GITHUB_ACCOUNTS JSON and character.settings.github.accounts are the multi-account forms; GITHUB_TOKEN stays the ownerless legacy single token.",
|
|
}),
|
|
definePath({
|
|
id: "github.device-oauth",
|
|
family: "github",
|
|
kind: "user-oauth",
|
|
label: "GitHub OAuth device login",
|
|
requiredAll: ["GITHUB_OAUTH_CLIENT_ID"],
|
|
optional: ["GITHUB_TOKEN"],
|
|
probeId: "github",
|
|
probeEndpoint:
|
|
"POST https://github.com/login/device/code, then GET https://api.github.com/user with the acquired token",
|
|
oneClick: {
|
|
type: "github-device",
|
|
detail:
|
|
"Display GitHub's short device code, confirm it on github.com, and save the returned token without exposing it to the browser",
|
|
},
|
|
availability: {
|
|
type: "env-all",
|
|
names: ["GITHUB_OAUTH_CLIENT_ID"],
|
|
reason:
|
|
"needs owner setup: no GitHub OAuth client id with device flow enabled (GITHUB_OAUTH_CLIENT_ID)",
|
|
},
|
|
}),
|
|
definePath({
|
|
id: "github.user-oauth",
|
|
family: "github",
|
|
kind: "user-oauth",
|
|
label: "GitHub user OAuth app",
|
|
requiredAll: [
|
|
"GITHUB_OAUTH_CLIENT_ID",
|
|
"GITHUB_OAUTH_CLIENT_SECRET",
|
|
"GITHUB_OAUTH_REDIRECT_URI",
|
|
],
|
|
probeId: "github",
|
|
probeEndpoint:
|
|
"GET https://api.github.com/user (Authorization: Bearer <token>)",
|
|
availability: {
|
|
type: "env-all",
|
|
names: ["GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"],
|
|
reason:
|
|
"no OAuth app configured (GITHUB_OAUTH_CLIENT_ID/SECRET absent; no in-repo public client id exists for a local device-code flow)",
|
|
},
|
|
}),
|
|
|
|
// --- Google (in-app OAuth consent; owner + agent are separate accounts) ------
|
|
definePath({
|
|
id: "google.oauth-owner",
|
|
family: "google",
|
|
group: "google",
|
|
kind: "user-oauth",
|
|
label: "Google OAuth — OWNER account",
|
|
requiredAll: [
|
|
"GOOGLE_CLIENT_ID",
|
|
"GOOGLE_CLIENT_SECRET",
|
|
"GOOGLE_REDIRECT_URI",
|
|
],
|
|
rolesVia: "oauth-requested-role",
|
|
probeId: "google",
|
|
probeEndpoint:
|
|
"presence-only client credentials; live proof is POST /api/connectors/google/oauth/start (metadata.requestedRole=OWNER) then the consent flow",
|
|
oneClick: {
|
|
type: "deep-link",
|
|
hrefPath: CONNECTORS_SECTION_PATH,
|
|
detail:
|
|
"Settings -> Connectors: connect the OWNER Google account (requestedRole=OWNER)",
|
|
},
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "google.oauth-agent",
|
|
family: "google",
|
|
group: "google",
|
|
kind: "user-oauth",
|
|
label: "Google OAuth — AGENT account",
|
|
requiredAll: [
|
|
"GOOGLE_CLIENT_ID",
|
|
"GOOGLE_CLIENT_SECRET",
|
|
"GOOGLE_REDIRECT_URI",
|
|
],
|
|
rolesVia: "oauth-requested-role",
|
|
probeId: "google",
|
|
probeEndpoint:
|
|
"presence-only client credentials; live proof is POST /api/connectors/google/oauth/start (metadata.requestedRole=AGENT) then the consent flow",
|
|
oneClick: {
|
|
type: "deep-link",
|
|
hrefPath: CONNECTORS_SECTION_PATH,
|
|
detail:
|
|
"Settings -> Connectors: connect the AGENT Google account (requestedRole=AGENT)",
|
|
},
|
|
availability: { type: "always" },
|
|
notes:
|
|
"OWNER and AGENT are separate real Google accounts (owner-agent matrix doc §3); the role rides oauth start metadata (packages/core/src/connectors/oauth-role.ts), not env names.",
|
|
}),
|
|
|
|
// --- Telegram -----------------------------------------------------------------
|
|
definePath({
|
|
id: "telegram.bot",
|
|
family: "telegram",
|
|
group: "telegram",
|
|
kind: "bot",
|
|
label: "Telegram bot token",
|
|
requiredAll: ["TELEGRAM_BOT_TOKEN"],
|
|
optional: ["TELEGRAM_TEST_CHAT_ID", "TELEGRAM_ALLOWED_CHATS"],
|
|
probeId: "telegram",
|
|
probeEndpoint: "GET https://api.telegram.org/bot<token>/getMe",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "telegram.user-client",
|
|
family: "telegram",
|
|
group: "telegram",
|
|
kind: "user-client",
|
|
label: "Telegram user client (future gramjs)",
|
|
requiredAll: ["TELEGRAM_API_ID", "TELEGRAM_API_HASH"],
|
|
requiredAny: ["TELEGRAM_OWNER_SESSION", "TELEGRAM_USER_SESSION"],
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"gramjs getMe over the string session (not yet wired in-repo)",
|
|
availability: {
|
|
type: "all-of",
|
|
specs: [
|
|
{
|
|
type: "env-all",
|
|
names: ["TELEGRAM_API_ID", "TELEGRAM_API_HASH"],
|
|
reason:
|
|
"missing Telegram API credentials (TELEGRAM_API_ID, TELEGRAM_API_HASH)",
|
|
},
|
|
{
|
|
type: "any-of",
|
|
specs: [
|
|
{
|
|
type: "env-present",
|
|
names: ["TELEGRAM_OWNER_SESSION", "TELEGRAM_USER_SESSION"],
|
|
reason:
|
|
"no owner gramjs string session in env (TELEGRAM_OWNER_SESSION)",
|
|
},
|
|
{
|
|
type: "file-exists",
|
|
path: "~/.eliza/telegram-user.session",
|
|
reason: "no saved session file (~/.eliza/telegram-user.session)",
|
|
},
|
|
],
|
|
reason:
|
|
"no owner gramjs string session in env (TELEGRAM_OWNER_SESSION)",
|
|
},
|
|
],
|
|
},
|
|
notes:
|
|
"Documented ahead of a gramjs integration; TELEGRAM_OWNER_SESSION is the owner-scoped key required by the HITL issue, while TELEGRAM_USER_SESSION remains a temporary read alias. Telegram Desktop's tdata is proprietary/encrypted and is not a credential source.",
|
|
}),
|
|
|
|
// --- Discord --------------------------------------------------------------------
|
|
definePath({
|
|
id: "discord.bot",
|
|
family: "discord",
|
|
group: "discord",
|
|
kind: "bot",
|
|
label: "Discord bot token",
|
|
requiredAny: ["DISCORD_API_TOKEN", "DISCORD_BOT_TOKEN"],
|
|
probeId: "discord",
|
|
probeEndpoint:
|
|
"GET https://discord.com/api/v10/users/@me (Authorization: Bot <token>)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "discord.user-token",
|
|
family: "discord",
|
|
group: "discord",
|
|
kind: "user-client",
|
|
label: "Discord user token paste",
|
|
requiredAll: ["DISCORD_USER_TOKEN"],
|
|
probeId: "discord",
|
|
probeEndpoint:
|
|
"GET https://discord.com/api/v10/users/@me (raw user token, no Bot prefix)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "discord.user-oauth",
|
|
family: "discord",
|
|
group: "discord",
|
|
kind: "user-oauth",
|
|
label: "Discord user OAuth app",
|
|
requiredAll: ["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"],
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"POST https://discord.com/api/oauth2/token then GET /users/@me",
|
|
availability: {
|
|
type: "env-all",
|
|
names: ["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"],
|
|
reason:
|
|
"no Discord user-OAuth app configured (the in-tree exchange at packages/app-core/src/api/auth/discord-exchange.ts is Activity-iframe-only)",
|
|
},
|
|
}),
|
|
|
|
// --- Slack ------------------------------------------------------------------------
|
|
definePath({
|
|
id: "slack.bot",
|
|
family: "slack",
|
|
group: "slack",
|
|
kind: "bot",
|
|
label: "Slack bot + app tokens",
|
|
requiredAll: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
|
|
optional: ["SLACK_CHANNEL_IDS", "SLACK_SIGNING_SECRET"],
|
|
probeId: "slack",
|
|
probeEndpoint:
|
|
"POST https://slack.com/api/auth.test (xoxb) + apps.connections.open (xapp)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "slack.user-token",
|
|
family: "slack",
|
|
group: "slack",
|
|
kind: "user-client",
|
|
label: "Slack user token (user-context calls)",
|
|
requiredAll: ["SLACK_USER_TOKEN"],
|
|
probeId: "slack",
|
|
probeEndpoint: "POST https://slack.com/api/auth.test (xoxp user token)",
|
|
availability: { type: "always" },
|
|
}),
|
|
|
|
// --- Signal --------------------------------------------------------------------------
|
|
definePath({
|
|
id: "signal.desktop-bridge",
|
|
family: "signal",
|
|
group: "signal",
|
|
kind: "local-bridge",
|
|
label: "Signal Desktop (status only)",
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"local install/link status only — Signal Desktop's DB is SQLCipher-encrypted and is not a credential source",
|
|
availability: {
|
|
type: "all-of",
|
|
specs: [
|
|
{
|
|
type: "dir-exists",
|
|
path: "/Applications/Signal.app",
|
|
reason: "Signal Desktop not installed",
|
|
},
|
|
{
|
|
type: "dir-exists",
|
|
path: "~/Library/Application Support/Signal",
|
|
reason: "no Signal Desktop profile directory",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
definePath({
|
|
id: "signal.cli",
|
|
family: "signal",
|
|
group: "signal",
|
|
kind: "user-client",
|
|
label: "signal-cli / signal-cli-rest-api",
|
|
requiredAll: ["SIGNAL_ACCOUNT_NUMBER"],
|
|
requiredAny: ["SIGNAL_HTTP_URL", "SIGNAL_CLI_PATH"],
|
|
probeId: "signal",
|
|
probeEndpoint: "GET {SIGNAL_HTTP_URL}/v1/about, else signal-cli --version",
|
|
oneClick: {
|
|
type: "shell",
|
|
detail:
|
|
"signal-cli link -n eliza-hitl-dashboard — emits an sgnl://linkdevice URI to scan from the phone (Signal → Settings → Linked devices → Link new device)",
|
|
},
|
|
availability: {
|
|
type: "any-of",
|
|
specs: [
|
|
{
|
|
type: "env-present",
|
|
names: ["SIGNAL_HTTP_URL"],
|
|
reason: "no signal-cli-rest-api URL",
|
|
},
|
|
{
|
|
type: "all-of",
|
|
specs: [
|
|
{
|
|
type: "command-in-path",
|
|
command: "signal-cli",
|
|
reason: "signal-cli not in PATH",
|
|
},
|
|
{
|
|
type: "command-ok",
|
|
command: "signal-cli",
|
|
args: ["--version"],
|
|
reason: "signal-cli is installed but not runnable",
|
|
},
|
|
{
|
|
type: "command-output-nonempty",
|
|
command: "signal-cli",
|
|
args: ["listAccounts"],
|
|
outputFilter: "signal-accounts",
|
|
reason: "no linked Signal account",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
notes:
|
|
"Availability executes the read-only listAccounts command: an installed-but-unrunnable binary, a listAccounts run failure, and a runnable client with no linked account (warning-only output included) remain distinct skip states.",
|
|
}),
|
|
|
|
// --- WhatsApp -----------------------------------------------------------------------
|
|
definePath({
|
|
id: "whatsapp.cloud-api",
|
|
family: "whatsapp",
|
|
group: "whatsapp",
|
|
kind: "api-key",
|
|
label: "WhatsApp Cloud API token + phone id",
|
|
requiredAll: [
|
|
"ELIZA_WHATSAPP_ACCESS_TOKEN",
|
|
"ELIZA_WHATSAPP_PHONE_NUMBER_ID",
|
|
],
|
|
optional: ["WHATSAPP_ACCESS_TOKEN", "WHATSAPP_PHONE_NUMBER_ID"],
|
|
probeId: "whatsapp",
|
|
probeEndpoint:
|
|
"GET https://graph.facebook.com/v19.0/{PHONE_NUMBER_ID}?fields=display_phone_number (Bearer)",
|
|
availability: { type: "always" },
|
|
notes:
|
|
"ELIZA_WHATSAPP_* and bare WHATSAPP_* spellings are write-aliased by the dashboard; either satisfies the probe.",
|
|
}),
|
|
|
|
// --- iMessage ------------------------------------------------------------------------
|
|
definePath({
|
|
id: "imessage.macos",
|
|
family: "imessage",
|
|
group: "native_ios_macos",
|
|
kind: "local-bridge",
|
|
label: "macOS Messages bridge",
|
|
optional: ["ELIZA_IMESSAGE_BACKEND"],
|
|
probeId: "imessage",
|
|
probeEndpoint:
|
|
"local: ~/Library/Messages/chat.db readable (requires Full Disk Access)",
|
|
availability: {
|
|
type: "all-of",
|
|
specs: [
|
|
{ type: "platform", platform: "darwin", reason: "not macOS" },
|
|
{
|
|
type: "any-of",
|
|
specs: [
|
|
{
|
|
type: "env-present",
|
|
names: ["ELIZA_IMESSAGE_BACKEND"],
|
|
reason: "no iMessage backend configured",
|
|
},
|
|
{
|
|
type: "file-exists",
|
|
path: "~/Library/Messages/chat.db",
|
|
reason: "Messages database not readable (grant Full Disk Access)",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
definePath({
|
|
id: "imessage.bluebubbles",
|
|
family: "imessage",
|
|
group: "native_ios_macos",
|
|
kind: "local-bridge",
|
|
label: "BlueBubbles server",
|
|
requiredAll: ["BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"],
|
|
probeId: "imessage",
|
|
probeEndpoint:
|
|
"GET {BLUEBUBBLES_SERVER_URL|http://localhost:1234}/api/v1/ping?password=<password>",
|
|
availability: {
|
|
type: "any-of",
|
|
specs: [
|
|
{
|
|
type: "env-all",
|
|
names: ["BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"],
|
|
reason: "BlueBubbles server URL/password not configured",
|
|
},
|
|
{
|
|
type: "dir-exists",
|
|
path: "~/Library/Application Support/bluebubbles-server",
|
|
reason: "no BlueBubbles server profile",
|
|
},
|
|
{
|
|
type: "dir-exists",
|
|
path: "/Applications/BlueBubbles.app",
|
|
reason: "BlueBubbles not installed",
|
|
},
|
|
],
|
|
},
|
|
notes:
|
|
"An installed-but-stopped server is 'available' (row shows, probe reports connection refused with the start hint); the password lives in the server's config.db.",
|
|
}),
|
|
|
|
// --- X ---------------------------------------------------------------------------------
|
|
definePath({
|
|
id: "x.oauth1-user",
|
|
family: "x",
|
|
group: "x",
|
|
kind: "user-oauth",
|
|
label: "X OAuth1 user context (owner account)",
|
|
requiredAll: [
|
|
"TWITTER_API_KEY",
|
|
"TWITTER_API_SECRET_KEY",
|
|
"TWITTER_ACCESS_TOKEN",
|
|
"TWITTER_ACCESS_TOKEN_SECRET",
|
|
],
|
|
optional: ["X_API_KEY"],
|
|
probeId: "x",
|
|
probeEndpoint: "GET https://api.x.com/2/users/me (OAuth1 HMAC-SHA1 signed)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "x.bearer-app",
|
|
family: "x",
|
|
group: "x",
|
|
kind: "api-key",
|
|
label: "X app-only bearer token",
|
|
requiredAll: ["TWITTER_BEARER_TOKEN"],
|
|
probeId: "x",
|
|
probeEndpoint:
|
|
"GET https://api.x.com/2/users/me (Bearer; app-only cannot read user context — a valid key authenticates but is told so, an invalid key gets 401)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "x.agent-account",
|
|
family: "x",
|
|
group: "x",
|
|
kind: "user-oauth",
|
|
label: "X AGENT account (separate real account)",
|
|
rolesVia: "separate-real-accounts",
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"GET https://api.x.com/2/users/me with the agent account's own OAuth1 tokens",
|
|
availability: {
|
|
type: "never",
|
|
reason:
|
|
"agent X identity is a separate real account per the owner-agent matrix doc §3 — no dedicated env slots exist; connect it as its own account when the matrix lane needs it",
|
|
},
|
|
}),
|
|
|
|
// --- Twilio -------------------------------------------------------------------------------
|
|
definePath({
|
|
id: "twilio.api",
|
|
family: "twilio",
|
|
group: "twilio",
|
|
kind: "api-key",
|
|
label: "Twilio account SID + auth token",
|
|
requiredAll: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"],
|
|
optional: ["TWILIO_PHONE_NUMBER", "TWILIO_WEBHOOK_URL"],
|
|
probeId: "twilio",
|
|
probeEndpoint:
|
|
"GET https://api.twilio.com/2010-04-01/Accounts/{SID}.json (Basic)",
|
|
availability: { type: "always" },
|
|
}),
|
|
|
|
// --- Health --------------------------------------------------------------------------------
|
|
definePath({
|
|
id: "health.strava",
|
|
family: "health",
|
|
group: "health",
|
|
kind: "api-key",
|
|
label: "Strava access token",
|
|
requiredAll: ["STRAVA_ACCESS_TOKEN"],
|
|
probeId: "health",
|
|
probeEndpoint: "GET https://www.strava.com/api/v3/athlete (Bearer)",
|
|
oneClick: {
|
|
type: "deep-link",
|
|
hrefPath: CONNECTORS_SECTION_PATH,
|
|
detail: "Settings -> Connectors: Strava OAuth consent",
|
|
},
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "health.oura",
|
|
family: "health",
|
|
group: "health",
|
|
kind: "api-key",
|
|
label: "Oura access token",
|
|
requiredAll: ["OURA_ACCESS_TOKEN"],
|
|
probeId: "health",
|
|
probeEndpoint:
|
|
"GET https://api.ouraring.com/v2/usercollection/personal_info (Bearer)",
|
|
oneClick: {
|
|
type: "deep-link",
|
|
hrefPath: CONNECTORS_SECTION_PATH,
|
|
detail: "Settings -> Connectors: Oura OAuth consent",
|
|
},
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "health.fitbit",
|
|
family: "health",
|
|
group: "health",
|
|
kind: "api-key",
|
|
label: "Fitbit access token",
|
|
requiredAll: ["FITBIT_ACCESS_TOKEN"],
|
|
probeId: "health",
|
|
probeEndpoint: "GET https://api.fitbit.com/1/user/-/profile.json (Bearer)",
|
|
oneClick: {
|
|
type: "deep-link",
|
|
hrefPath: CONNECTORS_SECTION_PATH,
|
|
detail: "Settings -> Connectors: Fitbit OAuth consent",
|
|
},
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "health.withings",
|
|
family: "health",
|
|
group: "health",
|
|
kind: "api-key",
|
|
label: "Withings access token",
|
|
requiredAll: ["WITHINGS_ACCESS_TOKEN"],
|
|
probeId: "health",
|
|
probeEndpoint:
|
|
"GET https://wbsapi.withings.net/v2/user?action=getdevice (Bearer)",
|
|
oneClick: {
|
|
type: "deep-link",
|
|
hrefPath: CONNECTORS_SECTION_PATH,
|
|
detail: "Settings -> Connectors: Withings OAuth consent",
|
|
},
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "health.healthkit",
|
|
family: "health",
|
|
group: "health",
|
|
kind: "local-bridge",
|
|
label: "HealthKit export CLI (device-local)",
|
|
requiredAll: ["ELIZA_HEALTHKIT_CLI_PATH"],
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"local: the ELIZA_HEALTHKIT_CLI_PATH binary responds on-device",
|
|
availability: {
|
|
type: "all-of",
|
|
specs: [
|
|
{
|
|
type: "platform",
|
|
platform: "darwin",
|
|
reason: "HealthKit is Apple-only",
|
|
},
|
|
{
|
|
type: "env-present",
|
|
names: ["ELIZA_HEALTHKIT_CLI_PATH"],
|
|
reason: "no HealthKit CLI configured",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
definePath({
|
|
id: "health.google-fit",
|
|
family: "health",
|
|
group: "health",
|
|
kind: "api-key",
|
|
label: "Google Fit access token",
|
|
requiredAll: ["ELIZA_GOOGLE_FIT_ACCESS_TOKEN"],
|
|
probeId: "health",
|
|
probeEndpoint:
|
|
"GET https://www.googleapis.com/fitness/v1/users/me/dataSources (Bearer)",
|
|
availability: { type: "always" },
|
|
}),
|
|
|
|
// --- Finance --------------------------------------------------------------------------------
|
|
definePath({
|
|
id: "finance.plaid",
|
|
family: "finance",
|
|
group: "finance",
|
|
kind: "api-key",
|
|
label: "Plaid sandbox credentials",
|
|
requiredAll: ["PLAID_CLIENT_ID", "PLAID_SECRET"],
|
|
probeId: "plaid",
|
|
probeEndpoint: "POST https://sandbox.plaid.com/institutions/get",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "finance.paypal",
|
|
family: "finance",
|
|
group: "finance",
|
|
kind: "api-key",
|
|
label: "PayPal sandbox credentials",
|
|
requiredAll: ["PAYPAL_CLIENT_ID", "PAYPAL_CLIENT_SECRET"],
|
|
optional: ["PAYPAL_API_BASE"],
|
|
probeId: "paypal",
|
|
probeEndpoint:
|
|
"POST {PAYPAL_API_BASE|https://api-m.sandbox.paypal.com}/v1/oauth2/token (Basic, client_credentials)",
|
|
availability: { type: "always" },
|
|
notes:
|
|
"LIFEOPS_FINANCE_CSV_FIXTURE remains the keyless finance alternative recognized by CONNECTOR_GROUPS; it is a fixture, not an auth path.",
|
|
}),
|
|
|
|
// --- Crypto ---------------------------------------------------------------------------------
|
|
definePath({
|
|
id: "crypto.evm",
|
|
family: "crypto",
|
|
kind: "api-key",
|
|
label: "EVM private key",
|
|
requiredAll: ["EVM_PRIVATE_KEY"],
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"local key parse -> address, then eth_getBalance via a public RPC (free, read-only)",
|
|
availability: { type: "always" },
|
|
}),
|
|
definePath({
|
|
id: "crypto.solana",
|
|
family: "crypto",
|
|
kind: "api-key",
|
|
label: "Solana private key",
|
|
requiredAll: ["SOLANA_PRIVATE_KEY"],
|
|
probeId: null,
|
|
probeEndpoint:
|
|
"local key parse -> address, then getBalance via a public RPC (free, read-only)",
|
|
availability: { type: "always" },
|
|
}),
|
|
].map(Object.freeze);
|
|
|
|
/** All env names any path can read — the dashboard's save-allowlist extension. */
|
|
export const CONNECTOR_PATH_ENV_NAMES = new Set(
|
|
CONNECTOR_PATHS.flatMap((path) => [
|
|
...path.requiredAll,
|
|
...path.requiredAny,
|
|
...path.optional,
|
|
...path.ownerVars,
|
|
...path.agentVars,
|
|
]),
|
|
);
|
|
|
|
export function getFamilies(paths = CONNECTOR_PATHS) {
|
|
return [...new Set(paths.map((path) => path.family))];
|
|
}
|
|
|
|
export function getPathsForFamily(family, paths = CONNECTOR_PATHS) {
|
|
return paths.filter((path) => path.family === family);
|
|
}
|
|
|
|
// --- availability evaluation ---------------------------------------------------
|
|
|
|
function hasEnv(env, name) {
|
|
const value = env[name];
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function expandHome(path, home) {
|
|
return path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
|
}
|
|
|
|
function commandInPath(command) {
|
|
const pathValue = process.env.PATH ?? "";
|
|
return pathValue
|
|
.split(delimiter)
|
|
.some((dir) => dir.length > 0 && existsSync(join(dir, command)));
|
|
}
|
|
|
|
/** Real-machine evaluation context; every field is injectable in tests. */
|
|
export function defaultAvailabilityCtx() {
|
|
return {
|
|
env: process.env,
|
|
platform: process.platform,
|
|
home: homedir(),
|
|
existsSync,
|
|
commandInPath,
|
|
runCommand: (command, args) => {
|
|
const result = spawnSync(command, args, {
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
});
|
|
return {
|
|
ok: !result.error && result.status === 0,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
// null on spawn failure (ENOENT, timeout kill) — the reason renders
|
|
// it as "spawn-error" so a crash never masquerades as empty output.
|
|
status: result.status,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
// Declarative specs are data, so a spec cannot carry a filter function;
|
|
// `outputFilter` names one of these instead. The signal filter is the exact
|
|
// parser probeSignal uses, keeping the coarse availability leaf and the deep
|
|
// probe in agreement on what counts as a linked account (#15848).
|
|
const COMMAND_OUTPUT_FILTERS = {
|
|
"signal-accounts": parseSignalAccountLines,
|
|
};
|
|
|
|
// A command that failed to run is a distinct skip state from one that ran and
|
|
// printed nothing; surfacing exit code + first stderr line keeps the
|
|
// dashboard from misdiagnosing e.g. locked signal-cli account storage as "no
|
|
// linked Signal account".
|
|
function commandRunFailureReason(spec, result) {
|
|
const stderrLine =
|
|
typeof result.stderr === "string"
|
|
? (result.stderr.trim().split(/\r?\n/)[0] ?? "").slice(0, 160)
|
|
: "";
|
|
return `${spec.command} ${spec.args.join(" ")} failed (status=${
|
|
result.status ?? "spawn-error"
|
|
})${stderrLine ? `: ${stderrLine}` : ""}`;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a declarative availability spec to { available, reason }. Leaf
|
|
* failures surface their spec's reason — except a command that fails to run
|
|
* under command-output-nonempty, which reports the run failure (exit code +
|
|
* stderr snippet) so a crash is never labeled with the empty-output reason.
|
|
* any-of joins all branch reasons, all-of reports the first failing
|
|
* requirement. Unknown spec types and unknown outputFilter names throw — a
|
|
* malformed registry entry is a bug, not a skip.
|
|
*/
|
|
export function checkAvailability(spec, ctx = defaultAvailabilityCtx()) {
|
|
const ok = { available: true, reason: null };
|
|
switch (spec.type) {
|
|
case "always":
|
|
return ok;
|
|
case "never":
|
|
return { available: false, reason: spec.reason };
|
|
case "env-present":
|
|
case "env-any":
|
|
return spec.names.some((name) => hasEnv(ctx.env, name))
|
|
? ok
|
|
: {
|
|
available: false,
|
|
reason: spec.reason ?? `none of ${spec.names.join(", ")} set`,
|
|
};
|
|
case "env-all":
|
|
return spec.names.every((name) => hasEnv(ctx.env, name))
|
|
? ok
|
|
: {
|
|
available: false,
|
|
reason:
|
|
spec.reason ??
|
|
`missing ${spec.names.filter((name) => !hasEnv(ctx.env, name)).join(", ")}`,
|
|
};
|
|
case "dir-exists":
|
|
case "file-exists":
|
|
return ctx.existsSync(expandHome(spec.path, ctx.home))
|
|
? ok
|
|
: { available: false, reason: spec.reason ?? `${spec.path} missing` };
|
|
case "command-in-path":
|
|
return ctx.commandInPath(spec.command)
|
|
? ok
|
|
: {
|
|
available: false,
|
|
reason: spec.reason ?? `${spec.command} not in PATH`,
|
|
};
|
|
case "command-ok":
|
|
return ctx.runCommand(spec.command, spec.args).ok
|
|
? ok
|
|
: {
|
|
available: false,
|
|
reason:
|
|
spec.reason ?? `${spec.command} ${spec.args.join(" ")} failed`,
|
|
};
|
|
case "command-output-nonempty": {
|
|
const filter =
|
|
spec.outputFilter === undefined
|
|
? null
|
|
: COMMAND_OUTPUT_FILTERS[spec.outputFilter];
|
|
if (spec.outputFilter !== undefined && !filter) {
|
|
throw new Error(
|
|
`Unknown command-output filter: ${JSON.stringify(spec.outputFilter)}`,
|
|
);
|
|
}
|
|
const result = ctx.runCommand(spec.command, spec.args);
|
|
if (!result.ok) {
|
|
return {
|
|
available: false,
|
|
reason: commandRunFailureReason(spec, result),
|
|
};
|
|
}
|
|
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
const meaningful = filter ? filter(stdout).join("\n") : stdout;
|
|
return meaningful.trim().length > 0
|
|
? ok
|
|
: {
|
|
available: false,
|
|
reason:
|
|
spec.reason ??
|
|
`${spec.command} ${spec.args.join(" ")} returned no output`,
|
|
};
|
|
}
|
|
case "platform":
|
|
return ctx.platform === spec.platform
|
|
? ok
|
|
: {
|
|
available: false,
|
|
reason: spec.reason ?? `requires ${spec.platform}`,
|
|
};
|
|
case "any-of": {
|
|
const results = spec.specs.map((inner) => checkAvailability(inner, ctx));
|
|
const hit = results.find((result) => result.available);
|
|
return (
|
|
hit ?? {
|
|
available: false,
|
|
reason: results.map((r) => r.reason).join("; "),
|
|
}
|
|
);
|
|
}
|
|
case "all-of": {
|
|
for (const inner of spec.specs) {
|
|
const result = checkAvailability(inner, ctx);
|
|
if (!result.available) return result;
|
|
}
|
|
return ok;
|
|
}
|
|
default:
|
|
throw new Error(
|
|
`Unknown availability spec type: ${JSON.stringify(spec.type)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate every path's availability. Rows carry env NAMES and metadata only —
|
|
* never env values — so the result is safe to serialize into a dashboard
|
|
* payload or a committed ledger.
|
|
*/
|
|
export function evaluateConnectorPaths(
|
|
ctx = defaultAvailabilityCtx(),
|
|
paths = CONNECTOR_PATHS,
|
|
) {
|
|
return paths.map((path) => {
|
|
const { available, reason } = checkAvailability(path.availability, ctx);
|
|
return {
|
|
id: path.id,
|
|
family: path.family,
|
|
group: path.group,
|
|
kind: path.kind,
|
|
label: path.label,
|
|
requiredAll: path.requiredAll,
|
|
requiredAny: path.requiredAny,
|
|
optional: path.optional,
|
|
ownerVars: path.ownerVars,
|
|
agentVars: path.agentVars,
|
|
rolesVia: path.rolesVia,
|
|
probeId: path.probeId,
|
|
probeEndpoint: path.probeEndpoint,
|
|
oneClick: path.oneClick,
|
|
notes: path.notes,
|
|
available,
|
|
reason,
|
|
};
|
|
});
|
|
}
|
|
|
|
// --- registry invariants ---------------------------------------------------------
|
|
|
|
const VALID_PROBE_IDS = new Set([...PROBE_FAMILIES, ...EXTRA_PROBE_IDS]);
|
|
|
|
function validateAvailabilitySpec(spec, id, problems) {
|
|
if (!spec || typeof spec.type !== "string") {
|
|
problems.push(`${id}: availability spec missing type`);
|
|
return;
|
|
}
|
|
if (spec.type === "any-of" || spec.type === "all-of") {
|
|
if (!Array.isArray(spec.specs) || spec.specs.length === 0) {
|
|
problems.push(`${id}: ${spec.type} needs a non-empty specs array`);
|
|
return;
|
|
}
|
|
for (const inner of spec.specs)
|
|
validateAvailabilitySpec(inner, id, problems);
|
|
return;
|
|
}
|
|
const known = [
|
|
"always",
|
|
"never",
|
|
"env-present",
|
|
"env-any",
|
|
"env-all",
|
|
"dir-exists",
|
|
"file-exists",
|
|
"command-in-path",
|
|
"command-ok",
|
|
"command-output-nonempty",
|
|
"platform",
|
|
];
|
|
if (!known.includes(spec.type)) {
|
|
problems.push(`${id}: unknown availability type ${spec.type}`);
|
|
}
|
|
if (spec.type === "never" && !spec.reason) {
|
|
problems.push(`${id}: never spec must carry a reason`);
|
|
}
|
|
if (
|
|
spec.outputFilter !== undefined &&
|
|
!Object.hasOwn(COMMAND_OUTPUT_FILTERS, spec.outputFilter)
|
|
) {
|
|
problems.push(`${id}: unknown command-output filter ${spec.outputFilter}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Structural invariants for a paths array; returns problem strings (empty =
|
|
* valid). Enforced at import time for the shipped registry so a malformed
|
|
* entry fails fast instead of silently mis-rendering.
|
|
*/
|
|
export function validateConnectorPaths(paths = CONNECTOR_PATHS) {
|
|
const problems = [];
|
|
const seen = new Set();
|
|
for (const path of paths) {
|
|
if (seen.has(path.id)) problems.push(`duplicate id: ${path.id}`);
|
|
seen.add(path.id);
|
|
if (!path.id.startsWith(`${path.family}.`)) {
|
|
problems.push(`${path.id}: id must be <family>.<slug>`);
|
|
}
|
|
if (!CONNECTOR_PATH_KINDS.includes(path.kind)) {
|
|
problems.push(`${path.id}: invalid kind ${path.kind}`);
|
|
}
|
|
if (typeof path.label !== "string" || path.label.length === 0) {
|
|
problems.push(`${path.id}: missing label`);
|
|
}
|
|
if (path.probeId !== null && !VALID_PROBE_IDS.has(path.probeId)) {
|
|
problems.push(`${path.id}: unknown probeId ${path.probeId}`);
|
|
}
|
|
if (
|
|
typeof path.probeEndpoint !== "string" ||
|
|
path.probeEndpoint.length === 0
|
|
) {
|
|
problems.push(
|
|
`${path.id}: every path must name its free/cheap probe endpoint`,
|
|
);
|
|
}
|
|
if (
|
|
path.oneClick !== null &&
|
|
!ONE_CLICK_TYPES.includes(path.oneClick.type)
|
|
) {
|
|
problems.push(`${path.id}: invalid oneClick type ${path.oneClick?.type}`);
|
|
}
|
|
if (path.rolesVia !== null && !ROLES_VIA.includes(path.rolesVia)) {
|
|
problems.push(`${path.id}: invalid rolesVia ${path.rolesVia}`);
|
|
}
|
|
if (
|
|
(path.ownerVars.length > 0 || path.agentVars.length > 0) &&
|
|
path.rolesVia !== "env-slots"
|
|
) {
|
|
problems.push(`${path.id}: ownerVars/agentVars imply rolesVia env-slots`);
|
|
}
|
|
for (const name of [
|
|
...path.requiredAll,
|
|
...path.requiredAny,
|
|
...path.optional,
|
|
...path.ownerVars,
|
|
...path.agentVars,
|
|
]) {
|
|
if (!ENV_NAME_PATTERN.test(name)) {
|
|
problems.push(`${path.id}: malformed env name ${name}`);
|
|
}
|
|
}
|
|
validateAvailabilitySpec(path.availability, path.id, problems);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
{
|
|
const problems = validateConnectorPaths(CONNECTOR_PATHS);
|
|
if (problems.length > 0) {
|
|
throw new Error(
|
|
`connector-paths registry invalid:\n${problems.join("\n")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- CLI: evaluate availability on this machine (no secrets printed) -------------
|
|
|
|
const IS_MAIN =
|
|
import.meta.main || process.argv[1] === fileURLToPath(import.meta.url);
|
|
|
|
if (IS_MAIN) {
|
|
const rows = evaluateConnectorPaths();
|
|
if (process.argv.includes("--json")) {
|
|
console.log(JSON.stringify(rows, null, 2));
|
|
} else {
|
|
for (const row of rows) {
|
|
const mark = row.available ? "avail" : "skip ";
|
|
console.log(
|
|
`${mark} ${row.id.padEnd(24)} ${row.kind.padEnd(13)} ${row.reason ?? ""}`,
|
|
);
|
|
}
|
|
console.log(`\n${rows.length} paths, ${getFamilies().length} families`);
|
|
}
|
|
}
|