Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1766,6 +1766,21 @@ Locked invariants (pinned by [src/tui/mcp/mcp-reducer.test.ts](src/tui/mcp/mcp-r
4. **The editor is disabled on the MCP tab while a modal is open.** `mcpTabBusy` in `app-key-bindings.ts` covers both `addModal !== null` (lets the `MultiLineEditor` capture every keystroke) and `removeConfirm !== null` (claims the `y`/`n` confirmation keys against the global nav cycler).
5. **Variant γ surface is opt-in but on by default.** Restarting the runtime is no longer required after add/remove — the prompt's `### tools` catalog and GBNF grammar are rebuilt on the next step. KV-cache for in-flight sessions is invalidated once per add/remove (the persona stays byte-stable; only the rendered tools block changes).

## Integrations hub

The `Integrations` tab ([src/tui/integrations/](src/tui/integrations/)) is the single place an operator puts third-party credentials. Before it, every integration grew its own surface — Telegram had a tab, LLM providers had a wizard, Composio had nothing — so "where do I put my key" required already knowing which kind of thing a given service was.

An integration declares itself as a **descriptor** ([src/integrations/integration-descriptor.ts](src/integrations/integration-descriptor.ts)): id, label, summary, docs URL, a list of credential fields, and a `status()` projection. The hub renders any descriptor without a bespoke pane, so adding an integration is a descriptor file plus one line in [integration-registry.ts](src/integrations/integration-registry.ts) — not a new TUI slice.

Locked invariants (pinned by [src/integrations/integration-secrets.test.ts](src/integrations/integration-secrets.test.ts), [src/integrations/integration-registry.test.ts](src/integrations/integration-registry.test.ts), [src/tui/integrations/integrations-panel-reducer.test.ts](src/tui/integrations/integrations-panel-reducer.test.ts), [src/tui/integrations/integrations-key-bindings.test.ts](src/tui/integrations/integrations-key-bindings.test.ts)):

1. **Credentials live in `<stateDir>/.env`, never in `config.json`.** `writeFieldValue` goes through `setDotenvKey` (0600, atomic) and updates `process.env` in the same breath, so the running process sees a new key without a restart. Field env vars must match `/^[A-Z_][A-Z0-9_]*$/` — the registry test pins this, because `setDotenvKey` would otherwise throw in front of the operator at save time.
2. **`IntegrationsOrchestrator` is the only module that touches credential storage or the live `McpManager` for this tab.** The reducer and component are pure; the key bindings only dispatch and call callbacks.
3. **A secret is never rendered in the clear except in the edit buffer being typed.** `displayFieldValue` masks and caps; starting an edit opens an *empty* buffer rather than seeding the stored value; `integrations_action_settled` clears the buffer so a key never lingers in UI state.
4. **Edit mode swallows the whole keyboard.** `d`, `e` and `r` are bindings on this tab; inside the editor they are key material. A paste that silently triggered "clear field" halfway through would be both baffling and destructive.
5. **A re-sync never yanks the cursor.** Rows are re-read on every refresh; the reducer clamps the selection instead of resetting it, so a background refresh cannot move the operator's place mid-edit.
6. **Changing a Composio key drops the cached tool-router session.** A session belongs to the key that created it; reusing it across a key swap would keep talking to the old account. The orchestrator unmounts, clears the cache, re-resolves, and remounts live.

## Composio (hosted toolkits)

[Composio](https://composio.dev) is a hosted catalogue of ~1500 SaaS toolkits (Gmail, Slack, Notion, Linear, Jira, …) that also brokers each app's OAuth. atomic-agent consumes it as **one more MCP server** rather than as a bespoke integration: a tool-router session yields a Streamable-HTTP MCP endpoint authenticated by a static `x-api-key` header, which is exactly the transport [src/mcp/](src/mcp/) already speaks. Code lives in [src/composio/](src/composio/); the cold-path wiring is a single `await resolveComposioServerConfig(...)` in [src/runtime/bootstrap.ts](src/runtime/bootstrap.ts) that appends at most one entry to the server list before `McpManager` is constructed.
Expand Down
55 changes: 55 additions & 0 deletions src/integrations/composio-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";

import { composioIntegration } from "./composio-integration.js";
import { COMPOSIO_API_KEY_ENV } from "../composio/index.js";

const KEY = "apiKey";

function ctx(present: string[], states?: Record<string, string>) {
return {
presentFields: new Set(present),
configured: present.includes(KEY),
...(states === undefined
? {}
: { mcpServerStates: new Map(Object.entries(states)) }),
};
}

describe("composioIntegration", () => {
it("needs exactly one required field: the API key", () => {
const required = composioIntegration.fields.filter((f) => f.required);
expect(required).toHaveLength(1);
expect(required[0]?.envVar).toBe(COMPOSIO_API_KEY_ENV);
expect(required[0]?.secret).toBe(true);
});

it("says plainly that no key means no tools", () => {
const status = composioIntegration.status(ctx([]));
expect(status.level).toBe("not_configured");
expect(status.detail).toMatch(/not loaded/);
});

it("reports connected once the MCP server is up", () => {
expect(composioIntegration.status(ctx([KEY], { composio: "up" }))).toEqual({
level: "connected",
detail: "connected",
});
});

it("distinguishes a saved key from a failed connection", () => {
// These must not look the same: one is "wait a moment", the other
// is "your key is wrong or Composio is down".
expect(composioIntegration.status(ctx([KEY])).level).toBe("configured");
expect(
composioIntegration.status(ctx([KEY], { composio: "down" })).level,
).toBe("error");
});

it("rejects a non-ASCII key at entry", () => {
// The key is sent as an x-api-key header; a smart quote from a
// copy-paste would otherwise blow up opaquely inside fetch.
const validate = composioIntegration.fields[0]?.validate;
expect(validate?.("ak_plain_ascii")).toBeUndefined();
expect(validate?.("ak_“fancy”")).toMatch(/ASCII/);
});
});
69 changes: 69 additions & 0 deletions src/integrations/composio-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Composio as an Integrations-hub tenant.
*
* One required field — the API key — because that is genuinely the
* whole setup: everything past it (which apps, which OAuth, which
* tools) is negotiated inside the session at the moment the operator
* asks for something. See AGENTS.md §"Composio".
*/

import { COMPOSIO_API_KEY_ENV, COMPOSIO_SERVER_NAME } from "../composio/index.js";
import { isAsciiOnly } from "../llm/provider/openai/ascii-header-guard.js";
import type {
IntegrationDescriptor,
IntegrationStatus,
IntegrationStatusContext,
} from "./integration-descriptor.js";
import { isConfigured } from "./integration-descriptor.js";

const COMPOSIO_KEY_FIELD = "apiKey";

export const composioIntegration: IntegrationDescriptor = {
id: "composio",
label: "Composio",
summary:
"~1500 SaaS toolkits (Gmail, Slack, Notion, Linear, Jira…) with OAuth handled for you",
docsUrl: "https://composio.dev",
appliesLive: true,
fields: [
{
key: COMPOSIO_KEY_FIELD,
label: "API key",
envVar: COMPOSIO_API_KEY_ENV,
secret: true,
required: true,
help: "Free tier: 100K tool calls/month. Get one at composio.dev.",
validate: (raw) => {
// The key is sent as an x-api-key header, and header values must
// be ASCII — catching it here names the problem, instead of
// letting fetch throw something opaque at connect time.
if (!isAsciiOnly(raw)) {
return "API key must be ASCII — check for a smart quote or stray character in the paste.";
}
return undefined;
},
},
],
status(ctx: IntegrationStatusContext): IntegrationStatus {
const configured = isConfigured(composioIntegration, ctx.presentFields);
if (!configured) {
return {
level: "not_configured",
detail: "no key — Composio tools are not loaded",
};
}
const state = ctx.mcpServerStates?.get(COMPOSIO_SERVER_NAME);
if (state === "up") {
return { level: "connected", detail: "connected" };
}
if (state === "down") {
return {
level: "error",
detail: "key saved, but the connection failed — see the MCP tab",
};
}
// Key present and no live signal yet: either the runtime has not
// reached the server, or it mounts on the next boot.
return { level: "configured", detail: "key saved" };
},
};
22 changes: 22 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Integrations hub — one place for every third-party credential.
* See AGENTS.md §"Integrations hub".
*/

export { basicStatus, isConfigured } from "./integration-descriptor.js";
export type {
IntegrationDescriptor,
IntegrationField,
IntegrationStatus,
IntegrationStatusContext,
IntegrationStatusLevel,
} from "./integration-descriptor.js";
export {
IntegrationSecretError,
displayFieldValue,
presentFieldKeys,
readFieldValue,
writeFieldValue,
} from "./integration-secrets.js";
export { findIntegration, listIntegrations } from "./integration-registry.js";
export { composioIntegration } from "./composio-integration.js";
103 changes: 103 additions & 0 deletions src/integrations/integration-descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* The contract every third-party integration declares itself with.
*
* Before this existed, each integration grew its own settings surface:
* Telegram had a tab, LLM providers had a wizard, Composio had nothing.
* An operator looking for "where do I put my key" had to already know
* which of those a given service was. A descriptor moves that knowledge
* into data, so the Integrations hub can render any integration —
* present or future — without a bespoke pane, and adding one is a
* descriptor plus a registry line rather than a TUI slice.
*
* Descriptors are pure data plus two pure functions. Nothing here reads
* config, touches the filesystem, or talks to a runtime; the hub's
* orchestrator owns all of that.
*/

/** One credential an integration needs. */
export interface IntegrationField {
/** Stable id, unique within the integration. */
key: string;
/** Human label, e.g. "API key". */
label: string;
/**
* Env var this field is stored under in `<stateDir>/.env`. Secrets
* never enter `config.json`; this is the same split Telegram's bot
* token and the LLM provider keys already use.
*/
envVar: string;
/** Mask the value in the UI and never log it. */
secret: boolean;
/** A field the integration cannot work without. */
required: boolean;
/** Short hint rendered under the input. */
help?: string;
/**
* Reject a bad value at entry. Returns an error message, or
* `undefined` when the value is acceptable.
*/
validate?: (raw: string) => string | undefined;
}

export type IntegrationStatusLevel =
| "not_configured"
| "configured"
| "connected"
| "error";

export interface IntegrationStatus {
level: IntegrationStatusLevel;
/** One line shown next to the badge. */
detail?: string;
}

/** What the hub knows at render time, passed to `status()`. */
export interface IntegrationStatusContext {
/** Field keys that currently resolve to a non-empty value. */
presentFields: ReadonlySet<string>;
/** Every required field has a value. */
configured: boolean;
/**
* Live MCP server states by server name, for integrations that mount
* one. Absent when the runtime is not available (e.g. in tests).
*/
mcpServerStates?: ReadonlyMap<string, string>;
}

export interface IntegrationDescriptor {
/** Stable id, also the `/integrations <id>` selector. */
id: string;
/** Display name, e.g. "Composio". */
label: string;
/** One line explaining what connecting this buys the operator. */
summary: string;
/** Where to get the credentials. */
docsUrl?: string;
fields: readonly IntegrationField[];
/**
* Whether a restart is needed for changes to take effect. The hub
* says so explicitly rather than leaving the operator to guess why
* nothing happened.
*/
appliesLive: boolean;
status: (ctx: IntegrationStatusContext) => IntegrationStatus;
}

/** Default status: configured-or-not, with no runtime signal. */
export function basicStatus(ctx: IntegrationStatusContext): IntegrationStatus {
return ctx.configured
? { level: "configured" }
: { level: "not_configured" };
}

/** Every required field of `descriptor` that has a value. */
export function isConfigured(
descriptor: IntegrationDescriptor,
presentFields: ReadonlySet<string>,
): boolean {
const required = descriptor.fields.filter((f) => f.required);
// An integration with no required fields is never "configured" by
// omission — that would badge an untouched entry as ready to use.
if (required.length === 0) return false;
return required.every((f) => presentFields.has(f.key));
}
64 changes: 64 additions & 0 deletions src/integrations/integration-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";

import { findIntegration, listIntegrations } from "./integration-registry.js";
import { isConfigured } from "./integration-descriptor.js";

describe("integration registry", () => {
it("lists Composio", () => {
expect(listIntegrations().map((i) => i.id)).toContain("composio");
});

it("gives every integration a unique id", () => {
const ids = listIntegrations().map((i) => i.id);
expect(new Set(ids).size).toBe(ids.length);
});

it("gives every field a unique key and env var within its integration", () => {
for (const integration of listIntegrations()) {
const keys = integration.fields.map((f) => f.key);
const envVars = integration.fields.map((f) => f.envVar);
expect(new Set(keys).size).toBe(keys.length);
expect(new Set(envVars).size).toBe(envVars.length);
}
});

it("names an env var the dotenv writer will accept", () => {
// setDotenvKey rejects anything outside /^[A-Z_][A-Z0-9_]*$/, and it
// throws at save time -- i.e. in front of the operator.
for (const integration of listIntegrations()) {
for (const field of integration.fields) {
expect(field.envVar).toMatch(/^[A-Z_][A-Z0-9_]*$/);
}
}
});

it("finds by id and returns undefined for an unknown one", () => {
expect(findIntegration("composio")?.label).toBe("Composio");
expect(findIntegration("nope")).toBeUndefined();
});
});

describe("isConfigured", () => {
it("is false when a required field is missing", () => {
const composio = findIntegration("composio")!;
expect(isConfigured(composio, new Set())).toBe(false);
expect(isConfigured(composio, new Set(["apiKey"]))).toBe(true);
});

it("never reports an integration with no required fields as configured", () => {
// Otherwise an untouched entry would badge itself ready to use.
expect(
isConfigured(
{
id: "x",
label: "X",
summary: "",
appliesLive: true,
fields: [],
status: () => ({ level: "not_configured" }),
},
new Set(),
),
).toBe(false);
});
});
23 changes: 23 additions & 0 deletions src/integrations/integration-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* The list of integrations the hub renders.
*
* A plain function rather than a module-level singleton, per
* AGENTS.md §"Layout rules" ("no global singletons — `getConfig()` is
* the only exception"). Adding an integration is one line here plus a
* descriptor file.
*/

import { composioIntegration } from "./composio-integration.js";
import type { IntegrationDescriptor } from "./integration-descriptor.js";

/** Every known integration, in display order. */
export function listIntegrations(): readonly IntegrationDescriptor[] {
return [composioIntegration];
}

/** Look one up by id. `undefined` when nothing matches. */
export function findIntegration(
id: string,
): IntegrationDescriptor | undefined {
return listIntegrations().find((i) => i.id === id);
}
Loading