Let a rotated cred.txt key win over the stale stored one

The credential file only seeded the database when it was empty, so once a
key was stored, rotating it in cred.txt changed nothing and Composio kept
rejecting the dead stored key with 401 - while the file held a freshly
generated, verified-working key. The file is now the source of truth
whenever it exists: a differing key replaces the stored one on resolution,
and packaged installs without the file keep managing the key in Settings.
Four resolution contract tests pin seed, rotation, settings-only, and
absent cases; the Connectors error line truncates instead of wrapping the
raw API response across the page.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 00:46:19 -04:00
1 parent 62e663ed90
commit 8fef89a22e
4 files changed
+100 -171

No files matched your search

@@ -3,30 +3,35 @@ import { join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
/**
* The org's Composio API key. When the database has none, a developer
* credential file next to the app (cred.txt, first line "Composio apikey:
* ak_...", gitignored) seeds it once for this org — the product owner's
* local path. Packaged installs configure the key in Settings instead; a
* missing file simply yields null.
* The org's Composio API key. A developer credential file next to the app
* (cred.txt, first line "Composio apikey: ak_...", gitignored) is the source
* of truth whenever it exists: rotating the key in the file takes effect on
* the next resolution, replacing whatever the database held. Packaged
* installs have no credential file and manage the key in Settings instead.
*/
export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentialFileDir: string = process.cwd()): string | null {
const stored = db.prepare(
"select value from composio_settings where org_id = ? and key = 'api_key' limit 1",
).get(orgId) as { value: string } | undefined;
if (stored?.value) return stored.value;
const path = join(credentialFileDir, "cred.txt");
if (!existsSync(path)) return null;
const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? "";
const key = firstLine.split(":").slice(1).join(":").trim();
if (!key) return null;
const fileKey = readCredentialFile(credentialFileDir);
if (!fileKey) return stored?.value ?? null;
if (stored?.value === fileKey) return fileKey;
const now = new Date().toISOString();
const upsert = db.prepare(`
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
`);
upsert.run(orgId, "api_key", key, now);
upsert.run(orgId, "api_key", fileKey, now);
upsert.run(orgId, "entity_id", orgId, now);
return key;
return fileKey;
}
function readCredentialFile(dir: string): string | null {
const path = join(dir, "cred.txt");
if (!existsSync(path)) return null;
const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? "";
const key = firstLine.split(":").slice(1).join(":").trim();
return key || null;
}
+13 -1
View File
@@ -123,7 +123,19 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
)}
{catalog.phase === "error" && (
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
<span style={{ color: "var(--text-2)", fontSize: 13 }}>{catalog.message}</span>
<span
style={{
color: "var(--text-2)",
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{catalog.message}
</span>
<Button size="small" onClick={() => {
setCatalogSlot(null);
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
-157
View File
@@ -1,157 +0,0 @@
import { useMemo, useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative } from "./types";
const DOC_TYPES = ["prd", "spec", "memo", "report", "runbook", "proposal", "policy", "guide", "template", "other"] as const;
const DOC_STATUSES = ["draft", "in_review", "approved", "archived"] as const;
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return text(form, key) || null;
}
export function DocumentsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const documents = useMemo(() => snapshot.documents.filter((d) => d.orgId === orgId), [snapshot.documents, orgId]);
const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]);
const projects = useMemo(() => snapshot.projects.filter((p) => p.orgId === orgId), [snapshot.projects, orgId]);
const goals = useMemo(() => snapshot.goals.filter((g) => g.orgId === orgId), [snapshot.goals, orgId]);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [filter, setFilter] = useState<string>("all");
const filtered = filter === "all" ? documents : documents.filter((d) => d.docType === filter);
const editing = editingId ? documents.find((d) => d.id === editingId) ?? null : null;
const create = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createDocument({
orgId,
title: text(form, "title"),
docType: text(form, "docType"),
content: text(form, "content"),
authorAgentId: optional(form, "authorAgentId"),
reviewerAgentId: null,
projectId: optional(form, "projectId"),
goalId: optional(form, "goalId"),
taskId: null,
})).then((r) => { if (r) setCreateOpen(false); });
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editing) return;
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.updateDocument({
orgId,
documentId: editing.id,
title: text(form, "title"),
content: text(form, "content"),
status: text(form, "status"),
})).then((r) => { if (r) setEditingId(null); });
};
const remove = (docId: string) => {
void actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: docId }));
setEditingId(null);
};
return (
<div className="page">
<TopbarActions>
<select className="button" value={filter} onChange={(e) => setFilter(e.target.value)} aria-label="Filter type">
<option value="all">All types</option>
{DOC_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={16} /> New document</Button>
</TopbarActions>
<h1 className="page-h1">Documents</h1>
<p className="page-sub">{documents.length} {documents.length === 1 ? "document" : "documents"}</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 2.5fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 60px" }}>
<div>Title</div><div>Type</div><div>Status</div><div>Updated</div><div />
</div>
{filtered.map((doc, index) => {
const author = agents.find((a) => a.id === doc.authorAgentId);
return (
<div
key={doc.id}
role="button"
tabIndex={0}
className="table-row clickable"
onClick={() => setEditingId(doc.id)}
onKeyDown={(e) => { if (e.key === "Enter") setEditingId(doc.id); }}
style={{ gridTemplateColumns: "minmax(0, 2.5fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 60px", borderBottom: index === filtered.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<div className="row" style={{ gap: 8, minWidth: 0 }}>
{author && <Avatar name={author.name} size="sm" bot />}
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{doc.title}</span>
</div>
<div><Pill>{doc.docType}</Pill></div>
<div><Pill tone={statusTone(doc.status)}>{doc.status}</Pill></div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(doc.updatedAt)}</div>
<div style={{ textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
<IconButton icon="x" label="Delete" onClick={() => remove(doc.id)} />
</div>
</div>
);
})}
</div>
<Dialog title="New document" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={create}>
<Field label="Title"><input className="input" name="title" required /></Field>
<Field label="Type">
<select className="input" name="docType" defaultValue="memo">
{DOC_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Content"><textarea className="input" name="content" rows={6} required /></Field>
<Field label="Author">
<select className="input" name="authorAgentId" defaultValue="">
<option value="">None</option>
{agents.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</Field>
<Field label="Project">
<select className="input" name="projectId" defaultValue="">
<option value="">None</option>
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Field>
<Field label="Goal">
<select className="input" name="goalId" defaultValue="">
<option value="">None</option>
{goals.map((g) => <option key={g.id} value={g.id}>{g.title}</option>)}
</select>
</Field>
</Dialog>
{editing && (
<Dialog key={editing.id} title={editing.title} open submitLabel="Save" onClose={() => setEditingId(null)} onSubmit={save}>
<Field label="Title"><input className="input" name="title" defaultValue={editing.title} required /></Field>
<Field label="Status">
<select className="input" name="status" defaultValue={editing.status}>
{DOC_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Content"><textarea className="input" name="content" rows={10} defaultValue={editing.content} required /></Field>
</Dialog>
)}
</div>
);
}
function statusTone(status: string): "amber" | "violet" | "green" | undefined {
if (status === "draft") return undefined;
if (status === "in_review") return "amber";
if (status === "approved") return "green";
return "violet";
}
+69
View File
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { resolveComposioApiKey } from "../../src/main/services/composio/composio-credentials";
interface Harness {
connection: DatabaseConnection;
orgId: string;
credDir: string;
}
function harness(): Harness {
const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
});
const credDir = join(tmpdir(), `ac-cred-dir-${randomUUID()}`);
mkdirSync(credDir, { recursive: true });
return { connection, orgId, credDir };
}
function storedKey(h: Harness): string | null {
const row = h.connection.db.prepare(
"select value from composio_settings where org_id = ? and key = 'api_key'",
).get(h.orgId) as { value: string } | undefined;
return row?.value ?? null;
}
function writeCred(h: Harness, key: string): void {
writeFileSync(join(h.credDir, "cred.txt"), `Composio apikey: ${key}\nProject id: pr_x\n`, "utf8");
}
describe("composio credential resolution", () => {
test("seeds the database from the credential file", () => {
const h = harness();
writeCred(h, "ak_first");
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBe("ak_first");
expect(storedKey(h)).toBe("ak_first");
});
test("a rotated credential file replaces the stale stored key", () => {
const h = harness();
writeCred(h, "ak_old");
resolveComposioApiKey(h.connection.db, h.orgId, h.credDir);
writeCred(h, "ak_rotated");
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBe("ak_rotated");
expect(storedKey(h)).toBe("ak_rotated");
});
test("without a credential file the stored key stands", () => {
const h = harness();
const now = new Date().toISOString();
h.connection.db.prepare(
"insert into composio_settings(org_id, key, value, updated_at) values (?, 'api_key', 'ak_settings', ?)",
).run(h.orgId, now);
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBe("ak_settings");
});
test("no file and no stored key yields null", () => {
const h = harness();
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBeNull();
});
});