feat(try-cli): free-trial gateway + signed-in BYO tier for AI demos#882
Conversation
Anonymous visitors can now try the AI coding agents with no login and no API key for a few minutes; signing in with AgentClash unlocks a longer session where they bring their own provider credentials. Safety model — our keys are NEVER injected into the sandbox (that violates provider terms and is trivially exfiltratable). Instead: - A metered passthrough gateway lives in the try-cli service (/gw/<provider>); the real ANTHROPIC/OPENAI/XAI keys live only there. - Each anonymous session is minted a short-lived, spend-capped proxy token (separate from the loggable session id). The sandbox CLIs send it as their key and point their base URL at the gateway; the gateway validates the token against a live in-budget session, swaps in the real key, streams the response straight back, and meters real token usage. - Per-session USD budget + a durable Redis-backed global daily ceiling (the hard backstop for a public no-login endpoint). Fails closed if Redis is down. Per-request output-token clamp + request body-size cap bound worst case. - Verified end-to-end in a real sandbox: claude/codex route through the gateway; invalid/expired tokens 401; spend accumulates and the per-session cap returns 402 once crossed. Tiers: - Anonymous: ~7 min, gateway-backed, wired for claude-code (Anthropic), codex (OpenAI, via config.toml), grok (xAI). opencode stays BYO-only for now. - Authenticated: ~20 min, BYO credentials (no gateway, no cost to us). The Vercel proxy forwards the signed-in user id behind a shared secret so the public service can't be spoofed into the authed tier. Frontend: free-trial badge + countdown, a "Sign in to continue" panel, and the BYO steps reframed as "use your own account" during a trial. Schema/wiring is config-driven; caps + session lengths are env-tunable. Docs updated with the gateway deploy config. Claude uses ANTHROPIC_AUTH_TOKEN (Bearer) to skip the first-use key-approval prompt.
Greptile SummaryThis PR introduces a metered passthrough gateway and a two-tier session model for the Try CLI demo: anonymous visitors get a short (~7 min) free trial backed by AgentClash's provider keys (spend-capped per session and by a Redis-backed daily ceiling), while signed-in users get a longer (~20 min) BYO-credentials session at no cost to the service.
Confidence Score: 3/5The gateway key-security model is sound — provider keys stay server-side and proxy tokens are properly scoped — but the The
|
| Filename | Overview |
|---|---|
| services/try-cli/server/index.ts | Adds sessionTier() helper and /gw/ routing. Critical logic bug: the PROXY_SECRET guard short-circuits to false when the env var is unset, granting the authenticated tier to any request that includes x-agentclash-user. |
| services/try-cli/server/gateway.ts | New metered passthrough gateway. Well-structured with good auth and header hygiene. Spend metering is async and does not reserve budget before forwarding, allowing multiple concurrent requests to race past the per-session cap. |
| services/try-cli/server/sessions.ts | Extends sessions with tier, proxyToken, budget tracking, and gateway wiring. Token generation and cleanup are correct. wireGatewayTrial is properly guarded and sets trialWired only after env injection. |
| services/try-cli/server/daily-ledger.ts | Redis-backed daily spend ledger with correct fail-closed get() behavior. add() falls back to 0 on error rather than Infinity, making the error mode inconsistent with the documented design. |
| web/src/app/api/try/[[...path]]/route.ts | Vercel proxy now adds x-agentclash-user and x-agentclash-proxy-secret headers when the user is signed in. Auth lookup via withAuth() is wrapped in try/catch so unauthenticated requests degrade gracefully. |
| web/src/components/try-cli/demo-client.tsx | Adds tier and trial state, "Free trial" / "Your account" badges, and the free-trial CTA card. All state is derived from server responses; no client-side trust decisions. |
Sequence Diagram
sequenceDiagram
participant Browser
participant Vercel as Vercel (Next.js proxy)
participant Service as Try-CLI Service (Railway)
participant E2B as E2B Sandbox
participant Provider as AI Provider
Browser->>Vercel: "POST /api/try/sessions {slug}"
Vercel->>Vercel: withAuth() → user?
Vercel->>Service: POST /api/sessions + x-agentclash-user + x-agentclash-proxy-secret
Service->>Service: "sessionTier() → anonymous|authenticated"
Service->>E2B: Sandbox.create()
Service->>Service: wireGatewayTrial() — inject ANTHROPIC_BASE_URL + proxyToken into PTY env
Service-->>Vercel: "{id, tier, expiresAt}"
Vercel-->>Browser: session info
Browser->>Service: "WS /ws?sessionId=…"
Service->>E2B: PTY attach (envs include proxyToken as API key)
E2B->>Service: POST /gw/anthropic/v1/messages (Authorization: Bearer proxyToken)
Service->>Service: validateGatewayToken() + budget check + daily ceiling check
Service->>Provider: forward with real ANTHROPIC_API_KEY (streaming)
Provider-->>Service: SSE stream
Service-->>E2B: stream (toClient)
Service->>Service: meter(toMeter) — parse usage → addGatewaySpend + daily.add()
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
services/try-cli/server/index.ts:20-27
**Auth tier bypass when `PROXY_SECRET` is unset**
The condition `PROXY_SECRET && ...` short-circuits to `undefined` (falsy) when the env var is not set, so the inner comparison is never evaluated. Any request that includes an `x-agentclash-user` header — even one sent directly to the Railway service by an attacker — then falls through to `return "authenticated"`. This is the opposite of the documented "safe default" (anonymous). An attacker who knows the header name can always claim the 20-minute tier, burning E2B compute, without needing any secret.
```suggestion
function sessionTier(req: Request): "anonymous" | "authenticated" {
const user = req.headers.get("x-agentclash-user");
if (!user) return "anonymous";
// Require the secret to be both configured AND matching. If it is unset, fall
// back to anonymous so a misconfigured deploy never auto-grants the authed tier.
if (!PROXY_SECRET || req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
return "anonymous";
}
return "authenticated";
}
```
### Issue 2 of 3
services/try-cli/server/gateway.ts:170-176
**Per-session budget can be exceeded by concurrent requests**
The budget check reads `session.gatewaySpentUsd` synchronously at request start, but the `meter()` function that updates it only runs after the full response stream is consumed. Two (or more) requests that start before either completes both see a spend of `0`, both pass the `>= gatewayBudgetUsd` check, and both get forwarded upstream — potentially consuming 2× (or more) the intended budget. The global daily ceiling is the real backstop, but the per-session cap can be silently exceeded.
```suggestion
// Per-session budget. Include already-reserved (in-flight) spend so concurrent
// requests can't bypass the cap while a prior request is still streaming.
if (session.gatewaySpentUsd + (session.gatewayReservedUsd ?? 0) >= session.gatewayBudgetUsd) {
return json(
{ error: "Free trial budget reached. Sign in with your own account to continue." },
402,
);
}
```
### Issue 3 of 3
services/try-cli/server/daily-ledger.ts:45-52
**`add()` returns `0` on Redis failure instead of `Infinity`**
`get()` correctly returns `Infinity` on Redis errors to fail closed, but `add()` falls back to `0`. Intermittent Redis failures during `add()` silently under-count daily spend, and the fail-closed guarantee then depends entirely on `get()` also failing — a narrower window than the design intends. Returning `Infinity` makes both methods consistent.
```suggestion
async add(usd) {
return safe(async () => {
const key = todayKey();
const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
await client.send("EXPIRE", [key, "172800"]); // 48h
return parseFloat(String(total));
}, Number.POSITIVE_INFINITY);
},
```
Reviews (1): Last reviewed commit: "feat(try-cli): free-trial gateway + sign..." | Re-trigger Greptile
| function sessionTier(req: Request): "anonymous" | "authenticated" { | ||
| const user = req.headers.get("x-agentclash-user"); | ||
| if (!user) return "anonymous"; | ||
| if (PROXY_SECRET && req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) { | ||
| return "anonymous"; // header present but unsigned — don't trust it | ||
| } | ||
| return "authenticated"; | ||
| } |
There was a problem hiding this comment.
Auth tier bypass when
PROXY_SECRET is unset
The condition PROXY_SECRET && ... short-circuits to undefined (falsy) when the env var is not set, so the inner comparison is never evaluated. Any request that includes an x-agentclash-user header — even one sent directly to the Railway service by an attacker — then falls through to return "authenticated". This is the opposite of the documented "safe default" (anonymous). An attacker who knows the header name can always claim the 20-minute tier, burning E2B compute, without needing any secret.
| function sessionTier(req: Request): "anonymous" | "authenticated" { | |
| const user = req.headers.get("x-agentclash-user"); | |
| if (!user) return "anonymous"; | |
| if (PROXY_SECRET && req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) { | |
| return "anonymous"; // header present but unsigned — don't trust it | |
| } | |
| return "authenticated"; | |
| } | |
| function sessionTier(req: Request): "anonymous" | "authenticated" { | |
| const user = req.headers.get("x-agentclash-user"); | |
| if (!user) return "anonymous"; | |
| // Require the secret to be both configured AND matching. If it is unset, fall | |
| // back to anonymous so a misconfigured deploy never auto-grants the authed tier. | |
| if (!PROXY_SECRET || req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) { | |
| return "anonymous"; | |
| } | |
| return "authenticated"; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/try-cli/server/index.ts
Line: 20-27
Comment:
**Auth tier bypass when `PROXY_SECRET` is unset**
The condition `PROXY_SECRET && ...` short-circuits to `undefined` (falsy) when the env var is not set, so the inner comparison is never evaluated. Any request that includes an `x-agentclash-user` header — even one sent directly to the Railway service by an attacker — then falls through to `return "authenticated"`. This is the opposite of the documented "safe default" (anonymous). An attacker who knows the header name can always claim the 20-minute tier, burning E2B compute, without needing any secret.
```suggestion
function sessionTier(req: Request): "anonymous" | "authenticated" {
const user = req.headers.get("x-agentclash-user");
if (!user) return "anonymous";
// Require the secret to be both configured AND matching. If it is unset, fall
// back to anonymous so a misconfigured deploy never auto-grants the authed tier.
if (!PROXY_SECRET || req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
return "anonymous";
}
return "authenticated";
}
```
How can I resolve this? If you propose a fix, please make it concise.| // Per-session budget. | ||
| if (session.gatewaySpentUsd >= session.gatewayBudgetUsd) { | ||
| return json( | ||
| { error: "Free trial budget reached. Sign in with your own account to continue." }, | ||
| 402, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Per-session budget can be exceeded by concurrent requests
The budget check reads session.gatewaySpentUsd synchronously at request start, but the meter() function that updates it only runs after the full response stream is consumed. Two (or more) requests that start before either completes both see a spend of 0, both pass the >= gatewayBudgetUsd check, and both get forwarded upstream — potentially consuming 2× (or more) the intended budget. The global daily ceiling is the real backstop, but the per-session cap can be silently exceeded.
| // Per-session budget. | |
| if (session.gatewaySpentUsd >= session.gatewayBudgetUsd) { | |
| return json( | |
| { error: "Free trial budget reached. Sign in with your own account to continue." }, | |
| 402, | |
| ); | |
| } | |
| // Per-session budget. Include already-reserved (in-flight) spend so concurrent | |
| // requests can't bypass the cap while a prior request is still streaming. | |
| if (session.gatewaySpentUsd + (session.gatewayReservedUsd ?? 0) >= session.gatewayBudgetUsd) { | |
| return json( | |
| { error: "Free trial budget reached. Sign in with your own account to continue." }, | |
| 402, | |
| ); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/try-cli/server/gateway.ts
Line: 170-176
Comment:
**Per-session budget can be exceeded by concurrent requests**
The budget check reads `session.gatewaySpentUsd` synchronously at request start, but the `meter()` function that updates it only runs after the full response stream is consumed. Two (or more) requests that start before either completes both see a spend of `0`, both pass the `>= gatewayBudgetUsd` check, and both get forwarded upstream — potentially consuming 2× (or more) the intended budget. The global daily ceiling is the real backstop, but the per-session cap can be silently exceeded.
```suggestion
// Per-session budget. Include already-reserved (in-flight) spend so concurrent
// requests can't bypass the cap while a prior request is still streaming.
if (session.gatewaySpentUsd + (session.gatewayReservedUsd ?? 0) >= session.gatewayBudgetUsd) {
return json(
{ error: "Free trial budget reached. Sign in with your own account to continue." },
402,
);
}
```
How can I resolve this? If you propose a fix, please make it concise.| async add(usd) { | ||
| return safe(async () => { | ||
| const key = todayKey(); | ||
| const total = await client.send("INCRBYFLOAT", [key, String(usd)]); | ||
| await client.send("EXPIRE", [key, "172800"]); // 48h | ||
| return parseFloat(String(total)); | ||
| }, 0); | ||
| }, |
There was a problem hiding this comment.
add() returns 0 on Redis failure instead of Infinity
get() correctly returns Infinity on Redis errors to fail closed, but add() falls back to 0. Intermittent Redis failures during add() silently under-count daily spend, and the fail-closed guarantee then depends entirely on get() also failing — a narrower window than the design intends. Returning Infinity makes both methods consistent.
| async add(usd) { | |
| return safe(async () => { | |
| const key = todayKey(); | |
| const total = await client.send("INCRBYFLOAT", [key, String(usd)]); | |
| await client.send("EXPIRE", [key, "172800"]); // 48h | |
| return parseFloat(String(total)); | |
| }, 0); | |
| }, | |
| async add(usd) { | |
| return safe(async () => { | |
| const key = todayKey(); | |
| const total = await client.send("INCRBYFLOAT", [key, String(usd)]); | |
| await client.send("EXPIRE", [key, "172800"]); // 48h | |
| return parseFloat(String(total)); | |
| }, Number.POSITIVE_INFINITY); | |
| }, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/try-cli/server/daily-ledger.ts
Line: 45-52
Comment:
**`add()` returns `0` on Redis failure instead of `Infinity`**
`get()` correctly returns `Infinity` on Redis errors to fail closed, but `add()` falls back to `0`. Intermittent Redis failures during `add()` silently under-count daily spend, and the fail-closed guarantee then depends entirely on `get()` also failing — a narrower window than the design intends. Returning `Infinity` makes both methods consistent.
```suggestion
async add(usd) {
return safe(async () => {
const key = todayKey();
const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
await client.send("EXPIRE", [key, "172800"]); // 48h
return parseFloat(String(total));
}, Number.POSITIVE_INFINITY);
},
```
How can I resolve this? If you propose a fix, please make it concise.- sessionTier: fall back to anonymous when TRY_CLI_PROXY_SECRET is unset (the previous `SECRET && ...` short-circuit auto-granted the authed tier to any request carrying the header on a misconfigured deploy). - gateway: hold a per-request spend reservation so concurrent requests can't all pass the per-session budget check before any finishes metering; released in meter()'s finally and on every early-return/error path. - daily-ledger: add() now fails closed (Infinity) on Redis error, matching get().
|
Addressed all three Greptile findings in 4005b67:
Verified: service transpiles clean. |
Summary
Adds the free trial + signed-in auth model for the Try CLI AI agents, the safe way. Follow-up to #881.
Why a gateway (and not key injection)
We never put our provider keys in the sandbox — that violates OpenAI/Anthropic terms and anyone in the shell can
catthe key and drain the account. Instead a metered passthrough gateway lives in the try-cli service:ANTHROPIC_/OPENAI_/XAI_API_KEYlive only on the service./gw/<provider>; the gateway validates it against a live, in-budget session, swaps in the real key, streams the response straight back, and meters real token usage.Verified end-to-end (real E2B sandbox)
model_providersconfig — the risky case, confirmed working).usage(forces identity encoding so it isn't reading gzip).tsc+eslint(0 errors) +next buildpass.Tiers / wiring
ANTHROPIC_AUTH_TOKEN+ base URL/loginconfig.tomlcustom provider--device-authGROK_BASE_URL+ tokenXAI_API_KEYDeploy (post-merge) — env to set
On the Railway try-cli service:
ANTHROPIC_API_KEY,OPENAI_API_KEY,XAI_API_KEY,TRY_CLI_GATEWAY_URL(its own public URL),REDIS_URL(project Redis),TRY_CLI_PROXY_SECRET, optional caps (GW_DAILY_CEILING_USD,GW_SESSION_BUDGET_USD, …). On Vercel: matchingTRY_CLI_PROXY_SECRET. Full list indocs/deployment/try-cli.md.Grok's free trial only activates once
XAI_API_KEYis set on the service.