Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/account-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ Deployment is intentionally deferred. Before a first deploy:
2. Set `CLERK_JWKS_URL`, `CLERK_ISSUER`, and
`CLERK_OAUTH_CLIENT_ID=<your-clerk-oauth-client-id>` as Worker vars/secrets. Register
`https://<worker-host>/device/callback` as an allowed redirect URI for the
Clerk OAuth application.
Clerk OAuth application. Set `WEB_CLIENT_ORIGIN` to the exact HTTPS origin
of the hosted ADE web client; this is the only cross-origin caller allowed
to send an account bearer to `GET /account/machines`.
3. Apply both remote migrations and deploy the Worker.
4. Set `ADE_ACCOUNT_DIRECTORY_URL=https://<worker-host>` for ADE brains that
should offer device login.
Expand Down
71 changes: 69 additions & 2 deletions apps/account-directory/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Env {
CLERK_JWKS_URL: string;
CLERK_ISSUER: string;
CLERK_OAUTH_CLIENT_ID: string;
WEB_CLIENT_ORIGIN?: string;
ONLINE_WINDOW_MS?: string;
}

Expand Down Expand Up @@ -326,10 +327,31 @@ async function handleDelete(
return json({ ok: true, machineKey });
}

export async function handleRequest(
function trustedWebClientOrigin(env: Env): string | null {
const raw = env.WEB_CLIENT_ORIGIN?.trim();
if (!raw) return null;
try {
const url = new URL(raw);
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
if (url.origin !== raw || url.username || url.password || url.search || url.hash) return null;
return url.origin;
} catch {
return null;
}
}

function withCors(response: Response, origin: string): Response {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", origin);
headers.set("vary", "Origin");
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}

async function handleRequestCore(
request: Request,
env: Env,
options: DeviceAuthorizationRequestOptions = {},
options: DeviceAuthorizationRequestOptions,
): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/health") {
Expand All @@ -348,3 +370,48 @@ export async function handleRequest(
if (route.kind === "list") return await handleList(request, env, userId);
return await handleDelete(request, env, userId, route.machineKey);
}

export async function handleRequest(
request: Request,
env: Env,
options: DeviceAuthorizationRequestOptions = {},
): Promise<Response> {
const url = new URL(request.url);
const requestOrigin = request.headers.get("origin");
const allowedOrigin = trustedWebClientOrigin(env);
const corsOrigin = requestOrigin && allowedOrigin && requestOrigin === allowedOrigin
? allowedOrigin
: null;
if (request.method === "OPTIONS") {
const route = routeAccount(url.pathname);
if (!route || route.kind !== "list") return text("not found", 404);
if (!corsOrigin) return text("origin not allowed", 403);
if (request.headers.get("access-control-request-method")?.toUpperCase() !== "GET") {
return text("method not allowed", 405);
}
const requestedHeaders = (request.headers.get("access-control-request-headers") ?? "")
.split(",")
.map((header) => header.trim().toLowerCase())
.filter(Boolean);
if (requestedHeaders.some((header) => header !== "authorization")) {
return text("headers not allowed", 403);
}
return new Response(null, {
status: 204,
headers: {
"access-control-allow-origin": corsOrigin,
"access-control-allow-headers": "authorization",
"access-control-allow-methods": "GET, OPTIONS",
"access-control-max-age": "600",
vary: "Origin",
},
});
}
// Daemon/native callers omit Origin. Browser callers must match the one
// configured hosted client exactly; reject hostile origins before auth or D1.
if (requestOrigin && routeAccount(url.pathname) && !corsOrigin) {
return text("origin not allowed", 403);
}
const response = await handleRequestCore(request, env, options);
return corsOrigin ? withCors(response, corsOrigin) : response;
}
33 changes: 33 additions & 0 deletions apps/account-directory/test/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,39 @@ describe("machine directory", () => {
expect(await response.json()).toEqual({ ok: true });
});

it("allows only the configured hosted origin to preflight and read the directory", async () => {
const env = makeEnv({ WEB_CLIENT_ORIGIN: "https://app.ade.dev" });
const token = await mintToken();
const preflight = await handleRequest(new Request("https://directory.test/account/machines", {
method: "OPTIONS",
headers: {
origin: "https://app.ade.dev",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}), env);
expect(preflight.status).toBe(204);
expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.ade.dev");
expect(preflight.headers.get("access-control-allow-headers")).toBe("authorization");

const allowed = await handleRequest(new Request("https://directory.test/account/machines", {
headers: { origin: "https://app.ade.dev", authorization: `Bearer ${token}` },
}), env);
expect(allowed.status).toBe(200);
expect(allowed.headers.get("access-control-allow-origin")).toBe("https://app.ade.dev");

const hostilePreflight = await handleRequest(new Request("https://directory.test/account/machines", {
method: "OPTIONS",
headers: { origin: "https://evil.example" },
}), env);
expect(hostilePreflight.status).toBe(403);
const hostileRead = await handleRequest(new Request("https://directory.test/account/machines", {
headers: { origin: "https://evil.example", authorization: `Bearer ${token}` },
}), env);
expect(hostileRead.status).toBe(403);
expect(hostileRead.headers.get("access-control-allow-origin")).toBeNull();
});

it("upserts registration heartbeats and isolates rows by Clerk sub", async () => {
const env = makeEnv();
const firstToken = await mintToken({ sub: "user_1" });
Expand Down
5 changes: 4 additions & 1 deletion apps/account-directory/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"preview_urls": false,
"triggers": { "crons": ["* * * * *"] },
"vars": {
"ONLINE_WINDOW_MS": "90000"
"ONLINE_WINDOW_MS": "90000",
// Exact hosted web-client origin allowed to read /account/machines.
// Set to the production HTTPS origin before deployment.
"WEB_CLIENT_ORIGIN": ""
},
"d1_databases": [
{
Expand Down
29 changes: 24 additions & 5 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ The `sync.connectToBrain`, `sync.disconnectFromBrain`, and `sync.transferBrainTo
- Headless CLI fallback uses `EncryptedFileCredentialStore`, which keeps `credentials.json.enc` encrypted with AES-256-GCM and serializes read-modify-write access with `credentials.json.enc.lock`.
- Secret directories are created with mode `0700`; credential blobs, lock files, and legacy machine keys are written with mode `0600`.

`ade login`, `ade logout`, and `ade auth status` operate on the daemon-owned ADE
account session in that store. `ade machines list` reads the authenticated
account directory; signed-out users get a local-first message and existing
local, PIN, explicit-address, and saved SSH paths remain available. Machine keys
and device IDs are stable selectors. A display name is accepted only when it is
unambiguous; otherwise the command prints the matching stable machine keys.

## `ade code`

`ade code` launches the terminal-native ADE Work chat (Ink + React, in `src/tuiClient/`). Default behavior:
Expand All @@ -236,18 +243,26 @@ ade code # attach to the machine brain, auto-spawn it
ade code --embedded # force the in-process embedded runtime
ade code --print-state # smoke-test the connection and exit
ade code remote --target mac --project ADE
# attach to a saved desktop remote machine
# attach to a saved paired or SSH remote machine
ade code remote session --target mac --project ADE --session chat-1
# open a remote chat or provider CLI terminal session
ade login # sign in to the optional shared machine account
ade machines list --text # list account machines, including offline state
ade machines connect <machine-key> --project ADE
# pair if needed, then open ADE Code on that machine
ade --socket /path/to/ade.sock code # attach to a specific local endpoint
ade --project-root /repo code # bind to a specific project root
```

`ade code remote` reads the same saved remote-machine registry as desktop ADE,
starts `ade rpc --stdio` over SSH, and bridges it back into the normal TUI with
`--remote`, `--remote-label`, `--require-socket`, remote project roots, and an
optional `--session` hint. Use `--list-targets`, `--list-projects`, and
`--list-sessions` for non-interactive discovery.
then uses the target's declared transport. Paired targets connect through the
DPoP-bound sync runtime bridge; SSH targets start `ade rpc --stdio` over a
validated SSH route. Account-created targets are paired-only and fail closed
instead of falling back to SSH or a plaintext address. The launcher bridges the
selected transport back into the normal TUI with `--remote`, `--remote-label`,
`--require-socket`, remote project roots, and an optional `--session` hint. Use
`--list-targets`, `--list-projects`, and `--list-sessions` for non-interactive
discovery.

**Browser mirror (dev):** from the repo root, `npm run dev:code:web` runs **one** `ade code` in a **single PTY** and mirrors that TTY to the browser (xterm). Use Cursor’s browser tools against that page like any other local URL. This is not the same as running `ade code` in a terminal app **and** in the browser at once—that would be two separate processes.

Expand All @@ -273,6 +288,10 @@ ade login # loopback OAuth, or device flow on SS
ade login --headless # print verification URL + user code
ade auth status --text # account identity + loopback/device/env-token source
ade account token create --text # print a self-contained durable ADE_ACCOUNT_TOKEN once
ade logout
ade machines list --text
ade machines connect <machine-key> --project ADE
ade machines hop <device-id> --session chat-1
ade doctor --json
ade projects list --text
ade projects inspect /path/to/checkout --json # classify a path (repo root vs linked/ADE-managed worktree) and find its owning project + existing lane
Expand Down
60 changes: 60 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,66 @@ describe("ADE CLI", () => {
injectProjectRootIntoArgs: true,
}],
});
const machines = expectExecutePlan(buildCliPlan(["machines", "list"]));
expect(machines).toMatchObject({
label: "account machines list",
formatter: "account-machines",
machineOnly: true,
machineAutoStart: true,
steps: [{
method: "account.call",
params: { action: "listMachines", args: {} },
}],
});
expect(shouldAutoRegisterProjectForPlan(machines)).toBe(false);
expect(buildCliPlan([
"machines",
"connect",
"mk_studio",
"--project",
"ADE",
])).toEqual({
kind: "account-machine-connect",
machine: "mk_studio",
remoteArgs: ["--project", "ADE"],
});
expect(buildCliPlan([
"machine",
"hop",
"--machine",
"device_studio",
"--session",
"Fix auth",
])).toEqual({
kind: "account-machine-connect",
machine: "device_studio",
remoteArgs: ["--session", "Fix auth"],
});
expect(() => buildCliPlan(["machines", "connect", "--project", "ADE"]))
.toThrow(/requires a stable machine key/i);
expect(formatOutput(
{ state: "signed_out", machines: [], message: null },
{ ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true },
"account-machines",
)).toContain("run `ade login`");
expect(formatOutput(
{
state: "ok",
message: null,
machines: [{
machineKey: "lan-only",
deviceId: "device-lan",
name: "LAN only",
platform: "macOS",
deviceType: "desktop",
reachableEndpoints: [{ kind: "lan", host: "192.168.1.8", port: 8787 }],
lastSeenAt: 1,
online: true,
}],
},
{ ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true },
"account-machines",
)).toContain("unreachable");
const rawActionPlan = expectExecutePlan(buildCliPlan(["actions", "run", "account.status"]));
expect(rawActionPlan.steps[0]).toMatchObject({
method: "account.call",
Expand Down
Loading