Skip to content

Make Cap fully operable by agents#2020

Merged
richiemcilroy merged 58 commits into
mainfrom
agent-cap
Jul 19, 2026
Merged

Make Cap fully operable by agents#2020
richiemcilroy merged 58 commits into
mainfrom
agent-cap

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jul 18, 2026

Copy link
Copy Markdown
Member

Adds an additive Effect /api/v1, comprehensive Cap CLI and local stdio MCP surface, secure browser/PKCE authentication with OS-backed credentials, Agent Skill installers, server-enforced idempotency, lifecycle cleanup, and dashboard-equivalent account, library, organization, and developer operations. Includes release-binary Docker E2E coverage across MySQL, MinIO, media processing, permissions, authentication, CLI/MCP parity, security, and regression gates.

Greptile Summary

This PR adds a comprehensive agent API surface to Cap: a new /api/v1 Effect-based HTTP handler, PKCE browser authentication, OS-backed CLI credential storage, an MCP stdio interface, server-enforced idempotency via MySQL FOR UPDATE transactions, and a full suite of account/library/organization/developer operations.

  • All three issues flagged in the prior review have been resolved: lastUsedAt is now updated in the auth middleware (with a 5-minute throttle), the Rust callback listener returns a 405 response for non-GET requests, and LIKE search terms are now escaped with escapeAgentLikePattern and ESCAPE '!'.
  • Two new P2 observations: the cron cleanup route's string-length guard before timingSafeEqual can diverge from byte length for multi-byte UTF-8 characters (causing a throw instead of a 401), and the agentApiOperations cleanup only deletes terminal states, leaving any workflow-crashed records in \"running\" state indefinitely.

Confidence Score: 5/5

Safe to merge — the three previously-flagged defects are all addressed, auth and idempotency logic are sound, and the two remaining observations are non-blocking edge cases.

The core security paths (PKCE flow, token hashing, scope enforcement, idempotency correctness) are well-implemented. The cron timingSafeEqual edge case can only produce a 500 for malformed Authorization headers and does not allow authentication bypass. The stuck-operation cleanup gap requires a double failure scenario before it manifests operationally.

apps/web/lib/agent-api-cleanup.ts and apps/web/app/api/cron/cleanup-agent-api/route.ts contain the two minor issues noted in the comments.

Important Files Changed

Filename Overview
apps/web/app/api/v1/[...route]/route.ts New 8,604-line agent API handler with comprehensive capability system, scope-guarded mutations, and idempotency. One P2 concern: stuck async operations in running/queued state are never reaped by the cleanup job.
apps/web/lib/agent-api-cleanup.ts Handles expired auth codes, idempotency records, and access tokens correctly, but agentApiOperations deletion only targets terminal states, leaving orphaned running/queued operations to accumulate indefinitely.
apps/web/app/api/cron/cleanup-agent-api/route.ts Correctly uses timingSafeEqual and rejects missing CRON_SECRET, but string-length pre-check can diverge from byte-length for multi-byte UTF-8 characters, causing timingSafeEqual to throw.
packages/web-backend/src/AgentAuth.ts New auth middleware with proper SHA-256 token hashing, 5-minute lastUsedAt refresh throttle, scope validation, and legacy key path. All three issues from the previous review are resolved.
apps/web/lib/agent-write.ts Server-enforced idempotency using MySQL FOR UPDATE locks — insert-then-lock pattern correctly prevents concurrent duplicate mutations and provides deterministic replay.
apps/web/lib/agent-token.ts PKCE authorization code exchange is single-use, tokens are hashed before storage, and the revoke endpoint correctly scopes by principal ID.
apps/web/workflows/agent-cap-operation.ts Step-based durability pattern; try/catch transitions operations to failed state on any error, but if failOperation itself fails the record stays in running state permanently.
apps/web/app/cli/authorize/page.tsx Re-validates all form fields server-side, rate-limits per user, and only issues codes for loopback redirect URIs. CSRF covered by Next.js server action origin checking.
packages/database/schema.ts Adds agent API tables with appropriate indexes. Tokens stored as SHA-256 hashes, not plaintext.
apps/cli/src/agent_auth.rs Previously-flagged non-GET callback path now correctly sends a 405 Method Not Allowed response. PKCE flow and state validation look correct.

Reviews (5): Last reviewed commit: "fix: validate developer app logo URLs" | Re-trigger Greptile

Comment thread packages/web-backend/src/AgentAuth.ts
Comment thread apps/cli/src/agent_auth.rs
Comment thread apps/web/app/api/v1/[...route]/route.ts Outdated
Comment on lines +150 to +167
if (token.length !== 36) return yield* authRequired();

const [row] = yield* database.use((db) =>
db
.select({
id: Db.users.id,
email: Db.users.email,
activeOrganizationId: Db.users.activeOrganizationId,
source: Db.authApiKeys.source,
})
.from(Db.authApiKeys)
.innerJoin(Db.users, eq(Db.authApiKeys.userId, Db.users.id))
.where(eq(Db.authApiKeys.id, token))
.limit(1),
);
if (!row) return yield* authRequired();
if (!isLegacyAgentKeySource(row.source)) return yield* authRequired();
if (!isAgentReadAccessEnabled(row.email)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Legacy API keys are looked up by plaintext token value (as DB primary key)

The legacy path (token.length === 36) queries authApiKeys using eq(Db.authApiKeys.id, token) — meaning the token is the primary key and is stored in plaintext in the database. New cap_cli_* tokens are properly hashed with SHA-256 before storage. If the authApiKeys table is ever exfiltrated, all legacy session tokens are immediately usable by the attacker. This is a backward-compatibility constraint, but it is worth considering a migration path to hash these values and retire the plaintext storage.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web-backend/src/AgentAuth.ts
Line: 150-167

Comment:
**Legacy API keys are looked up by plaintext token value (as DB primary key)**

The legacy path (`token.length === 36`) queries `authApiKeys` using `eq(Db.authApiKeys.id, token)` — meaning the token _is_ the primary key and is stored in plaintext in the database. New `cap_cli_*` tokens are properly hashed with SHA-256 before storage. If the `authApiKeys` table is ever exfiltrated, all legacy session tokens are immediately usable by the attacker. This is a backward-compatibility constraint, but it is worth considering a migration path to hash these values and retire the plaintext storage.

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@@ -0,0 +1,227 @@
import { createHash } from "node:crypto";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: avoids relying on a global crypto (runtime/TS lib differences) and keeps UUID generation consistent with the other node:crypto usage.

Suggested change
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";

import { Effect, Layer, Schema } from "effect";
import { Database } from "./Database.ts";

const requestId = () => crypto.randomUUID();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Follow-up from the import change.)

Suggested change
const requestId = () => crypto.randomUUID();
const requestId = () => randomUUID();

});
}

if (token.length !== 36) return yield* authRequired();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enables legacy UUID-style tokens for the agent API. If you want an easy way to phase these out (or quickly disable them if a legacy key leaks) without a DB migration, a dedicated env kill-switch helps.

Suggested change
if (token.length !== 36) return yield* authRequired();
if (token.length !== 36) return yield* authRequired();
if (process.env.CAP_AGENT_API_DISABLE_LEGACY_KEYS === "true") {
return yield* authRequired();
}

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

database
.delete(agentApiKeys)
.where(
or(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch adding revokedAt cleanup. One perf thought: OR (expiresAt < cutoff OR revokedAt < cutoff) can prevent using the existing expires_at_idx, and there’s no index on revokedAt currently. Might be worth adding an index on revokedAt (or splitting this into two deletes) so the cron doesn’t degrade as agent_api_keys grows.

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment on lines +1 to +10
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

describe("self-hosted proxy routes", () => {
it("allows browser-based CLI authorization pages", () => {
const source = readFileSync(join(process.cwd(), "proxy.ts"), "utf8");
expect(source).toContain('path.startsWith("/cli/")');
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process.cwd() can vary depending on how vitest is invoked, so this can be a bit flaky. Using import.meta.url keeps the path stable.

Suggested change
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("self-hosted proxy routes", () => {
it("allows browser-based CLI authorization pages", () => {
const source = readFileSync(join(process.cwd(), "proxy.ts"), "utf8");
expect(source).toContain('path.startsWith("/cli/")');
});
});
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("self-hosted proxy routes", () => {
it("allows browser-based CLI authorization pages", () => {
const source = readFileSync(new URL("../../proxy.ts", import.meta.url), "utf8");
expect(source).toContain('path.startsWith("/cli/")');
});
});

@ringotokens-commits

Copy link
Copy Markdown

I found one security-sensitive gap in the new agent confirmation model.

The API advertises many write/destructive capabilities as requiring confirmation: "user" (for example signOutAllDevices is destructive), but the server-side enforcement in requireUserConfirmedRequest only checks whether the request includes X-Cap-Confirmation: user. The first-party CLI only adds that header after prompting, which is good for the happy path, but the bearer token holder can also send the same header directly from any HTTP client. That means a custom client, compromised MCP process, or injected agent with a scoped token can self-confirm protected operations, so the server is not actually enforcing a distinct user confirmation step.

If confirmation is intended as a security boundary, I think this needs a server-verifiable proof of confirmation instead of a spoofable header: e.g. a short-lived confirmation challenge minted by the dashboard/native client and bound to {tokenId, operation, idempotencyKey/body hash}, or else the capability metadata/errors should be renamed to make it explicit that this is only client-side UX friction. Otherwise users may grant broad scopes believing destructive actions still require a separate user-mediated approval.

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR


it("ignores tombstones during organization creation", () => {
const source = readFileSync(
join(process.cwd(), "app/api/v1/[...route]/route.ts"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process.cwd() can vary depending on how vitest is invoked, so these tests can get a bit flaky. Using import.meta.url keeps the path stable.

Suggested change
join(process.cwd(), "app/api/v1/[...route]/route.ts"),
const source = readFileSync(
new URL("../../app/api/v1/[...route]/route.ts", import.meta.url),
"utf8",
);

Comment thread apps/web/lib/agent-api.ts
try {
const url = new URL(value);
return (
(url.protocol === "http:" || url.protocol === "https:") &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to have a centralized URL guard. One extra hardening that tends to pay off: reject embedded credentials (https://user:pass@...) so we don’t end up storing/leaking them (logs, dashboards, etc.).

Suggested change
(url.protocol === "http:" || url.protocol === "https:") &&
(url.protocol === "http:" || url.protocol === "https:") &&
url.hostname.length > 0 &&
url.username.length === 0 &&
url.password.length === 0

Comment thread apps/web/lib/agent-api.ts
return ((hours * 60 + minutes) * 60 + seconds) * 1000 + milliseconds;
};

const stripVttCueTags = (value: string) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small perf win: most cue text probably has no tags. An early return avoids per-character allocations in that case.

Suggested change
const stripVttCueTags = (value: string) => {
const stripVttCueTags = (value: string) => {
if (!value.includes("<")) return value;
const text: string[] = [];
let insideTag = false;
for (const character of value) {
if (character === "<") {
insideTag = true;
continue;
}
if (insideTag) {
if (character === ">") insideTag = false;
continue;
}
text.push(character);
}
return text.join("");
};

@richiemcilroy
richiemcilroy merged commit c78872d into main Jul 19, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants