Skip to content

Commit d0e97d7

Browse files
committed
chore: merge main
2 parents cbbf06e + a669827 commit d0e97d7

1,480 files changed

Lines changed: 86001 additions & 19587 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.coderabbit.config.ts‎

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
11
import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config";
22

3-
const approvability = `Fail when a maintainer should read this pull request before CodeRabbit approves it, and name the rule and file. Fail if it:
4-
5-
- Changes a product default: a setting's default value, or what users get without opting in. Making a feature do what it already promises is a bug fix, not a default change.
6-
- Adds or broadens a directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic, including file-level, line-level, and configuration-level overrides.
7-
- Adds a subsystem or user workflow, or is a large refactor across apps or packages.
8-
- Changes packages/contracts or persisted data in a way that existing clients or stored data might not accept.
9-
- Changes authentication, pairing, credentials, secrets, or remote connection trust.
10-
- Adds or changes an external side effect, such as acting on GitHub, publishing a release, or calling a webhook.
11-
- Adds, upgrades, or patches a dependency.
12-
- Changes CI or release configuration, agent or contributor instructions, or any review tool's configuration, including .github/, AGENTS.md, CONTRIBUTING.md, .agents/, and .coderabbit.config.ts.
13-
14-
Otherwise pass. A focused bug fix, copy or layout fix, revert, or docs-only or test-only change passes unless a rule above applies. If you cannot decide, fail rather than report inconclusive. When failing, say that the pull request needs a maintainer's review.
15-
`;
16-
173
// Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit
18-
// requests changes until its comments are resolved and its checks pass, then approves.
4+
// requests changes until its comments are resolved, then approves.
195
const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
206
const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? "");
217

@@ -31,13 +17,6 @@ export default defineConfig((ctx) => ({
3117
pre_merge_checks: {
3218
docstrings: { mode: "off" },
3319
override_requested_reviewers_only: isGated(ctx),
34-
custom_checks: [
35-
{
36-
name: "Approvability",
37-
mode: isGated(ctx) ? "error" : "off",
38-
instructions: approvability,
39-
},
40-
],
4120
},
4221
path_filters: [
4322
// Vendored read-only reference checkouts of upstream Effect and Alchemy

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/
150150
## Taste
151151

152152
- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb.
153+
- Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method.
153154
- Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md).
154155
- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md).
155156
- Inferred types over annotations. `any` is the enemy.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
9494
- [Project settings](./docs/user/project-settings.md)
9595
- [Appearance preferences](./docs/user/appearance.md)
9696
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
97+
- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md)
9798
- [Keeping app and server in sync](./docs/user/updating.md)
9899
- [Source control integrations](./docs/user/source-control.md)
99100
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)

‎apps/desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@t3tools/tailscale": "workspace:*",
2525
"dbus-next": "0.10.2",
2626
"effect": "catalog:",
27-
"electron": "44.4.2",
27+
"electron": "44.4.5",
2828
"electron-store": "^8.2.0",
2929
"electron-updater": "^6.8.9",
3030
"ffi-rs": "1.3.2",

‎apps/desktop/src/app/DesktopAppIdentity.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ describe("DesktopAppIdentity", () => {
217217
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
218218
yield* identity.configure;
219219

220-
assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]);
220+
assert.deepEqual(calls.setName, ["T3 Code Alpha"]);
221221
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)");
222222
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
223223
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
@@ -237,6 +237,39 @@ describe("DesktopAppIdentity", () => {
237237
);
238238
});
239239

240+
it.effect.each([
241+
{ stage: "Alpha", environment: {} },
242+
{
243+
stage: "Nightly",
244+
environment: { appVersion: "0.0.43-nightly.20260929.2428" },
245+
},
246+
{
247+
stage: "Dev",
248+
environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
249+
},
250+
])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => {
251+
const calls: ElectronAppCalls = {
252+
setAboutPanelOptions: [],
253+
setDockIcon: [],
254+
setName: [],
255+
};
256+
257+
return withIdentity(
258+
Effect.gen(function* () {
259+
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
260+
yield* identity.configure;
261+
262+
const runtimeName = calls.setName[0];
263+
assert.isDefined(runtimeName);
264+
assert.equal(runtimeName, `T3 Code ${stage}`);
265+
// RFC 9110's token grammar, after Electron removes ASCII spaces.
266+
assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/);
267+
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`);
268+
}),
269+
{ calls, environment },
270+
);
271+
});
272+
240273
it.effect("sets the dock icon only when running unpackaged", () => {
241274
const calls: ElectronAppCalls = {
242275
setAboutPanelOptions: [],

‎apps/desktop/src/app/DesktopAppIdentity.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,12 @@ export const make = Effect.gen(function* () {
9191

9292
const configure = Effect.gen(function* () {
9393
const commitHash = yield* resolveAboutCommitHash;
94-
yield* electronApp.setName(environment.displayName);
94+
// Electron removes spaces from this name to build the native User-Agent
95+
// product token, but leaves parentheses intact. Keep the runtime name valid
96+
// without rewriting preview sessions (which breaks Turnstile, #7110).
97+
yield* electronApp.setName(
98+
`${environment.branding.baseName} ${environment.branding.stageLabel}`,
99+
);
95100
yield* electronApp.setAboutPanelOptions({
96101
applicationName: environment.displayName,
97102
applicationVersion: environment.appVersion,
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { afterEach, beforeEach, expect, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Option from "effect/Option";
6+
import * as Path from "effect/Path";
7+
import * as Sink from "effect/Sink";
8+
import * as Stream from "effect/Stream";
9+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10+
11+
import * as DesktopCliCommand from "./DesktopCliCommand.ts";
12+
import * as DesktopCliShim from "./DesktopCliShim.ts";
13+
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
14+
15+
const environmentFor = (
16+
path: Path.Path,
17+
input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean },
18+
) =>
19+
DesktopEnvironment.DesktopEnvironment.of({
20+
path,
21+
platform: input.platform ?? "linux",
22+
isPackaged: input.isPackaged ?? true,
23+
homeDirectory: input.home,
24+
baseDir: input.baseDir,
25+
stateDir: path.join(input.baseDir, "userdata"),
26+
serverRoot: "/opt/T3 Code/resources/app.asar",
27+
appImagePath: Option.none(),
28+
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
29+
30+
/** The service for a packaged app; the launcher is written on Install. */
31+
const commandIn = (
32+
input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean },
33+
spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"],
34+
) =>
35+
Effect.gen(function* () {
36+
const path = yield* Path.Path;
37+
const fs = yield* FileSystem.FileSystem;
38+
const baseDir = input.baseDir ?? path.join(input.home, ".t3");
39+
yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true });
40+
const make = DesktopCliCommand.make.pipe(
41+
Effect.provideService(
42+
DesktopEnvironment.DesktopEnvironment,
43+
environmentFor(path, { ...input, baseDir }),
44+
),
45+
);
46+
return yield* spawner
47+
? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
48+
: make;
49+
});
50+
51+
/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */
52+
const fakePowerShell = (initial: string) => {
53+
const registry = { path: initial, failReads: false, writes: 0 };
54+
const spawner = ChildProcessSpawner.make((command) =>
55+
Effect.sync(() => {
56+
const { options } = command as unknown as {
57+
readonly options: { readonly env?: Record<string, string> };
58+
};
59+
const env = options.env ?? {};
60+
let exitCode = 0;
61+
let stdout = "";
62+
if (env.T3_SET === "1") {
63+
registry.path = env.T3_PATH ?? "";
64+
registry.writes += 1;
65+
} else if (registry.failReads) {
66+
exitCode = 1;
67+
} else {
68+
stdout = Buffer.from(registry.path, "utf8").toString("base64");
69+
}
70+
return ChildProcessSpawner.makeHandle({
71+
pid: ChildProcessSpawner.ProcessId(1),
72+
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
73+
isRunning: Effect.succeed(false),
74+
kill: () => Effect.void,
75+
unref: Effect.succeed(Effect.void),
76+
stdin: Sink.drain,
77+
stdout: Stream.make(new TextEncoder().encode(stdout)),
78+
stderr: Stream.empty,
79+
all: Stream.empty,
80+
getInputFd: () => Sink.drain,
81+
getOutputFd: () => Stream.empty,
82+
});
83+
}),
84+
);
85+
return { registry, spawner };
86+
};
87+
88+
// The machine's own PATH may already hold a t3; each test sets the PATH it means.
89+
const machinePath = process.env.PATH;
90+
beforeEach(() => {
91+
process.env.PATH = "";
92+
});
93+
afterEach(() => {
94+
process.env.PATH = machinePath;
95+
});
96+
97+
it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
98+
it.effect("links the launcher onto PATH and removes only that link", () =>
99+
Effect.gen(function* () {
100+
const fs = yield* FileSystem.FileSystem;
101+
const path = yield* Path.Path;
102+
const home = yield* fs.makeTempDirectoryScoped();
103+
const command = yield* commandIn({ home });
104+
const link = path.join(home, ".local", "bin", "t3");
105+
106+
expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false });
107+
// Install writes the launcher itself, even when no local backend ever did.
108+
const installed = yield* command.install;
109+
expect(installed.installedPath).toBe(link);
110+
expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3"));
111+
expect((yield* command.install).installedPath).toBe(link);
112+
113+
expect((yield* command.uninstall).installedPath).toBeNull();
114+
expect(yield* fs.exists(link)).toBe(false);
115+
// The launcher itself stays for setup commands.
116+
expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true);
117+
}).pipe(Effect.scoped),
118+
);
119+
120+
it.effect("never replaces or removes a t3 it did not create", () =>
121+
Effect.gen(function* () {
122+
const fs = yield* FileSystem.FileSystem;
123+
const path = yield* Path.Path;
124+
const home = yield* fs.makeTempDirectoryScoped();
125+
const command = yield* commandIn({ home });
126+
const theirs = path.join(home, ".local", "bin", "t3");
127+
yield* fs.makeDirectory(path.dirname(theirs), { recursive: true });
128+
yield* fs.writeFileString(theirs, "npm's t3\n");
129+
// Even a broken link in the next folder is someone else's.
130+
yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true });
131+
yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3"));
132+
133+
const error = yield* Effect.flip(command.install);
134+
expect(error.message).toContain("Another t3 command is already installed");
135+
yield* command.uninstall;
136+
expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n");
137+
expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone"));
138+
}).pipe(Effect.scoped),
139+
);
140+
141+
it.effect("finds and removes a link left by a previous T3 home", () =>
142+
Effect.gen(function* () {
143+
const fs = yield* FileSystem.FileSystem;
144+
const path = yield* Path.Path;
145+
const home = yield* fs.makeTempDirectoryScoped();
146+
const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") });
147+
const link = (yield* before.install).installedPath;
148+
149+
const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") });
150+
expect((yield* after.state).installedPath).toBe(link);
151+
// Installing again points the link at this home's launcher.
152+
expect((yield* after.install).installedPath).toBe(link);
153+
expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3"));
154+
yield* after.uninstall;
155+
expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
156+
expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false);
157+
}).pipe(Effect.scoped),
158+
);
159+
160+
it.effect("does not read a large binary another t3 links to", () =>
161+
Effect.gen(function* () {
162+
const fs = yield* FileSystem.FileSystem;
163+
const path = yield* Path.Path;
164+
const home = yield* fs.makeTempDirectoryScoped();
165+
// A big executable that happens to contain the marker text is still not ours.
166+
const binary = path.join(home, "native-t3");
167+
yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`);
168+
yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true });
169+
yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3"));
170+
const command = yield* commandIn({ home });
171+
expect((yield* command.state).installedPath).toBeNull();
172+
yield* command.uninstall;
173+
expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary);
174+
}).pipe(Effect.scoped),
175+
);
176+
177+
it.effect("refuses to install behind another t3 that runs first", () =>
178+
Effect.gen(function* () {
179+
const fs = yield* FileSystem.FileSystem;
180+
const path = yield* Path.Path;
181+
const home = yield* fs.makeTempDirectoryScoped();
182+
const shadow = path.join(home, "shadow");
183+
yield* fs.makeDirectory(shadow);
184+
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
185+
process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
186+
187+
const command = yield* commandIn({ home });
188+
const theirs = path.join(shadow, "t3");
189+
expect((yield* command.state).shadowedBy).toBe(theirs);
190+
// A link behind it would never run, so nothing is created.
191+
const error = yield* Effect.flip(command.install);
192+
expect(error.message).toContain(theirs);
193+
expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
194+
195+
yield* fs.remove(theirs);
196+
const installed = yield* command.install;
197+
expect(installed).toMatchObject({
198+
onPath: true,
199+
installedPath: path.join(home, ".local", "bin", "t3"),
200+
});
201+
expect(installed.shadowedBy).toBeUndefined();
202+
}).pipe(Effect.scoped),
203+
);
204+
205+
it.effect("leaves the Windows PATH alone when it cannot be read", () =>
206+
Effect.gen(function* () {
207+
const fs = yield* FileSystem.FileSystem;
208+
const home = yield* fs.makeTempDirectoryScoped();
209+
const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools";
210+
const { registry, spawner } = fakePowerShell(userPath);
211+
const command = yield* commandIn({ home, platform: "win32" }, spawner);
212+
213+
registry.failReads = true;
214+
const error = yield* Effect.flip(command.install);
215+
expect(error.message).toContain("left unchanged");
216+
expect(registry).toMatchObject({ path: userPath, writes: 0 });
217+
218+
registry.failReads = false;
219+
const launcherDir = DesktopCliShim.launcherPath(
220+
environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }),
221+
).replace(/[\\/]t3\.cmd$/, "");
222+
yield* command.install;
223+
expect(registry.path).toBe(`${userPath};${launcherDir}`);
224+
yield* command.uninstall;
225+
expect(registry.path).toBe(userPath);
226+
}).pipe(Effect.scoped),
227+
);
228+
229+
it.effect("does not claim or remove a Windows PATH entry the user added", () =>
230+
Effect.gen(function* () {
231+
const fs = yield* FileSystem.FileSystem;
232+
const path = yield* Path.Path;
233+
const home = yield* fs.makeTempDirectoryScoped();
234+
const launcherDir = path.dirname(
235+
DesktopCliShim.launcherPath(
236+
environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }),
237+
),
238+
);
239+
const userPath = `C:\\Tools;${launcherDir}`;
240+
const { registry, spawner } = fakePowerShell(userPath);
241+
const command = yield* commandIn({ home, platform: "win32" }, spawner);
242+
243+
expect((yield* command.state).installedPath).toBeNull();
244+
yield* command.uninstall;
245+
expect(registry.path).toBe(userPath);
246+
}).pipe(Effect.scoped),
247+
);
248+
249+
it.effect("offers nothing for a development build", () =>
250+
Effect.gen(function* () {
251+
const fs = yield* FileSystem.FileSystem;
252+
const command = yield* commandIn({
253+
home: yield* fs.makeTempDirectoryScoped(),
254+
isPackaged: false,
255+
});
256+
expect((yield* command.state).supported).toBe(false);
257+
}).pipe(Effect.scoped),
258+
);
259+
});

0 commit comments

Comments
 (0)