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
14 changes: 11 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
type WorkContentIcon = AppSymbolName | "browser" | "t3-code";
type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code";

function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
Expand All @@ -96,7 +96,13 @@ function WorkLogIcon(props: {
}
return (
<SymbolView
name={props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon}
name={
props.icon === "browser"
? { ios: "globe", android: "public" }
: props.icon === "device"
? { ios: "iphone", android: "smartphone" }
: props.icon
}
size={13}
weight="medium"
{...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}
Expand Down Expand Up @@ -922,7 +928,7 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "t3-code";
readonly summaryToolIcon?: "browser" | "device" | "t3-code";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
Expand Down Expand Up @@ -1267,6 +1273,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
return { ios: "square.and.pencil", android: "edit" };
case "command":
return { ios: "terminal", android: "terminal" };
case "device":
return { ios: "iphone", android: "smartphone" };
case "browser":
case "search":
return { ios: "globe", android: "public" };
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export type ThreadFeedEntry =
readonly summaryKind: ToolGroupSummaryKind;
readonly toolSurface?: WorkLogEntry["toolSurface"];
readonly toolIcon?: WorkLogEntry["toolIcon"];
readonly summaryToolIcon?: "browser" | "t3-code";
readonly summaryToolIcon?: "browser" | "device" | "t3-code";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
Expand Down
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"effect": "catalog:",
"msgpackr-extract": "3.0.4",
"node-pty": "^1.1.0",
"proper-lockfile": "4.1.2",
"stream-chain": "^4.2.5",
"stream-json": "3.6.0",
"yaml": "catalog:",
Expand All @@ -45,10 +46,12 @@
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/proper-lockfile": "4.1.4",
"@types/yauzl": "^3.4.0",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceList]: AuthOrchestrationReadScope,
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/device/AgentDeviceShim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
/**
* A directory holding an `agent-device` launcher that runs the pinned install
* with the server's Node. Prepended to provider subprocess PATHs so the agent
* types `agent-device …` and gets the version the injected instructions were
* written for, regardless of what is or is not globally installed.
*/
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

const SHIM_DIR = "device/bin";

export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: {
readonly entryPath: string;
readonly stateDir: string;
}) {
const { entryPath } = input;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const shimDir = path.join(input.stateDir, SHIM_DIR);
yield* fs.makeDirectory(shimDir, { recursive: true });
const node = process.execPath;
const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
yield* fs.writeFileString(
launcherPath,
`import { spawn } from "node:child_process";
const args = process.argv.slice(2);
const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]);
const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); };
if (!informational && !(hasValue("--config") && hasValue("--session"))) {
console.error("Call device_open first and include its --config and --session flags.");
process.exit(1);
}
const env = { ...process.env };
delete env.AGENT_DEVICE_DAEMON_BASE_URL;
delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
delete env.AGENT_DEVICE_CONFIG;
const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env });
child.on("error", error => { console.error(error.message); process.exitCode = 1; });
child.on("exit", code => { process.exitCode = code ?? 1; });
`,
);
if (platform === "win32") {
const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`;
yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script);
} else {
const command = [node, launcherPath]
.map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'")
.join(" ");
const script = `#!/bin/sh\nexec ${command} "$@"\n`;
const shimPath = path.join(shimDir, "agent-device");
yield* fs.writeFileString(shimPath, script);
yield* fs.chmod(shimPath, 0o755);
}
return shimDir;
});
89 changes: 89 additions & 0 deletions apps/server/src/device/AgentDeviceTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { describe, expect, it } from "@effect/vitest";
import * as NodeChildProcess from "node:child_process";
import * as NodeUtil from "node:util";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts";
import {
agentDeviceConfigPath,
agentDeviceSession,
writeAgentDeviceConfig,
} from "./AgentDeviceTarget.ts";

const exec = NodeUtil.promisify(NodeChildProcess.execFile);

describe("host-bound agent commands", () => {
it.effect("runs two hosts concurrently and only updates the reconnected host", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
const platform = yield* HostProcessPlatform;
const dir = path.join(
temp,
platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`",
);
yield* fs.makeDirectory(dir);
const entryPath = path.join(dir, "cli.mjs");
yield* fs.writeFileString(
entryPath,
`import { readFileSync } from 'node:fs';
const args = process.argv.slice(2);
console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
);
const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
const files = ["mini", "android"].map((host) => agentDeviceConfigPath(dir, host, path));
for (const [index, file] of files.entries())
yield* writeAgentDeviceConfig(file, {
baseUrl: `http://127.0.0.1:${1000 + index}`,
token: `token-${index}`,
entryPath,
});
const invoke = (file: string) =>
exec(
platform === "win32" ? process.execPath : path.join(shim, "agent-device"),
[
...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []),
"snapshot",
"--config",
file,
"--session",
"test-session",
],
{ env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } },
).then((result) => JSON.parse(result.stdout));
expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([
{ daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" },
{ daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" },
]);
const second = yield* fs.readFileString(files[1]!);
yield* writeAgentDeviceConfig(files[0]!, {
baseUrl: "http://127.0.0.1:2000",
token: "new",
entryPath,
});
expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
expect(yield* fs.readFileString(files[1]!)).toBe(second);
expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe(
agentDeviceSession("thread", "android", "same-id"),
);
for (const args of [
["snapshot"],
["snapshot", "--config", files[0]!],
["snapshot", "--config", "help"],
["snapshot", "--config", files[0]!, "--session"],
]) {
yield* Effect.promise(() =>
expect(
exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]),
).rejects.toThrow("Call device_open first"),
);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});
43 changes: 43 additions & 0 deletions apps/server/src/device/AgentDeviceTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as NodeCrypto from "node:crypto";
import * as Schema from "effect/Schema";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import type { AgentDeviceEndpoint } from "./DeviceHost.ts";

const encodeEndpoint = Schema.encodeEffect(
Schema.fromJsonString(
Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }),
),
);

const key = (value: string) =>
NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);

/** A stable file per host lets forwarded endpoints change without retargeting other commands. */
export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) =>
path.join(stateDir, "device", "hosts", `${key(hostId)}.json`);

export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>
`t3-${key(JSON.stringify([threadId, hostId, deviceId]))}`;

export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
file: string,
endpoint: AgentDeviceEndpoint,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
const content = yield* encodeEndpoint({
daemonBaseUrl: endpoint.baseUrl,
daemonAuthToken: endpoint.token,
});
if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return;
const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" });
yield* Effect.gen(function* () {
yield* fs.chmod(temporary, 0o600);
yield* fs.writeFileString(temporary, content);
yield* fs.rename(temporary, file);
}).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore)));
});
Loading
Loading