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
4 changes: 1 addition & 3 deletions .github/workflows/deploy-relay.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
name: Deploy T3 Connect relay

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
tags:
- "v*.*.*"
- "!v*-nightly.*"
schedule:
- cron: "0 */3 * * *"
workflow_dispatch:
inputs:
channel:
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# T3 Code

T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon).
T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, Grok, Muse Code, and OpenCode, with more coming soon).

## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, and OpenCode.
> T3 Code currently supports Codex, Claude, Cursor, Grok, Muse Code, and OpenCode.
> Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login`
> - Muse Code: install [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2) and run `muse login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`

### Run without installing
Expand Down Expand Up @@ -57,7 +58,7 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d
- [Remote access](./docs/user/remote-access.md)
- [Keeping T3 Code in sync](./docs/user/server-updates.md)
- [Architecture overview](./docs/architecture/overview.md)
- [Provider guides](./docs/providers/codex.md)
- Provider guides: [Codex](./docs/providers/codex.md), [Claude](./docs/providers/claude.md), and [Muse Code](./docs/providers/muse.md)
- [Operations](./docs/operations/ci.md)
- [Reference](./docs/reference/encyclopedia.md)

Expand Down
96 changes: 95 additions & 1 deletion apps/server/src/authConnector/AuthConnectorManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
import { describe, expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { testHelpers } from "./AuthConnectorManager.ts";
import { ServerConfig } from "../config.ts";
import { start, testHelpers } from "./AuthConnectorManager.ts";

describe("AuthConnectorManager output parsing", () => {
it.effect("rejects Muse authentication before spawning a process when Muse is withheld", () =>
Effect.gen(function* () {
const defaultConfig = yield* ServerConfig;
const error = yield* start({ connector: "muse", method: "account" }).pipe(
Effect.provideService(ServerConfig, {
...defaultConfig,
museCodeEnabled: false,
}),
Effect.flip,
);

expect(error).toMatchObject({
operation: "start",
detail: "Muse Code is not available in this T3 Code environment.",
});
}).pipe(
Effect.provide(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-auth-connector-gate-test-",
}).pipe(Layer.provideMerge(NodeServices.layer)),
),
),
);

it("extracts GitHub device authorization details", () => {
const output = [
"! First copy your one-time code: ABCD-1234",
Expand Down Expand Up @@ -35,6 +63,34 @@ describe("AuthConnectorManager output parsing", () => {
);
});

it("extracts Muse's Meta device authorization details", () => {
const output = [
"Open this page to sign in:",
" https://auth.meta.com/oauth/device/?code=ZKWQ-XCBZ",
"confirm this code matches:",
" ZKWQ-XCBZ",
"Waiting for approval…",
].join("\n");

expect(testHelpers.extractUserCode(output)).toBe("ZKWQ-XCBZ");
expect(testHelpers.extractUrl(output)).toBe(
"https://auth.meta.com/oauth/device/?code=ZKWQ-XCBZ",
);
expect(
testHelpers.parseOutputForTest({
connector: "muse",
method: "account",
flow: "device",
output,
}).snapshot,
).toMatchObject({
status: "waiting",
stage: "authorize",
verificationUrl: "https://auth.meta.com/oauth/device/?code=ZKWQ-XCBZ",
userCode: "ZKWQ-XCBZ",
});
});

it("keeps Claude authorization query parameters intact", () => {
const output =
"If the browser did not open, visit: https://claude.com/cai/oauth/authorize?code=true&state=opaque";
Expand All @@ -44,6 +100,13 @@ describe("AuthConnectorManager output parsing", () => {
);
});

it("rejects lookalike authentication hosts", () => {
expect(
testHelpers.extractUrl("Open https://auth.meta.com.evil.example/oauth/device to continue"),
).toBeNull();
expect(testHelpers.extractUrl("Open http://auth.meta.com/oauth/device to continue")).toBeNull();
});

it("extracts Microsoft device authorization details for Azure DevOps", () => {
const output =
"To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code A1B2C3D4 to authenticate.";
Expand Down Expand Up @@ -89,6 +152,37 @@ describe("AuthConnectorManager output parsing", () => {
expect(spec?.ptyName).toBe("dumb");
});

it("starts Muse account login with its device flow", () => {
expect(
testHelpers.launchSpec({
connector: "muse",
method: "account",
}),
).toMatchObject({
command: "muse",
args: ["login"],
flow: "device",
});
});

it("starts Muse API-key login through stdin", () => {
expect(
testHelpers.launchSpec({
connector: "muse",
method: "api-key",
}),
).toMatchObject({
command: "muse",
args: ["auth", "set", "--provider", "meta", "--api-key-stdin"],
flow: "secret",
fields: [{ key: "secret", type: "password" }],
});
expect(testHelpers.secretInputTerminator({ connector: "muse", method: "api-key" })).toBe(
"\r\u0004",
);
expect(testHelpers.secretInputTerminator({ connector: "codex", method: "api-key" })).toBe("\r");
});

it("accepts Claude's full callback URL or short authorization code", () => {
expect(testHelpers.claudeCallbackField()).toMatchObject({
key: "callback",
Expand Down
68 changes: 61 additions & 7 deletions apps/server/src/authConnector/AuthConnectorManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import { ServerConfig } from "../config.ts";
import { writeStoredBitbucketCredentials } from "../sourceControl/BitbucketCredentialStore.ts";

const SESSION_TTL_MS = 15 * 60 * 1_000;
Expand Down Expand Up @@ -88,14 +89,38 @@ function stripAnsi(input: string): string {
return input.replace(ANSI_PATTERN, "").replace(/\r/g, "");
}

const AUTH_URL_HOSTS = [
"github.com",
"gitlab.com",
"openai.com",
"claude.com",
"cursor.com",
"x.ai",
"auth.meta.com",
"microsoft.com",
"microsoftonline.com",
"aka.ms",
] as const;

function isAllowedAuthUrl(candidate: string): boolean {
try {
const parsed = new URL(candidate);
const hostname = parsed.hostname.toLowerCase();
return (
parsed.protocol === "https:" &&
AUTH_URL_HOSTS.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`))
);
} catch {
return false;
}
}

function extractUrl(output: string): string | null {
const matches = output.match(/https?:\/\/[^\s<>"']+/giu) ?? [];
const candidate = matches.findLast((url) =>
/(?:github\.com|gitlab\.com|openai\.com|claude\.com|cursor\.com|x\.ai|microsoft\.com|microsoftonline\.com|aka\.ms)/iu.test(
url,
),
return (
matches.map((candidate) => candidate.replace(/[),.;]+$/u, "")).findLast(isAllowedAuthUrl) ??
null
);
return candidate?.replace(/[),.;]+$/u, "") ?? null;
}

function extractUserCode(output: string): string | null {
Expand All @@ -105,6 +130,7 @@ function extractUserCode(output: string): string | null {
/enter code:\s*([A-Z0-9-]{6,})/iu,
/enter the code\s+([A-Z0-9-]{6,})/iu,
/confirm this code(?: in your browser)?:\s*([A-Z0-9-]{6,})/iu,
/confirm this code matches:\s*([A-Z0-9-]{6,})/iu,
/user_code=([A-Z0-9-]{6,})/iu,
];
for (const pattern of patterns) {
Expand Down Expand Up @@ -269,6 +295,10 @@ function secretFields(input: AuthConnectorStartInput): ReadonlyArray<AuthConnect
];
}

function secretInputTerminator(input: Pick<AuthConnectorSession, "connector" | "method">): string {
return input.connector === "muse" && input.method === "api-key" ? "\r\u0004" : "\r";
}

type LaunchSpec = {
readonly command: string;
readonly args: ReadonlyArray<string>;
Expand Down Expand Up @@ -322,6 +352,22 @@ function launchSpec(input: AuthConnectorStartInput): LaunchSpec | null {
flow: "device",
message: "Starting xAI sign-in…",
};
case "muse":
if (input.method !== "account" && input.method !== "api-key") return null;
return input.method === "api-key"
? {
command: "muse",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor the configured Muse binary during authentication

When a Muse instance uses the supported binaryPath setting because muse is not on PATH, its health checks and turns use that configured executable, but both Connect flows still spawn the literal muse command here. Consequently, the provider can appear installed and usable while Meta sign-in and API-key setup fail to start; pass the target instance/configuration through the connector and resolve the same binary path used by MuseDriver.

Useful? React with 👍 / 👎.

args: ["auth", "set", "--provider", "meta", "--api-key-stdin"],
flow: "secret",
message: "Enter a Meta API key.",
fields: secretFields(input),
}
: {
command: "muse",
args: ["login"],
flow: "device",
message: "Starting secure Meta sign-in…",
};
case "github":
if (input.method !== "account" && input.method !== "token") return null;
return input.method === "token"
Expand Down Expand Up @@ -535,7 +581,14 @@ async function submitBitbucket(

export const start = Effect.fn("AuthConnectorManager.start")(function* (
input: AuthConnectorStartInput,
): Effect.fn.Return<AuthConnectorSession, AuthConnectorError> {
): Effect.fn.Return<AuthConnectorSession, AuthConnectorError, ServerConfig> {
const { museCodeEnabled } = yield* ServerConfig;
if (input.connector === "muse" && !museCodeEnabled) {
return yield* connectorError(
"start",
"Muse Code is not available in this T3 Code environment.",
);
}
if (input.connector === "bitbucket" && input.method !== "token") {
return yield* connectorError("start", "That sign-in method is not supported.");
}
Expand Down Expand Up @@ -635,7 +688,7 @@ export const submit = Effect.fn("AuthConnectorManager.submit")(function* (
return yield* connectorError("submit", "Enter the requested credential to continue.");
}
clearSensitiveOutput(session);
session.process?.write(`${secret}\r`);
session.process?.write(`${secret}${secretInputTerminator(session.snapshot)}`);
setSnapshot(session, {
status: "starting",
stage: "verifying",
Expand Down Expand Up @@ -711,6 +764,7 @@ export const testHelpers = {
hasGitHubCredentialPrompt,
hasGitHubBrowserPrompt,
claudeCallbackField,
secretInputTerminator,
launchSpec,
parseOutputForTest,
};
1 change: 1 addition & 0 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
tailscaleServeEnabled: false,
tailscaleServePort: 443,
managedDevPc: false,
museCodeEnabled: true,
} satisfies ServerConfig.ServerConfig["Service"];
});

Expand Down
Loading
Loading