ada-agent 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/docs/enterprise.md +83 -0
- package/package.json +1 -1
- package/src/client/cli.ts +39 -2
- package/src/client/settings.ts +21 -3
- package/src/selfcheck.ts +73 -0
- package/src/server/enterprise.ts +346 -0
- package/src/server/index.ts +163 -25
- package/src/server/providers/anthropic.ts +10 -1
- package/src/server/providers/openai-compat.ts +23 -4
package/README.md
CHANGED
|
@@ -245,6 +245,13 @@ See **[docs/architecture.md](docs/architecture.md)** for the design (adapters, r
|
|
|
245
245
|
flow, file layout), **[docs/orchestration.md](docs/orchestration.md)** for the agent strategies, and
|
|
246
246
|
**[docs/integrations.md](docs/integrations.md)** for the HTTP API / SDK / ACP.
|
|
247
247
|
|
|
248
|
+
## Enterprise
|
|
249
|
+
|
|
250
|
+
`ada-server` doubles as an org **control plane**: per-user seat keys, an org policy (server-enforced
|
|
251
|
+
model allowlist + tool rules pushed to every client), per-user usage metering, and an audit log —
|
|
252
|
+
activated only when you create seats, file-backed, self-hosted in your own network. See
|
|
253
|
+
**[docs/enterprise.md](docs/enterprise.md)** for the 2-minute bootstrap.
|
|
254
|
+
|
|
248
255
|
## Benchmarks
|
|
249
256
|
|
|
250
257
|
ada can run **SWE-bench Verified** — it generates patches for real GitHub issues (one isolated repo
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Enterprise: seats, policy, metering, audit
|
|
2
|
+
|
|
3
|
+
`ada-server` doubles as an org **control plane**: per-user seat keys, an org policy the backend
|
|
4
|
+
enforces (and clients apply locally), per-user usage metering, and an audit log. It's all
|
|
5
|
+
file-backed under `~/.ada/server/` (override with `ADA_DATA_DIR`) — fine to ~50 seats; a database
|
|
6
|
+
is the upgrade path, not the starting point.
|
|
7
|
+
|
|
8
|
+
**Enterprise mode activates only when a seat exists or `ADA_ADMIN_KEY` is set.** With neither, the
|
|
9
|
+
backend behaves exactly as before (dev-open, or `ADA_CLIENT_KEYS`/login).
|
|
10
|
+
|
|
11
|
+
## Bootstrap (2 minutes)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. start the backend with a bootstrap admin key (any long random string)
|
|
15
|
+
export ADA_ADMIN_KEY=$(openssl rand -hex 24)
|
|
16
|
+
export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... # your provider keys
|
|
17
|
+
ada-server # banner shows: [ENTERPRISE (0 seats + admin key)]
|
|
18
|
+
|
|
19
|
+
# 2. create a seat per developer — the key is shown ONCE
|
|
20
|
+
curl -s -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" \
|
|
21
|
+
-d '{"name":"alice"}' http://localhost:8787/v1/users
|
|
22
|
+
# → { "key": "ada_sk_…", "name": "alice", "role": "dev", "note": "shown once — store it now" }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Each developer sets their seat key and points at the backend:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export ADA_BACKEND_URL=https://ada.yourcompany.com/v1
|
|
29
|
+
export ADA_CLIENT_KEY=ada_sk_…
|
|
30
|
+
ada
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Seats
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
GET /v1/users # list: name, role, keyPrefix, created, disabled (admin)
|
|
37
|
+
POST /v1/users # {"name":"bob","role":"dev"|"admin"} → full key, once (admin)
|
|
38
|
+
DELETE /v1/users/<keyPrefix> # disable a seat (≥12 chars of its key; kept for the audit trail) (admin)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Full keys are never listed after creation — only a 14-char prefix. `ADA_ADMIN_KEY` is the
|
|
42
|
+
break-glass admin; create an admin *seat* for day-to-day and keep the env key in a vault.
|
|
43
|
+
|
|
44
|
+
## Org policy
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
curl -X PUT -H "Authorization: Bearer $ADA_ADMIN_KEY" http://localhost:8787/v1/policy -d '{
|
|
48
|
+
"models": ["@cf/*", "claude-*"],
|
|
49
|
+
"permissions": [
|
|
50
|
+
{ "tool": "web_*", "action": "deny" },
|
|
51
|
+
{ "tool": "bash", "pattern": "*curl*", "action": "ask" }
|
|
52
|
+
]
|
|
53
|
+
}'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **`models`** — allowlist (`*` wildcards). Enforced **server-side** (403 + audit entry), so a
|
|
57
|
+
modified client can't route around it. Empty/absent = all models.
|
|
58
|
+
- **`permissions`** — tool rules **pushed to clients** (fetched from `GET /v1/policy` at startup —
|
|
59
|
+
interactive, `-p` headless, `serve`, and `acp` alike). Merged restrictive-wins with local config:
|
|
60
|
+
an org `deny` beats any local `allow`, an org `ask` upgrades a local `allow`, and an org `allow`
|
|
61
|
+
can never *loosen* a local deny or the default gating. **Honest caveat:** tool rules run in the
|
|
62
|
+
*client*, so they govern well-behaved clients — and only **model-allowlist** denials are audited
|
|
63
|
+
server-side; tool-rule outcomes are not visible to `/v1/audit`. The **hard, server-enforced**
|
|
64
|
+
guarantees are: authentication, the model allowlist, provider pinning (when an allowlist is set,
|
|
65
|
+
the client's `provider` hint is ignored so a request can't be re-routed off-policy), and metering.
|
|
66
|
+
|
|
67
|
+
## Usage & audit
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
GET /v1/usage?days=30 # totals + per-user + per-model {requests, promptTokens, completionTokens} (admin)
|
|
71
|
+
GET /v1/audit?limit=200 # seat_created / seat_disabled / policy_updated / policy_denied_model … (admin)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Metering is captured server-side by teeing every chat response (streamed or not) and recording the
|
|
75
|
+
upstream's reported token usage per user — clients can't underreport. Join with
|
|
76
|
+
`ada catalog` prices for cost.
|
|
77
|
+
|
|
78
|
+
## Deployment notes
|
|
79
|
+
|
|
80
|
+
- Run behind TLS (caddy/nginx) — seat keys travel as bearer tokens.
|
|
81
|
+
- `ADA_DATA_DIR` on a persistent volume; back it up (it's 4 small JSON/JSONL files).
|
|
82
|
+
- One deployment = one org. Multi-org/SaaS is deliberately out of scope for v1.
|
|
83
|
+
- Compliance paperwork (SOC 2, DPA) is process, not code — start it when a buyer asks.
|
package/package.json
CHANGED
package/src/client/cli.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { stdin, stdout } from "node:process";
|
|
9
10
|
import OpenAI from "openai";
|
|
@@ -13,7 +14,7 @@ import { expandPrompt, loadPrompts } from "./prompts.ts";
|
|
|
13
14
|
import { Session, list, type SessionMeta } from "./session.ts";
|
|
14
15
|
import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
|
|
15
16
|
import { deviceLogin, oauthConfig } from "../server/oauth.ts";
|
|
16
|
-
import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, type Settings } from "./settings.ts";
|
|
17
|
+
import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, setOrgPermissions, type PermRule, type Settings } from "./settings.ts";
|
|
17
18
|
import { getCommands, loadExtensions } from "./extensions.ts";
|
|
18
19
|
import { registerTool, setAsker } from "./tools.ts";
|
|
19
20
|
import { addRemoteSkill, loadSkills, registerSkillTool } from "./skills.ts";
|
|
@@ -368,6 +369,38 @@ async function loginFlow(provider: string): Promise<boolean> {
|
|
|
368
369
|
}
|
|
369
370
|
}
|
|
370
371
|
|
|
372
|
+
/** Fetch org policy from an enterprise backend and apply its tool rules locally (restrictive-wins
|
|
373
|
+
* merge in settings.permissionFor; the backend enforces the model allowlist regardless). Caches the
|
|
374
|
+
* last-good policy under ~/.ada so a transient fetch failure falls back to known rules instead of
|
|
375
|
+
* silently dropping them. No-op against a non-enterprise backend. */
|
|
376
|
+
async function applyOrgPolicy(): Promise<void> {
|
|
377
|
+
const cacheFile = join(homedir(), ".ada", "org-policy.json");
|
|
378
|
+
const enterprise = clientKey().startsWith("ada_sk_"); // a seat key ⇒ this is an enterprise backend
|
|
379
|
+
try {
|
|
380
|
+
const r = await fetch(`${BACKEND}/policy`, { headers: { authorization: `Bearer ${clientKey()}` }, signal: AbortSignal.timeout(3000) });
|
|
381
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
382
|
+
const policy = (await r.json()) as { permissions?: PermRule[] };
|
|
383
|
+
setOrgPermissions(policy.permissions ?? null);
|
|
384
|
+
try {
|
|
385
|
+
mkdirSync(join(homedir(), ".ada"), { recursive: true });
|
|
386
|
+
writeFileSync(cacheFile, JSON.stringify(policy));
|
|
387
|
+
} catch {
|
|
388
|
+
/* cache best-effort */
|
|
389
|
+
}
|
|
390
|
+
if (policy.permissions?.length) console.error(`\x1b[2m↳ org policy applied (${policy.permissions.length} rule${policy.permissions.length === 1 ? "" : "s"})\x1b[0m`);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (!enterprise) return; // non-enterprise backend — local rules only, silently
|
|
393
|
+
// Enterprise backend unreachable — fall back to the last policy we saw, and say so loudly.
|
|
394
|
+
try {
|
|
395
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf8")) as { permissions?: PermRule[] };
|
|
396
|
+
setOrgPermissions(cached.permissions ?? null);
|
|
397
|
+
console.error(`\x1b[33m[warn] could not fetch org policy (${e instanceof Error ? e.message : e}) — using cached rules.\x1b[0m`);
|
|
398
|
+
} catch {
|
|
399
|
+
console.error(`\x1b[33m[warn] could not fetch org policy (${e instanceof Error ? e.message : e}) and no cache — org tool rules NOT applied this session.\x1b[0m`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
371
404
|
/** Startup login check: probe the backend; if it says 401, offer to sign in and rebuild the client. */
|
|
372
405
|
async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
|
|
373
406
|
let status: number;
|
|
@@ -658,6 +691,7 @@ async function main(): Promise<void> {
|
|
|
658
691
|
registerSkillTool(loadSkills(trusted));
|
|
659
692
|
await loadMcpServers(trusted);
|
|
660
693
|
const client = makeClient();
|
|
694
|
+
await applyOrgPolicy(); // enterprise org rules apply to acp sessions too
|
|
661
695
|
let model = process.env.ADA_MODEL || settings.model || "";
|
|
662
696
|
if (!model) {
|
|
663
697
|
try {
|
|
@@ -741,6 +775,7 @@ async function main(): Promise<void> {
|
|
|
741
775
|
registerSkillTool(loadSkills(trusted));
|
|
742
776
|
await loadMcpServers(trusted);
|
|
743
777
|
const client = makeClient();
|
|
778
|
+
await applyOrgPolicy(); // enterprise org rules apply to serve sessions too
|
|
744
779
|
let model = (process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : "") || process.env.ADA_MODEL || settings.model || "";
|
|
745
780
|
if (!model) {
|
|
746
781
|
try {
|
|
@@ -1082,6 +1117,7 @@ async function main(): Promise<void> {
|
|
|
1082
1117
|
if (flags.print !== undefined) {
|
|
1083
1118
|
const trusted = isTrusted(process.cwd());
|
|
1084
1119
|
const settings = loadSettings(trusted);
|
|
1120
|
+
await applyOrgPolicy(); // org tool rules bind headless runs too (CI is the classic bypass path)
|
|
1085
1121
|
let pm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
|
|
1086
1122
|
if (!pm) {
|
|
1087
1123
|
try {
|
|
@@ -1125,6 +1161,7 @@ async function main(): Promise<void> {
|
|
|
1125
1161
|
const mcp = await loadMcpServers(includeProject);
|
|
1126
1162
|
|
|
1127
1163
|
client = await ensureAuth(rl, client); // always check login at startup; prompt if the backend says 401
|
|
1164
|
+
await applyOrgPolicy(); // enterprise backends push org tool rules; no-op otherwise
|
|
1128
1165
|
|
|
1129
1166
|
let session: Session;
|
|
1130
1167
|
let history: Msg[] = [];
|
package/src/client/settings.ts
CHANGED
|
@@ -66,9 +66,15 @@ export function setActiveAgentPermissions(rules: PermRule[] | null): void {
|
|
|
66
66
|
activeAgentPerms = rules;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// Org policy pushed by an enterprise backend (fetched from /v1/policy at startup). Merged
|
|
70
|
+
// restrictive-wins: an org "deny" beats any local "allow"; an org "ask" upgrades a local "allow".
|
|
71
|
+
// A local "deny" always stands — the org can tighten a user's setup, never loosen it.
|
|
72
|
+
let orgPerms: PermRule[] | null = null;
|
|
73
|
+
export function setOrgPermissions(rules: PermRule[] | null): void {
|
|
74
|
+
orgPerms = rules?.length ? rules : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function evalRules(rules: PermRule[], toolName: string, summary: string): PermAction | null {
|
|
72
78
|
let result: PermAction | null = null;
|
|
73
79
|
for (const r of rules) {
|
|
74
80
|
const toolOk = !r.tool || r.tool === toolName || globMatch(r.tool, toolName);
|
|
@@ -78,6 +84,18 @@ export function permissionFor(toolName: string, summary: string): PermAction | n
|
|
|
78
84
|
return result;
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
const STRICTNESS: Record<PermAction, number> = { allow: 0, ask: 1, deny: 2 };
|
|
88
|
+
|
|
89
|
+
/** Evaluate the configured permission rules for a tool call. null = no matching rule (use defaults). */
|
|
90
|
+
export function permissionFor(toolName: string, summary: string): PermAction | null {
|
|
91
|
+
const local = evalRules(activeAgentPerms ?? loadSettings(isTrusted(process.cwd())).permissions ?? [], toolName, summary);
|
|
92
|
+
if (!orgPerms) return local;
|
|
93
|
+
const org = evalRules(orgPerms, toolName, summary);
|
|
94
|
+
if (org === null) return local;
|
|
95
|
+
if (local === null) return org === "allow" ? null : org; // org can't LOOSEN the default gating, only tighten
|
|
96
|
+
return STRICTNESS[org] > STRICTNESS[local] ? org : local;
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
export function addTrust(dir: string): void {
|
|
82
100
|
const g = readJson(GLOBAL);
|
|
83
101
|
const dirs = new Set(g.trustedDirs ?? []);
|
package/src/selfcheck.ts
CHANGED
|
@@ -293,6 +293,79 @@ async function main(): Promise<void> {
|
|
|
293
293
|
assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// --- enterprise control plane: seats, policy, metering, audit (temp data dir, no HTTP) ---
|
|
297
|
+
{
|
|
298
|
+
const dir = join(tmpdir(), `ada-ent-${Date.now()}`);
|
|
299
|
+
const ent = await import("./server/enterprise.ts");
|
|
300
|
+
process.env.ADA_DATA_DIR = dir;
|
|
301
|
+
try {
|
|
302
|
+
assert.equal(ent.enterpriseMode(dir), false, "no seats + no admin key → enterprise mode off");
|
|
303
|
+
const key = ent.createSeat("alice", "admin", dir);
|
|
304
|
+
assert.ok(key.startsWith("ada_sk_") && key.length > 40, "seat keys are long and prefixed");
|
|
305
|
+
assert.equal(ent.enterpriseMode(dir), true, "a seat activates enterprise mode");
|
|
306
|
+
assert.deepEqual(ent.identifySeat(key, dir), { user: "alice", role: "admin" }, "seat key resolves to its identity");
|
|
307
|
+
assert.equal(ent.identifySeat("ada_sk_wrong", dir), null, "unknown key → null");
|
|
308
|
+
// The auth-bypass the review caught: Object.prototype keys must NOT authenticate.
|
|
309
|
+
for (const evil of ["toString", "constructor", "__proto__", "valueOf", "hasOwnProperty"]) {
|
|
310
|
+
assert.equal(ent.identifySeat(evil, dir), null, `prototype key "${evil}" must not authenticate`);
|
|
311
|
+
}
|
|
312
|
+
assert.equal(ent.listSeats(dir)[0]!.keyPrefix.length, 14, "listing exposes only a key prefix");
|
|
313
|
+
assert.equal(ent.disableSeat(key.slice(0, 8), dir), null, "too-short prefix refused");
|
|
314
|
+
assert.equal(ent.disableSeat(key.slice(0, 14), dir), "alice", "disable by unique prefix");
|
|
315
|
+
assert.equal(ent.identifySeat(key, dir), null, "disabled seat no longer authenticates");
|
|
316
|
+
|
|
317
|
+
assert.ok(ent.modelAllowed("claude-opus-4-8", {}), "empty policy allows everything");
|
|
318
|
+
const pol = { models: ["@cf/*", "claude-*"] };
|
|
319
|
+
assert.ok(ent.modelAllowed("@cf/moonshotai/kimi-k2.7-code", pol), "wildcard allowlist matches");
|
|
320
|
+
assert.ok(!ent.modelAllowed("gpt-5", pol), "non-listed model denied");
|
|
321
|
+
|
|
322
|
+
ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 100, completionTokens: 20 }, dir);
|
|
323
|
+
ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 50, completionTokens: 10 }, dir);
|
|
324
|
+
ent.appendUsage({ ts: Date.now() - 90 * 86_400_000, user: "old", model: "m1", provider: "p", promptTokens: 999, completionTokens: 999 }, dir);
|
|
325
|
+
const sum = ent.usageSummary(30, dir);
|
|
326
|
+
assert.equal(sum.byUser.alice!.requests, 2, "usage aggregates per user");
|
|
327
|
+
assert.equal(sum.totals.promptTokens, 150, "old rows fall outside the window");
|
|
328
|
+
|
|
329
|
+
assert.ok(ent.auditTail(10, dir).some((e) => e.event === "seat_created"), "audit log records seat creation");
|
|
330
|
+
|
|
331
|
+
const sse = 'data: {"choices":[]}\n\ndata: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"completion_tokens_details":{"reasoning_tokens":2}}}\n\ndata: [DONE]\n\n';
|
|
332
|
+
assert.deepEqual(ent.extractLastUsage(sse), { promptTokens: 11, completionTokens: 7 }, "usage extracted from SSE tail (nested details ok)");
|
|
333
|
+
assert.equal(ent.extractLastUsage("no usage here"), null, "no usage → null");
|
|
334
|
+
// A trailing "usage": null must not hide the real one earlier in the stream.
|
|
335
|
+
assert.deepEqual(ent.extractLastUsage('{"usage":{"prompt_tokens":5,"completion_tokens":3}}\n{"usage":null}'), { promptTokens: 5, completionTokens: 3 }, "trailing usage:null skipped, real one found");
|
|
336
|
+
|
|
337
|
+
// policy validation rejects malformed shapes, accepts good ones
|
|
338
|
+
assert.ok("error" in ent.validatePolicy({ models: [1, 2] }), "non-string models rejected");
|
|
339
|
+
assert.ok("error" in ent.validatePolicy({ permissions: [{ tool: "x" }] }), "permission without action rejected");
|
|
340
|
+
assert.ok("policy" in ent.validatePolicy({ models: ["@cf/*"], permissions: [{ tool: "bash", action: "deny" }] }), "valid policy accepted");
|
|
341
|
+
|
|
342
|
+
// corrupt users.json → CorruptStore (fail-closed), NOT an empty map that unlocks the backend
|
|
343
|
+
writeFileSync(join(dir, "users.json"), "{ this is not json");
|
|
344
|
+
assert.throws(() => ent.loadSeats(dir), (e: unknown) => e instanceof ent.CorruptStore, "corrupt users.json throws CorruptStore");
|
|
345
|
+
assert.equal(ent.enterpriseMode(dir), true, "corrupt store → still enterprise (locked), never open");
|
|
346
|
+
} finally {
|
|
347
|
+
delete process.env.ADA_DATA_DIR;
|
|
348
|
+
rmSync(dir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- org policy merge: restrictive wins, org can tighten but never loosen ---
|
|
353
|
+
{
|
|
354
|
+
const { permissionFor, setActiveAgentPermissions, setOrgPermissions } = await import("./client/settings.ts");
|
|
355
|
+
setActiveAgentPermissions([{ tool: "bash", action: "allow" }]);
|
|
356
|
+
setOrgPermissions([{ tool: "bash", action: "deny" }]);
|
|
357
|
+
assert.equal(permissionFor("bash", "x"), "deny", "org deny beats local allow");
|
|
358
|
+
setOrgPermissions([{ tool: "bash", action: "ask" }]);
|
|
359
|
+
assert.equal(permissionFor("bash", "x"), "ask", "org ask upgrades local allow");
|
|
360
|
+
setActiveAgentPermissions([{ tool: "bash", action: "deny" }]);
|
|
361
|
+
setOrgPermissions([{ tool: "bash", action: "allow" }]);
|
|
362
|
+
assert.equal(permissionFor("bash", "x"), "deny", "org allow cannot loosen a local deny");
|
|
363
|
+
setActiveAgentPermissions([]);
|
|
364
|
+
assert.equal(permissionFor("bash", "x"), null, "org allow cannot loosen the default gating");
|
|
365
|
+
setOrgPermissions(null);
|
|
366
|
+
setActiveAgentPermissions(null);
|
|
367
|
+
}
|
|
368
|
+
|
|
296
369
|
// --- @codebase semantic search: pure parts (no network / no embedding model needed) ---
|
|
297
370
|
{
|
|
298
371
|
const { chunkText, cosine, walkFiles } = await import("./client/embed-index.ts");
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Enterprise control plane: seats (per-user client keys), org policy, usage metering, audit log.
|
|
2
|
+
// One deployment = one org (it's self-hosted; multi-org is the SaaS upgrade path, not v1).
|
|
3
|
+
//
|
|
4
|
+
// Enterprise mode ACTIVATES when a seat exists or ADA_ADMIN_KEY is set — with neither, the backend
|
|
5
|
+
// behaves exactly as before (dev-open / ADA_CLIENT_KEYS / login). Bootstrap:
|
|
6
|
+
//
|
|
7
|
+
// ADA_ADMIN_KEY=<random> ada-server
|
|
8
|
+
// curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" localhost:8787/v1/users -d '{"name":"alice"}'
|
|
9
|
+
//
|
|
10
|
+
// Security posture (hardened after an adversarial review):
|
|
11
|
+
// - lookups are own-property + format-guarded (no prototype-key auth bypass);
|
|
12
|
+
// - writes are atomic (tmp + rename); a corrupt/unreadable store fails CLOSED (never dev-open);
|
|
13
|
+
// - key comparisons are timing-safe.
|
|
14
|
+
// ponytail: file-backed under ~/.ada/server — fine to ~50 seats. Postgres + rotating usage logs are
|
|
15
|
+
// the upgrade path when an org outgrows files (usageSummary/auditTail read whole files: OK to
|
|
16
|
+
// low-millions of rows, then rotate).
|
|
17
|
+
|
|
18
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
|
|
23
|
+
// Resolved once: an empty ADA_DATA_DIR means "unset", and a relative path can't scatter the auth
|
|
24
|
+
// store across working directories (which would itself be a fail-open).
|
|
25
|
+
const DATA_DIR = resolve(process.env.ADA_DATA_DIR || join(homedir(), ".ada", "server"));
|
|
26
|
+
function dataDir(): string {
|
|
27
|
+
return DATA_DIR;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Thrown when a store file exists but can't be read/parsed — callers must fail CLOSED, never open. */
|
|
31
|
+
export class CorruptStore extends Error {}
|
|
32
|
+
|
|
33
|
+
export interface Seat {
|
|
34
|
+
name: string;
|
|
35
|
+
role: "admin" | "dev";
|
|
36
|
+
created: string;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface PolicyRule {
|
|
40
|
+
tool?: string;
|
|
41
|
+
pattern?: string;
|
|
42
|
+
action: "allow" | "ask" | "deny";
|
|
43
|
+
}
|
|
44
|
+
export interface Policy {
|
|
45
|
+
models?: string[];
|
|
46
|
+
permissions?: PolicyRule[];
|
|
47
|
+
}
|
|
48
|
+
export interface UsageRow {
|
|
49
|
+
ts: number;
|
|
50
|
+
user: string;
|
|
51
|
+
model: string;
|
|
52
|
+
provider: string;
|
|
53
|
+
promptTokens: number;
|
|
54
|
+
completionTokens: number;
|
|
55
|
+
}
|
|
56
|
+
export interface Identity {
|
|
57
|
+
user: string;
|
|
58
|
+
role: "admin" | "dev";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const usersFile = (dir: string): string => join(dir, "users.json");
|
|
62
|
+
const policyFile = (dir: string): string => join(dir, "policy.json");
|
|
63
|
+
const usageFile = (dir: string): string => join(dir, "usage.jsonl");
|
|
64
|
+
const auditFile = (dir: string): string => join(dir, "audit.jsonl");
|
|
65
|
+
|
|
66
|
+
function atomicWrite(file: string, data: string): void {
|
|
67
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
68
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
69
|
+
writeFileSync(tmp, data);
|
|
70
|
+
renameSync(tmp, file); // atomic on the same filesystem — a crash can't leave a torn file
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function errno(e: unknown): string | undefined {
|
|
74
|
+
return (e as NodeJS.ErrnoException).code;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Seats keyed by full key. Missing file → empty (prototype-free) map. Any OTHER read/parse error
|
|
78
|
+
* → CorruptStore (callers fail closed — a torn users.json must not silently disable auth). */
|
|
79
|
+
export function loadSeats(dir = dataDir()): Record<string, Seat> {
|
|
80
|
+
const map: Record<string, Seat> = Object.create(null); // no Object.prototype — belt-and-suspenders with the own-property check
|
|
81
|
+
let text: string;
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(usersFile(dir), "utf8");
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (errno(e) === "ENOENT") return map;
|
|
86
|
+
throw new CorruptStore(`users.json unreadable: ${e instanceof Error ? e.message : e}`);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const parsed = (JSON.parse(text) as { users?: Record<string, Seat> }).users ?? {};
|
|
90
|
+
for (const [k, v] of Object.entries(parsed)) map[k] = v;
|
|
91
|
+
return map;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
throw new CorruptStore(`users.json corrupt: ${e instanceof Error ? e.message : e}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveSeats(seats: Record<string, Seat>, dir = dataDir()): void {
|
|
98
|
+
atomicWrite(usersFile(dir), JSON.stringify({ users: seats }, null, 2));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Enterprise mode = admin key set, or seats exist. A corrupt seat store counts as enterprise
|
|
102
|
+
* (locked), never as "no seats" — fail closed. */
|
|
103
|
+
export function enterpriseMode(dir = dataDir()): boolean {
|
|
104
|
+
if (process.env.ADA_ADMIN_KEY) return true;
|
|
105
|
+
try {
|
|
106
|
+
return Object.keys(loadSeats(dir)).length > 0;
|
|
107
|
+
} catch {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function timingEqual(a: string, b: string): boolean {
|
|
113
|
+
const ab = Buffer.from(a);
|
|
114
|
+
const bb = Buffer.from(b);
|
|
115
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Resolve a bearer token to a seat identity (or the bootstrap admin). Null = not a seat. Throws
|
|
119
|
+
* CorruptStore if the seat store can't be read (caller returns 503, never dev-open). */
|
|
120
|
+
export function identifySeat(token: string, dir = dataDir()): Identity | null {
|
|
121
|
+
const admin = process.env.ADA_ADMIN_KEY;
|
|
122
|
+
if (admin && timingEqual(token, admin)) return { user: "admin", role: "admin" };
|
|
123
|
+
if (!token.startsWith("ada_sk_")) return null; // format guard — "toString"/"__proto__"/… never reach the map
|
|
124
|
+
const seats = loadSeats(dir); // may throw CorruptStore
|
|
125
|
+
if (!Object.prototype.hasOwnProperty.call(seats, token)) return null; // own-property only
|
|
126
|
+
const seat = seats[token]!;
|
|
127
|
+
return seat.disabled ? null : { user: seat.name, role: seat.role };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create a seat; returns its full key (shown once — only a prefix is ever listed again). */
|
|
131
|
+
export function createSeat(name: string, role: "admin" | "dev" = "dev", dir = dataDir()): string {
|
|
132
|
+
const key = `ada_sk_${randomBytes(24).toString("hex")}`;
|
|
133
|
+
const seats = loadSeats(dir);
|
|
134
|
+
seats[key] = { name, role, created: new Date().toISOString() };
|
|
135
|
+
saveSeats(seats, dir);
|
|
136
|
+
appendAudit({ ts: Date.now(), user: "-", event: "seat_created", detail: `${name} (${role})` }, dir);
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Disable (not delete — the audit trail keeps the history) the seat whose key starts with prefix. */
|
|
141
|
+
export function disableSeat(prefix: string, dir = dataDir()): string | null {
|
|
142
|
+
if (prefix.length < 12) return null; // too short to be safely unique
|
|
143
|
+
const seats = loadSeats(dir);
|
|
144
|
+
const keys = Object.keys(seats).filter((k) => k.startsWith(prefix));
|
|
145
|
+
if (keys.length !== 1) return null;
|
|
146
|
+
seats[keys[0]!]!.disabled = true;
|
|
147
|
+
saveSeats(seats, dir);
|
|
148
|
+
appendAudit({ ts: Date.now(), user: "-", event: "seat_disabled", detail: seats[keys[0]!]!.name }, dir);
|
|
149
|
+
return seats[keys[0]!]!.name;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Key prefixes + metadata for listing — full keys are never returned after creation. Display-only,
|
|
153
|
+
* so a corrupt store yields [] rather than crashing the banner. */
|
|
154
|
+
export function listSeats(dir = dataDir()): Array<Seat & { keyPrefix: string }> {
|
|
155
|
+
try {
|
|
156
|
+
return Object.entries(loadSeats(dir)).map(([k, s]) => ({ ...s, keyPrefix: k.slice(0, 14) }));
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let lastGoodPolicy: Policy | null = null;
|
|
163
|
+
/** Missing file → {} (no policy = allow all, legitimate). A corrupt EXISTING file → last-known-good
|
|
164
|
+
* if we have one, else CorruptStore (fail closed — a security control must not degrade to allow-all). */
|
|
165
|
+
export function loadPolicy(dir = dataDir()): Policy {
|
|
166
|
+
let text: string;
|
|
167
|
+
try {
|
|
168
|
+
text = readFileSync(policyFile(dir), "utf8");
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (errno(e) === "ENOENT") return {};
|
|
171
|
+
if (lastGoodPolicy) return lastGoodPolicy;
|
|
172
|
+
throw new CorruptStore(`policy.json unreadable: ${e instanceof Error ? e.message : e}`);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
lastGoodPolicy = JSON.parse(text) as Policy;
|
|
176
|
+
return lastGoodPolicy;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (lastGoodPolicy) return lastGoodPolicy;
|
|
179
|
+
throw new CorruptStore(`policy.json corrupt: ${e instanceof Error ? e.message : e}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function savePolicy(p: Policy, dir = dataDir()): void {
|
|
184
|
+
atomicWrite(policyFile(dir), JSON.stringify(p, null, 2));
|
|
185
|
+
lastGoodPolicy = p;
|
|
186
|
+
appendAudit({ ts: Date.now(), user: "-", event: "policy_updated", detail: JSON.stringify(p).slice(0, 300) }, dir);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Validate a policy shape from the wire. Returns the typed policy or an error message. */
|
|
190
|
+
export function validatePolicy(raw: unknown): { policy: Policy } | { error: string } {
|
|
191
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "policy must be a JSON object" };
|
|
192
|
+
const r = raw as Record<string, unknown>;
|
|
193
|
+
const out: Policy = {};
|
|
194
|
+
if (r.models !== undefined) {
|
|
195
|
+
if (!Array.isArray(r.models) || r.models.some((m) => typeof m !== "string" || !m.trim())) return { error: "models must be an array of non-empty strings" };
|
|
196
|
+
out.models = r.models as string[];
|
|
197
|
+
}
|
|
198
|
+
if (r.permissions !== undefined) {
|
|
199
|
+
if (!Array.isArray(r.permissions)) return { error: "permissions must be an array" };
|
|
200
|
+
for (const p of r.permissions) {
|
|
201
|
+
const rule = p as Record<string, unknown>;
|
|
202
|
+
if (!rule || typeof rule !== "object" || !["allow", "ask", "deny"].includes(rule.action as string)) return { error: "each permission needs action: allow|ask|deny" };
|
|
203
|
+
if (rule.tool !== undefined && typeof rule.tool !== "string") return { error: "permission.tool must be a string" };
|
|
204
|
+
if (rule.pattern !== undefined && typeof rule.pattern !== "string") return { error: "permission.pattern must be a string" };
|
|
205
|
+
}
|
|
206
|
+
out.permissions = r.permissions as PolicyRule[];
|
|
207
|
+
}
|
|
208
|
+
return { policy: out };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function globMatch(pattern: string, s: string): boolean {
|
|
212
|
+
const re = new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
|
|
213
|
+
return re.test(s);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Is this model allowed by org policy? No/empty allowlist = everything allowed. */
|
|
217
|
+
export function modelAllowed(model: string, policy: Policy): boolean {
|
|
218
|
+
if (!Array.isArray(policy.models) || !policy.models.length) return true;
|
|
219
|
+
return policy.models.some((p) => globMatch(p, model));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function appendUsage(row: UsageRow, dir = dataDir()): void {
|
|
223
|
+
try {
|
|
224
|
+
mkdirSync(dir, { recursive: true });
|
|
225
|
+
appendFileSync(usageFile(dir), `${JSON.stringify(row)}\n`);
|
|
226
|
+
} catch {
|
|
227
|
+
/* metering is best-effort; never fail a request over it */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface AuditRow {
|
|
232
|
+
ts: number;
|
|
233
|
+
user: string;
|
|
234
|
+
event: string;
|
|
235
|
+
detail: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function appendAudit(row: AuditRow, dir = dataDir()): void {
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(dir, { recursive: true });
|
|
241
|
+
appendFileSync(auditFile(dir), `${JSON.stringify(row)}\n`);
|
|
242
|
+
} catch {
|
|
243
|
+
/* best-effort */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function auditTail(limit = 200, dir = dataDir()): AuditRow[] {
|
|
248
|
+
let lines: string[];
|
|
249
|
+
try {
|
|
250
|
+
lines = readFileSync(auditFile(dir), "utf8").split("\n").filter(Boolean);
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
const out: AuditRow[] = [];
|
|
255
|
+
for (const l of lines.slice(-limit)) {
|
|
256
|
+
try {
|
|
257
|
+
out.push(JSON.parse(l) as AuditRow); // skip a torn last line instead of losing the whole view
|
|
258
|
+
} catch {
|
|
259
|
+
/* skip corrupt line */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface Bucket {
|
|
266
|
+
requests: number;
|
|
267
|
+
promptTokens: number;
|
|
268
|
+
completionTokens: number;
|
|
269
|
+
}
|
|
270
|
+
export interface UsageSummary {
|
|
271
|
+
since: number;
|
|
272
|
+
totals: Bucket;
|
|
273
|
+
byUser: Record<string, Bucket>;
|
|
274
|
+
byModel: Record<string, Bucket>;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function usageSummary(days = 30, dir = dataDir()): UsageSummary {
|
|
278
|
+
const since = Date.now() - days * 86_400_000;
|
|
279
|
+
const zero = (): Bucket => ({ requests: 0, promptTokens: 0, completionTokens: 0 });
|
|
280
|
+
const out: UsageSummary = { since, totals: zero(), byUser: Object.create(null), byModel: Object.create(null) };
|
|
281
|
+
let lines: string[] = [];
|
|
282
|
+
try {
|
|
283
|
+
lines = readFileSync(usageFile(dir), "utf8").split("\n").filter(Boolean);
|
|
284
|
+
} catch {
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
for (const l of lines) {
|
|
288
|
+
let r: UsageRow;
|
|
289
|
+
try {
|
|
290
|
+
r = JSON.parse(l) as UsageRow;
|
|
291
|
+
} catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (r.ts < since) continue;
|
|
295
|
+
for (const b of [out.totals, (out.byUser[r.user] ??= zero()), (out.byModel[r.model] ??= zero())]) {
|
|
296
|
+
b.requests++;
|
|
297
|
+
b.promptTokens += r.promptTokens || 0;
|
|
298
|
+
b.completionTokens += r.completionTokens || 0;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function matchBraces(text: string, start: number): string | null {
|
|
305
|
+
let depth = 0;
|
|
306
|
+
let inStr = false;
|
|
307
|
+
let esc = false;
|
|
308
|
+
for (let i = start; i < text.length; i++) {
|
|
309
|
+
const c = text[i];
|
|
310
|
+
if (inStr) {
|
|
311
|
+
if (esc) esc = false;
|
|
312
|
+
else if (c === "\\") esc = true;
|
|
313
|
+
else if (c === '"') inStr = false;
|
|
314
|
+
} else if (c === '"') inStr = true;
|
|
315
|
+
else if (c === "{") depth++;
|
|
316
|
+
else if (c === "}" && --depth === 0) return text.slice(start, i + 1);
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Pull the LAST real `"usage": { … }` object out of streamed/response text. Skips a trailing
|
|
322
|
+
* `"usage": null` and keeps scanning backwards, so a null in a late frame doesn't hide a real one. */
|
|
323
|
+
export function extractLastUsage(text: string): { promptTokens: number; completionTokens: number } | null {
|
|
324
|
+
let at = text.lastIndexOf('"usage"');
|
|
325
|
+
while (at >= 0) {
|
|
326
|
+
const brace = text.indexOf("{", at + 7);
|
|
327
|
+
const colon = text.indexOf(":", at + 7);
|
|
328
|
+
if (brace >= 0 && colon >= 0 && text.slice(colon + 1, brace).trim() === "") {
|
|
329
|
+
const obj = matchBraces(text, brace);
|
|
330
|
+
if (obj) {
|
|
331
|
+
try {
|
|
332
|
+
const u = JSON.parse(obj) as { prompt_tokens?: number; completion_tokens?: number };
|
|
333
|
+
if (u.prompt_tokens != null || u.completion_tokens != null) return { promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0 };
|
|
334
|
+
} catch {
|
|
335
|
+
/* malformed — keep looking backwards */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
at = text.lastIndexOf('"usage"', at - 1);
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function storeExists(dir = dataDir()): boolean {
|
|
345
|
+
return existsSync(usersFile(dir)) || existsSync(policyFile(dir));
|
|
346
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
7
7
|
import { PORT, PROVIDERS, clientKeys, configuredProviders, isConfigured } from "./config.ts";
|
|
8
|
+
import { CorruptStore, type Identity, appendAudit, appendUsage, auditTail, createSeat, disableSeat, enterpriseMode, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, usageSummary, validatePolicy } from "./enterprise.ts";
|
|
8
9
|
import { allowedUsers, isAllowed, verifyIdentity } from "./identity.ts";
|
|
9
10
|
import { adapterFor } from "./providers/registry.ts";
|
|
10
11
|
import { route } from "./router.ts";
|
|
@@ -19,20 +20,32 @@ function readBody(req: IncomingMessage): Promise<string> {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function locked(): boolean {
|
|
22
|
-
return clientKeys() !== null || allowedUsers() !== null || !!process.env.ADA_REQUIRE_LOGIN;
|
|
23
|
+
return enterpriseMode() || clientKeys() !== null || allowedUsers() !== null || !!process.env.ADA_REQUIRE_LOGIN;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** Resolve a request to WHO is making it. Order: seat key / ADA_ADMIN_KEY (enterprise), legacy
|
|
27
|
+
* static client key, GitHub/Google login. With no auth configured, the backend is open (dev mode).
|
|
28
|
+
* Returns "corrupt" if the seat store can't be read — the caller MUST 503, never fall through to
|
|
29
|
+
* dev-open. Null = unauthorized. */
|
|
30
|
+
async function identify(req: IncomingMessage): Promise<Identity | "corrupt" | null> {
|
|
29
31
|
const h = req.headers["authorization"];
|
|
30
32
|
const token = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : "";
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
if (token) {
|
|
34
|
+
let seat: Identity | null;
|
|
35
|
+
try {
|
|
36
|
+
seat = identifySeat(token);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (e instanceof CorruptStore) return "corrupt";
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
if (seat) return seat;
|
|
42
|
+
// Legacy ADA_CLIENT_KEYS are NOT honored once seats/admin-key exist — enterprise supersedes them,
|
|
43
|
+
// so a disabled seat can't be resurrected via a still-configured shared key.
|
|
44
|
+
if (!enterpriseMode() && clientKeys()?.includes(token)) return { user: "team", role: "dev" };
|
|
45
|
+
const id = await verifyIdentity(token); // GitHub / Google login
|
|
46
|
+
if (id && isAllowed(id.user)) return { user: id.user, role: "dev" };
|
|
47
|
+
}
|
|
48
|
+
return locked() ? null : { user: "dev", role: "dev" }; // dev mode: open
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
function json(res: ServerResponse, status: number, obj: unknown): void {
|
|
@@ -49,7 +62,7 @@ async function handleModels(res: ServerResponse): Promise<void> {
|
|
|
49
62
|
json(res, 200, { object: "list", data });
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
65
|
+
async function handleChat(req: IncomingMessage, res: ServerResponse, who: Identity): Promise<void> {
|
|
53
66
|
const raw = await readBody(req);
|
|
54
67
|
let body: Record<string, unknown>;
|
|
55
68
|
try {
|
|
@@ -61,32 +74,90 @@ async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<vo
|
|
|
61
74
|
const model = String(body.model ?? "");
|
|
62
75
|
if (!model) return json(res, 400, { error: { message: "missing 'model'" } });
|
|
63
76
|
|
|
64
|
-
|
|
77
|
+
// Org policy: model allowlist (enterprise). Enforced server-side so a modified client can't skip it.
|
|
78
|
+
let policy: import("./enterprise.ts").Policy;
|
|
79
|
+
try {
|
|
80
|
+
policy = loadPolicy();
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests (fail-closed)" } });
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
if (!modelAllowed(model, policy)) {
|
|
86
|
+
appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: model });
|
|
87
|
+
return json(res, 403, { error: { message: `model '${model}' is not allowed by org policy (allowed: ${policy.models!.join(", ")})` } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// When an allowlist is active, IGNORE the client's `provider` hint — else a seat holder could
|
|
91
|
+
// send an allowlisted model id with a different provider and leak the body to it before the
|
|
92
|
+
// upstream rejects the id. Route by the model id only.
|
|
93
|
+
const explicit = policy.models?.length ? undefined : typeof body.provider === "string" ? body.provider : undefined;
|
|
94
|
+
const provider = route(model, explicit);
|
|
65
95
|
if (!isConfigured(provider)) {
|
|
66
96
|
return json(res, 400, {
|
|
67
97
|
error: { message: `provider '${provider}' not configured — set ${PROVIDERS[provider].keyEnv} on the backend` },
|
|
68
98
|
});
|
|
69
99
|
}
|
|
70
100
|
|
|
101
|
+
// Metering must not be client-suppressible: force the upstream to emit a usage object on streams
|
|
102
|
+
// (OpenAI-compat only sends it when include_usage is set). Harmless for providers that ignore it.
|
|
103
|
+
if (body.stream) body.stream_options = { ...((body.stream_options as Record<string, unknown>) ?? {}), include_usage: true };
|
|
104
|
+
|
|
105
|
+
// Usage metering: tee the response (streamed or not) and record the last usage object the
|
|
106
|
+
// upstream reported. Wrapping res keeps this in ONE place for every adapter.
|
|
107
|
+
{
|
|
108
|
+
let tail = "";
|
|
109
|
+
const scan = (c: unknown): void => {
|
|
110
|
+
if (typeof c === "string" || Buffer.isBuffer(c)) tail = (tail + c.toString()).slice(-16_384);
|
|
111
|
+
};
|
|
112
|
+
const write = res.write.bind(res);
|
|
113
|
+
const end = res.end.bind(res);
|
|
114
|
+
res.write = ((c: never, ...a: never[]) => {
|
|
115
|
+
scan(c);
|
|
116
|
+
return write(c, ...a);
|
|
117
|
+
}) as typeof res.write;
|
|
118
|
+
res.end = ((c?: never, ...a: never[]) => {
|
|
119
|
+
scan(c);
|
|
120
|
+
const u = extractLastUsage(tail);
|
|
121
|
+
if (u) appendUsage({ ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: u.completionTokens });
|
|
122
|
+
return end(c, ...a);
|
|
123
|
+
}) as typeof res.end;
|
|
124
|
+
}
|
|
125
|
+
|
|
71
126
|
delete body.provider; // our routing hint; never forward it upstream
|
|
72
127
|
await adapterFor(provider).chat({ provider, model, body, res });
|
|
73
128
|
}
|
|
74
129
|
|
|
75
130
|
/** Embeddings for @codebase semantic search — forwarded to the ollama provider's
|
|
76
|
-
* OpenAI-compatible endpoint (embedding models only live there for now).
|
|
77
|
-
|
|
131
|
+
* OpenAI-compatible endpoint (embedding models only live there for now). Subject to the same org
|
|
132
|
+
* model allowlist as chat, and metered/attributed. */
|
|
133
|
+
async function handleEmbeddings(req: IncomingMessage, res: ServerResponse, who: Identity): Promise<void> {
|
|
78
134
|
const raw = await readBody(req);
|
|
135
|
+
let body: Record<string, unknown>;
|
|
79
136
|
try {
|
|
80
|
-
JSON.parse(raw);
|
|
137
|
+
body = JSON.parse(raw);
|
|
81
138
|
} catch {
|
|
82
139
|
return json(res, 400, { error: { message: "invalid JSON body" } });
|
|
83
140
|
}
|
|
141
|
+
const model = String(body.model ?? "");
|
|
142
|
+
let policy: import("./enterprise.ts").Policy;
|
|
143
|
+
try {
|
|
144
|
+
policy = loadPolicy();
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests" } });
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
if (model && !modelAllowed(model, policy)) {
|
|
150
|
+
appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: `embeddings:${model}` });
|
|
151
|
+
return json(res, 403, { error: { message: `embedding model '${model}' is not allowed by org policy` } });
|
|
152
|
+
}
|
|
84
153
|
const upstream = await fetch(`${PROVIDERS.ollama.baseURL}/embeddings`, {
|
|
85
154
|
method: "POST",
|
|
86
155
|
headers: { "content-type": "application/json" },
|
|
87
156
|
body: raw,
|
|
88
157
|
});
|
|
89
158
|
const text = await upstream.text();
|
|
159
|
+
const u = extractLastUsage(text); // embedding responses report prompt_tokens
|
|
160
|
+
if (u) appendUsage({ ts: Date.now(), user: who.user, model, provider: "ollama", promptTokens: u.promptTokens, completionTokens: 0 });
|
|
90
161
|
res.writeHead(upstream.status, { "content-type": "application/json" });
|
|
91
162
|
res.end(text);
|
|
92
163
|
}
|
|
@@ -98,22 +169,85 @@ const server = createServer(async (req, res) => {
|
|
|
98
169
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
99
170
|
return res.end("ada backend ok");
|
|
100
171
|
}
|
|
172
|
+
const who = await identify(req);
|
|
173
|
+
if (who === "corrupt") return json(res, 503, { error: { message: "auth store unreadable — refusing all requests (fail-closed). Fix ~/.ada/server/users.json." } });
|
|
174
|
+
if (!who) return json(res, 401, { error: { message: "unauthorized — invalid client key, seat key, or login" } });
|
|
175
|
+
|
|
101
176
|
if (req.method === "GET" && url.pathname === "/v1/whoami") {
|
|
102
|
-
|
|
103
|
-
return json(res, 200, { ok: true });
|
|
177
|
+
return json(res, 200, { ok: true, user: who.user, role: who.role });
|
|
104
178
|
}
|
|
105
179
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
106
|
-
if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
|
|
107
180
|
return await handleModels(res);
|
|
108
181
|
}
|
|
109
182
|
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
110
|
-
|
|
111
|
-
return await handleChat(req, res);
|
|
183
|
+
return await handleChat(req, res, who);
|
|
112
184
|
}
|
|
113
185
|
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
|
|
114
|
-
|
|
115
|
-
return await handleEmbeddings(req, res);
|
|
186
|
+
return await handleEmbeddings(req, res, who);
|
|
116
187
|
}
|
|
188
|
+
|
|
189
|
+
// ---- enterprise control plane ----
|
|
190
|
+
if (url.pathname === "/v1/policy") {
|
|
191
|
+
if (req.method === "GET") {
|
|
192
|
+
// any seat — clients fetch this and apply the tool rules locally
|
|
193
|
+
let policy: unknown;
|
|
194
|
+
try {
|
|
195
|
+
policy = loadPolicy();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable" } });
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
appendAudit({ ts: Date.now(), user: who.user, event: "policy_fetched", detail: "" }); // spot seats that never fetch
|
|
201
|
+
return json(res, 200, policy);
|
|
202
|
+
}
|
|
203
|
+
if (req.method === "PUT") {
|
|
204
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
205
|
+
let parsed: unknown;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(await readBody(req));
|
|
208
|
+
} catch {
|
|
209
|
+
return json(res, 400, { error: { message: "invalid JSON body" } });
|
|
210
|
+
}
|
|
211
|
+
const v = validatePolicy(parsed);
|
|
212
|
+
if ("error" in v) return json(res, 400, { error: { message: v.error } });
|
|
213
|
+
savePolicy(v.policy);
|
|
214
|
+
return json(res, 200, { ok: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (url.pathname === "/v1/users") {
|
|
218
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
219
|
+
if (req.method === "GET") return json(res, 200, { users: listSeats() });
|
|
220
|
+
if (req.method === "POST") {
|
|
221
|
+
let name = "";
|
|
222
|
+
let role: "admin" | "dev" = "dev";
|
|
223
|
+
try {
|
|
224
|
+
const b = JSON.parse(await readBody(req)) as { name?: string; role?: string };
|
|
225
|
+
name = String(b.name ?? "").trim();
|
|
226
|
+
if (b.role === "admin") role = "admin";
|
|
227
|
+
} catch {
|
|
228
|
+
/* falls through to the name check */
|
|
229
|
+
}
|
|
230
|
+
if (!name) return json(res, 400, { error: { message: "missing 'name'" } });
|
|
231
|
+
return json(res, 200, { key: createSeat(name, role), name, role, note: "shown once — store it now" });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
{
|
|
235
|
+
const m = req.method === "DELETE" && url.pathname.match(/^\/v1\/users\/([\w]+)$/);
|
|
236
|
+
if (m) {
|
|
237
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
238
|
+
const name = disableSeat(m[1]!);
|
|
239
|
+
return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "unknown or ambiguous key prefix (send ≥12 chars)" } });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (req.method === "GET" && url.pathname === "/v1/usage") {
|
|
243
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
244
|
+
return json(res, 200, usageSummary(Math.min(Number(url.searchParams.get("days")) || 30, 365)));
|
|
245
|
+
}
|
|
246
|
+
if (req.method === "GET" && url.pathname === "/v1/audit") {
|
|
247
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
248
|
+
return json(res, 200, { events: auditTail(Math.min(Number(url.searchParams.get("limit")) || 200, 2000)) });
|
|
249
|
+
}
|
|
250
|
+
|
|
117
251
|
return json(res, 404, { error: { message: "not found" } });
|
|
118
252
|
} catch (err) {
|
|
119
253
|
if (!res.headersSent) json(res, 500, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
@@ -127,9 +261,13 @@ const server = createServer(async (req, res) => {
|
|
|
127
261
|
});
|
|
128
262
|
|
|
129
263
|
server.listen(PORT, () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
264
|
+
if (enterpriseMode() && clientKeys()) console.warn("\x1b[33m[warn] ADA_CLIENT_KEYS is set but ignored in enterprise mode (seats supersede it) — unset it to avoid confusion.\x1b[0m");
|
|
265
|
+
const seats = listSeats().filter((s) => !s.disabled).length;
|
|
266
|
+
const auth = enterpriseMode()
|
|
267
|
+
? `ENTERPRISE (${seats} seat${seats === 1 ? "" : "s"}${process.env.ADA_ADMIN_KEY ? " + admin key" : ""})`
|
|
268
|
+
: locked()
|
|
269
|
+
? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
|
|
270
|
+
: "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ADMIN_KEY to lock down";
|
|
133
271
|
const provs = configuredProviders();
|
|
134
272
|
console.log(`ada backend → http://localhost:${PORT} [${auth}]`);
|
|
135
273
|
console.log(`providers: ${provs.length ? provs.join(", ") : "(none configured — set provider API keys)"}`);
|
|
@@ -114,6 +114,8 @@ export const anthropicAdapter: Adapter = {
|
|
|
114
114
|
|
|
115
115
|
let stop = "stop";
|
|
116
116
|
let toolIndex = -1;
|
|
117
|
+
let inTokens = 0; // Anthropic reports input on message_start, cumulative output on message_delta
|
|
118
|
+
let outTokens = 0;
|
|
117
119
|
|
|
118
120
|
try {
|
|
119
121
|
const client = await getClient();
|
|
@@ -148,7 +150,9 @@ export const anthropicAdapter: Adapter = {
|
|
|
148
150
|
);
|
|
149
151
|
|
|
150
152
|
for await (const event of stream) {
|
|
151
|
-
if (event.type === "
|
|
153
|
+
if (event.type === "message_start") {
|
|
154
|
+
inTokens = (event.message as { usage?: { input_tokens?: number } }).usage?.input_tokens ?? 0;
|
|
155
|
+
} else if (event.type === "content_block_start") {
|
|
152
156
|
const cb = event.content_block as { type: string; id?: string; name?: string };
|
|
153
157
|
if (cb.type === "tool_use") {
|
|
154
158
|
toolIndex++;
|
|
@@ -161,10 +165,15 @@ export const anthropicAdapter: Adapter = {
|
|
|
161
165
|
} else if (event.type === "message_delta") {
|
|
162
166
|
const reason = (event.delta as { stop_reason?: string | null }).stop_reason;
|
|
163
167
|
if (reason) stop = mapStop(reason);
|
|
168
|
+
const ot = (event as { usage?: { output_tokens?: number } }).usage?.output_tokens;
|
|
169
|
+
if (typeof ot === "number") outTokens = ot; // cumulative — take the latest
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
chunk({}, stop);
|
|
174
|
+
// Emit an OpenAI-shaped usage chunk so the backend's metering (and the client's own token
|
|
175
|
+
// counters) work for Claude too — Anthropic doesn't send one in this wire format.
|
|
176
|
+
writeChunk(res, { id, object: "chat.completion.chunk", created, model, choices: [], usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens } });
|
|
168
177
|
endStream(res);
|
|
169
178
|
} catch (err) {
|
|
170
179
|
chunk({ content: `\n[backend: anthropic error: ${err instanceof Error ? err.message : String(err)}]` }, "stop");
|
|
@@ -42,14 +42,25 @@ export const openAICompatAdapter: Adapter = {
|
|
|
42
42
|
// endpoint wants the bare id. (Cloudflare's "@cf/…" ids aren't "cloudflare/…", so they pass through.)
|
|
43
43
|
const prefix = `${provider}/`;
|
|
44
44
|
const outBody = typeof body.model === "string" && body.model.startsWith(prefix) ? { ...body, model: body.model.slice(prefix.length) } : body;
|
|
45
|
+
// If the client goes away, abort the upstream too — else the full completion is generated,
|
|
46
|
+
// billed, and (for enterprise) metered against a request nobody is reading.
|
|
47
|
+
const ac = new AbortController();
|
|
48
|
+
res.on("close", () => {
|
|
49
|
+
if (!res.writableEnded) ac.abort();
|
|
50
|
+
});
|
|
45
51
|
let upstream: Awaited<ReturnType<typeof fetch>>;
|
|
46
52
|
try {
|
|
47
53
|
upstream = await fetch(`${def.baseURL}/chat/completions`, {
|
|
48
54
|
method: "POST",
|
|
49
55
|
headers: { "content-type": "application/json", ...(await authHeaders(provider)) },
|
|
50
56
|
body: JSON.stringify(outBody),
|
|
57
|
+
signal: ac.signal,
|
|
51
58
|
});
|
|
52
59
|
} catch (e) {
|
|
60
|
+
if (ac.signal.aborted) {
|
|
61
|
+
res.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
53
64
|
res.writeHead(502, { "content-type": "application/json" });
|
|
54
65
|
res.end(
|
|
55
66
|
JSON.stringify({
|
|
@@ -71,10 +82,18 @@ export const openAICompatAdapter: Adapter = {
|
|
|
71
82
|
if (body.stream) {
|
|
72
83
|
res.writeHead(200, SSE_HEADERS);
|
|
73
84
|
const reader = upstream.body.getReader();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
try {
|
|
86
|
+
for (;;) {
|
|
87
|
+
const { done, value } = await reader.read();
|
|
88
|
+
if (done) break;
|
|
89
|
+
if (res.destroyed) {
|
|
90
|
+
await reader.cancel(); // client gone → stop pulling tokens from upstream
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (value) res.write(Buffer.from(value));
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
/* aborted mid-stream (client closed) — nothing more to do */
|
|
78
97
|
}
|
|
79
98
|
res.end();
|
|
80
99
|
} else {
|