Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/tui-access-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

`agents tui` now handles Cloudflare Access: for a URL behind Access, it gets a token from `cloudflared` (logging in through the browser the first time) and sends it on the WebSocket upgrade. `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` and explicit `--header` credentials still take precedence.
110 changes: 110 additions & 0 deletions packages/agents/src/experimental/channels/__tests__/tui-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { accessHeaders, type AccessDeps } from "../web/tui/access";

const TOKEN = "aaa.bbb.ccc";
const LOGIN = "https://team.cloudflareaccess.com/cdn-cgi/access/login/app";

function deps(options: {
location?: string;
tokens?: (string | undefined)[];
missing?: boolean;
}) {
const calls: { url?: string; command?: string[] }[] = [];
const tokens = [...(options.tokens ?? [])];
const value: AccessDeps = {
fetch: async (url) => {
calls.push({ url });
return options.location
? new Response(null, {
status: 302,
headers: { location: options.location }
})
: new Response("ok");
},
run: async (command, args) => {
calls.push({ command: [command, ...args] });
if (options.missing) throw new Error("Install cloudflared");
if (args[1] === "login") return { code: 0, stdout: "" };
const token = tokens.shift();
return token
? { code: 0, stdout: `${token}\n` }
: { code: 1, stdout: "" };
},
log: () => {}
};
return { value, calls };
}

describe("tui Access", () => {
it("sends nothing to a URL that is not behind Access", async () => {
const { value, calls } = deps({});
expect(await accessHeaders("wss://h/channels/r", {}, value)).toEqual({});
expect(calls).toEqual([{ url: "https://h/channels/r" }]);
});

it("gets a token from cloudflared for the address being opened", async () => {
const { value, calls } = deps({ location: LOGIN, tokens: [TOKEN] });
expect(await accessHeaders("wss://h/channels/r?as=a", {}, value)).toEqual({
"cf-access-token": TOKEN
});
expect(calls[1]).toEqual({
command: ["cloudflared", "access", "token", "-app=https://h/channels/r"]
});
});

it("logs in when there is no token yet", async () => {
const { value, calls } = deps({
location: LOGIN,
tokens: [undefined, TOKEN]
});
expect(await accessHeaders("wss://h/c", {}, value)).toEqual({
"cf-access-token": TOKEN
});
expect(calls.map((call) => call.command?.[2])).toEqual([
undefined,
"token",
"login",
"token"
]);
});

it("leaves credentials the caller sends alone", async () => {
const { value, calls } = deps({ location: LOGIN, tokens: [TOKEN] });
expect(
await accessHeaders("wss://h/c", { "cf-access-client-id": "id" }, value)
).toEqual({});
expect(calls).toEqual([]);
});

it("probes the channel path, which Access may protect alone", async () => {
const { value, calls } = deps({ location: LOGIN, tokens: [TOKEN] });
await accessHeaders("wss://h/channels/team/one", {}, value);
expect(calls[0]).toEqual({ url: "https://h/channels/team/one" });
});

it("still logs in when the caller's cookie is not Access's", async () => {
const { value } = deps({ location: LOGIN, tokens: [TOKEN] });
expect(
await accessHeaders("wss://h/c", { cookie: "theme=dark" }, value)
).toEqual({ "cf-access-token": TOKEN });
expect(
await accessHeaders(
"wss://h/c",
{ cookie: "theme=dark; CF_Authorization=x" },
value
)
).toEqual({});
});

it("ignores redirects that are not to Access", async () => {
const { value } = deps({ location: "https://h/login" });
expect(await accessHeaders("wss://h/c", {}, value)).toEqual({});
});

it("explains how to continue when cloudflared is missing", async () => {
const { value } = deps({ location: LOGIN, missing: true });
await expect(accessHeaders("wss://h/c", {}, value)).rejects.toThrow(
"Install cloudflared"
);
});
});
107 changes: 107 additions & 0 deletions packages/agents/src/experimental/channels/web/tui/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { spawn } from "node:child_process";

/** What finding an Access token needs, replaceable in tests. */
export type AccessDeps = {
fetch(url: string, init: RequestInit): Promise<Response>;
/** Runs a command; `interactive` lets it use the terminal. */
run(
command: string,
args: readonly string[],
interactive: boolean
): Promise<{ code: number; stdout: string }>;
log(message: string): void;
};

/** Whether the caller already sends Access credentials. */
function authenticated(headers: Readonly<Record<string, string>>): boolean {
return (
"cf-access-token" in headers ||
"cf-access-client-id" in headers ||
/(?:^|;\s*)CF_Authorization=/.test(headers.cookie ?? "")
);
}

/**
* The `cf-access-token` header for a URL behind Cloudflare Access, from
* `cloudflared`. Logs in through the browser when there is no token yet.
* Returns no headers when the caller already sends credentials, or when the
* URL is not behind Access.
*/
export async function accessHeaders(
socketUrl: string,
headers: Readonly<Record<string, string>>,
deps: AccessDeps = nodeDeps
): Promise<Record<string, string>> {
if (authenticated(headers)) return {};
// Access can protect a path rather than the whole host, so probe and log
// in to the address being opened.
const url = new URL(socketUrl);
url.protocol = url.protocol === "ws:" ? "http:" : "https:";
url.search = "";
const app = url.toString();
if (!(await behindAccess(app, deps))) return {};
Comment on lines +41 to +42

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.

🔍 Access scope differs from the description

The PR description specifies origin-based detection and login, while cloudflared receives the channel path. Confirm path-scoped application matching and update the description.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


const token = async () => {
const { code, stdout } = await deps.run(
"cloudflared",
["access", "token", `-app=${app}`],
false
);
const value = stdout.trim();
return code === 0 && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(value)
? value
: undefined;
};

let value = await token();
if (value === undefined) {
deps.log(`${app} is behind Cloudflare Access; logging in.`);
await deps.run("cloudflared", ["access", "login", app], true);
value = await token();
}
if (value === undefined) {
throw new Error(
`Could not get a Cloudflare Access token for ${app}. Run \`cloudflared access login ${app}\`, or pass --header cf-access-token=<token>.`
);
}
return { "cf-access-token": value };
}

async function behindAccess(app: string, deps: AccessDeps) {
try {
const response = await deps.fetch(app, { redirect: "manual" });
const location = response.headers.get("location");
if (response.status < 300 || response.status >= 400 || !location) {
return false;
}
return new URL(location, app).hostname.endsWith(".cloudflareaccess.com");
} catch {
// Unreachable: let the WebSocket report it.
return false;
}
}

const nodeDeps: AccessDeps = {
fetch: (url, init) => fetch(url, init),
run: (command, args, interactive) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: interactive ? "inherit" : ["ignore", "pipe", "ignore"]
});
let stdout = "";
child.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.on("error", (error: NodeJS.ErrnoException) => {
reject(
error.code === "ENOENT"
? new Error(
"This URL is behind Cloudflare Access. Install cloudflared, or pass --header cf-access-token=<token>."
)
: error
);
});
child.on("close", (code) => resolve({ code: code ?? 1, stdout }));
}),
log: (message) => console.error(message)
};
7 changes: 4 additions & 3 deletions packages/agents/src/experimental/channels/web/tui/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ Options:
--as Sets the "as" query parameter (demo agents read it as the participant)
--header Adds a header to the WebSocket upgrade; repeatable

Environment:
CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET
Sent as an Access service token`;
Cloudflare Access:
A URL behind Access gets a token from cloudflared, which logs in through
the browser the first time. Or set CF_ACCESS_CLIENT_ID and
CF_ACCESS_CLIENT_SECRET to send a service token instead.`;

export type TuiArgs = {
/** A ws(s) URL. */
Expand Down
14 changes: 12 additions & 2 deletions packages/agents/src/experimental/channels/web/tui/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { accessHeaders } from "./access";
import { parseArgs, usage, UsageError } from "./args";
import { runTui } from "./app";

Expand All @@ -7,12 +8,21 @@ export async function main(argv: readonly string[]): Promise<number> {
console.log(usage);
return 0;
}
let args;
try {
await runTui(parseArgs(argv, process.env));
return 0;
args = parseArgs(argv, process.env);
} catch (error) {
if (!(error instanceof UsageError)) throw error;
console.error(`${error.message}\n\n${usage}`);
return 2;
}
try {
const access = await accessHeaders(args.url, args.headers);
args = { ...args, headers: { ...args.headers, ...access } };
} catch (error) {
Comment on lines +21 to +22

@devin-ai-integration devin-ai-integration Bot Oct 2, 2026 •

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.

🔴 Expired Access tokens strand live sessions

When an Access token expires, sendHeadersOnUpgrade keeps sending it on reconnects. WebChannelClient retries with the same headers, leaving the TUI disconnected until restart.

Learn more

The TUI resolves an Access token once, then sendHeadersOnUpgrade captures that token in the WebSocket wrapper. The client's reconnect loop reuses the wrapper without refreshing its headers. When Access expires the token, all future upgrade attempts use an expired token until the process exits.

Example: A TUI starts at 09:00 with a token expiring at 10:00. If the socket drops at 10:05, every automatic retry uses the 09:00 token instead of obtaining a new one.

Recommended fix: Refresh cloudflared credentials before retries after an Access rejection or before reconnecting when the JWT has expired. Make the WebSocket upgrade wrapper read updated headers on each connection and avoid restarting interactive login for unrelated connection failures.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

console.error(error instanceof Error ? error.message : String(error));
return 1;
}
await runTui(args);
return 0;
}
Loading