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:
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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user