diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index c972e5372f4c..2c747fc71310 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -82,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin AuthOrchestrationOperateScope, ); }); + +it("requires operate permission for tool updates even alongside a read-only check", () => { + expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 819d98cfc92c..68a167083762 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -184,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope /** Retrying can install or restart tools even though ordinary listing is readable. */ export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => - input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope; + input.retryHostId || input.updateTool + ? AuthOrchestrationOperateScope + : AuthOrchestrationReadScope; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index ce57c724f8be..7a81d757c0ac 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + DeviceOperationError, LOCAL_DEVICE_HOST_ID, ThreadId, type DeviceServiceState, @@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* ( failListAfterShutdown = false, runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, inspectError = false, + installTool?: Parameters[3], ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + const service = yield* makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + installTool, + ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, @@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i expect(starts).toEqual([]); }).pipe(Effect.scoped), ); + +it.effect( + "manual updates install only the selected tool without enabling access or starting helpers", + () => + Effect.gen(function* () { + const installed: string[] = []; + const { service, starts, agentStarts, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + (tool) => + Effect.sync(() => { + installed.push(tool); + }), + ); + const before = yield* service.state; + const state = yield* service.updateTool("agent"); + expect(installed).toEqual(["agent"]); + expect(state.supportsToolUpdate).toBe(true); + expect(state.hostStatus).toBe(before.hostStatus); + expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); + expect(state.revision).toBeGreaterThan(before.revision); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + yield* service.updateTool("hub"); + expect(installed).toEqual(["agent", "hub"]); + }).pipe(Effect.scoped), +); + +it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => + Effect.gen(function* () { + let attempts = 0; + const { service, starts, agentStarts } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + () => + Effect.suspend(() => + ++attempts === 1 + ? Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause: new Error("offline"), + }), + ) + : Effect.void, + ), + ); + const before = yield* service.state; + const result = yield* service.updateTool("agent").pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(yield* service.state).toEqual(before); + yield* service.updateTool("agent"); + expect(attempts).toBe(2); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index f99522b2bd7b..2435fbca34ab 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -38,7 +38,7 @@ import { import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; -import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly updateTool: (tool: "hub" | "agent") => Effect.Effect; readonly inspect: Effect.Effect; readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; @@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent configuration is unavailable in this device service.", }), ), + installTool?: (tool: "hub" | "agent") => Effect.Effect, ) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); @@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const stateRef = yield* SynchronizedRef.make({ state: { supportsHostRetry: true, + supportsToolUpdate: installTool !== undefined, supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", @@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + updateTool: (tool) => + lifecycleLock.withPermit( + Effect.gen(function* () { + if (!installTool) + return yield* Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }), + ); + yield* installTool(tool); + return yield* inspect; + }), + ), retryHost, inspect, agentCli: Effect.fail( @@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () { ), ), configureAgent, + (tool) => + (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause, + }), + ), + ), ); const hostContext = yield* Effect.context>>(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 05b680b441f7..90271e00adfb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3456,13 +3456,15 @@ const makeWsRpcLayer = ( [WS_METHODS.deviceList]: (input) => observeRpcEffect( WS_METHODS.deviceList, - input.inspectOnly + input.inspectOnly && !input.updateTool ? deviceService.inspect : authorizeEffect( requiredScopeForDeviceList(input), - input.retryHostId - ? deviceService.retryHost(input.retryHostId) - : deviceService.list, + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, ), { "rpc.aggregate": "device", diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx index 0501c8370bdc..d9a79f4bddb5 100644 --- a/apps/web/src/components/device/DeviceToolVersions.tsx +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -57,7 +57,7 @@ export function DeviceToolVersions({ .filter(([name]) => !kind || name === label) .map(([name, tool]) => (
-

{name}

+ {!kind ?

{name}

: null}
Running
{tool.runningVersion ?? "Not running"}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 851ca7439f99..b6b4a77b7c04 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -642,7 +642,9 @@ function DeviceIntegrationControls({ ); const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); - const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); + const [pending, setPending] = useState< + "hub" | "check" | "agent" | "update-hub" | "update-agent" | null + >(null); const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; const [platformsRevealed, setPlatformsRevealed] = useState(false); // Keep diagnostics visible through subsequent agent setup and refresh phases. @@ -685,20 +687,64 @@ function DeviceIntegrationControls({ } }; - const checkVersions = state.supportsToolInspection ? ( - - ) : null; + const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>( + null, + ); + const localTools = state.hosts.find((host) => host.kind === "local")?.tools; + const versionActions = (tool: "hub" | "agent") => { + const version = localTools?.[tool]; + const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion); + return ( +
+
+ {state.supportsToolUpdate && needsUpdate ? ( + + ) : null} + {state.supportsToolInspection ? ( + + ) : null} +
+ {updateError?.tool === tool ? ( +

+ {updateError.message} +

+ ) : null} +
+ ); + }; return ( <> @@ -710,7 +756,7 @@ function DeviceIntegrationControls({ control={ <> host.kind === "local")?.tools} /> @@ -774,7 +820,7 @@ function DeviceIntegrationControls({ control={ <> host.kind === "local")?.tools} /> diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 20b49f850123..63859cfd3619 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ supportsHostRetry: Schema.optional(Schema.Boolean), + supportsToolUpdate: Schema.optional(Schema.Boolean), supportsToolInspection: Schema.optional(Schema.Boolean), hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, @@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({ export type DeviceServiceState = typeof DeviceServiceState.Type; export const DeviceListInput = Schema.Struct({ + /** Install this server's pinned tool without enabling access or starting helpers. */ + updateTool: Schema.optional(Schema.Literals(["hub", "agent"])), /** Read inventory without installing tools or starting helpers. */ inspectOnly: Schema.optional(Schema.Boolean), /** Retry this host only, including agent tools if access was already granted. */