| author | |
| committer | |
| log | 1de563c1491c7d82563e4553bf5bf689ce6adbb9 |
| tree | a35bfb9eee5bce24114c392f1c6c08f18c42003a |
| parent | b379b5b1407b4c718095cd13395173b6cc005218 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 188 insertions(+), 25 deletions(-)
apps/server/src/auth/RpcAuthorization.test.ts+7| ... | ... | @@ -82,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin |
| 82 | 82 | AuthOrchestrationOperateScope, |
| 83 | 83 | ); |
| 84 | 84 | }); |
| 85 | ||
| 86 | it("requires operate permission for tool updates even alongside a read-only check", () => { | |
| 87 | expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( | |
| 88 | AuthOrchestrationOperateScope, | |
| 89 | ); | |
| 90 | expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); | |
| 91 | }); |
apps/server/src/auth/RpcAuthorization.ts+3-1| ... | ... | @@ -184,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope |
| 184 | 184 | |
| 185 | 185 | /** Retrying can install or restart tools even though ordinary listing is readable. */ |
| 186 | 186 | export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => |
| 187 | input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope; | |
| 187 | input.retryHostId || input.updateTool | |
| 188 | ? AuthOrchestrationOperateScope | |
| 189 | : AuthOrchestrationReadScope; |
apps/server/src/device/DeviceService.test.ts+72-1| ... | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { |
| 3 | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | 4 | DeviceId, |
| 5 | DeviceOperationError, | |
| 5 | 6 | LOCAL_DEVICE_HOST_ID, |
| 6 | 7 | ThreadId, |
| 7 | 8 | type DeviceServiceState, |
| ... | ... | @@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* ( |
| 68 | 69 | failListAfterShutdown = false, |
| 69 | 70 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, |
| 70 | 71 | inspectError = false, |
| 72 | installTool?: Parameters<typeof makeWithHosts>[3], | |
| 71 | 73 | ) { |
| 72 | 74 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); |
| 73 | 75 | const starts: string[] = []; |
| ... | ... | @@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( |
| 129 | 131 | starts.push("stop"); |
| 130 | 132 | }), |
| 131 | 133 | }; |
| 132 | const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( | |
| 134 | const service = yield* makeWithHosts( | |
| 135 | new Map([[host.id, host]]), | |
| 136 | undefined, | |
| 137 | undefined, | |
| 138 | installTool, | |
| 139 | ).pipe( | |
| 133 | 140 | Effect.provideService(DeviceHost.DeviceHost, host), |
| 134 | 141 | Effect.provideService( |
| 135 | 142 | ServerSettingsService, |
| ... | ... | @@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i |
| 660 | 667 | expect(starts).toEqual([]); |
| 661 | 668 | }).pipe(Effect.scoped), |
| 662 | 669 | ); |
| 670 | ||
| 671 | it.effect( | |
| 672 | "manual updates install only the selected tool without enabling access or starting helpers", | |
| 673 | () => | |
| 674 | Effect.gen(function* () { | |
| 675 | const installed: string[] = []; | |
| 676 | const { service, starts, agentStarts, requests } = yield* fixture( | |
| 677 | Effect.void, | |
| 678 | undefined, | |
| 679 | false, | |
| 680 | undefined, | |
| 681 | false, | |
| 682 | (tool) => | |
| 683 | Effect.sync(() => { | |
| 684 | installed.push(tool); | |
| 685 | }), | |
| 686 | ); | |
| 687 | const before = yield* service.state; | |
| 688 | const state = yield* service.updateTool("agent"); | |
| 689 | expect(installed).toEqual(["agent"]); | |
| 690 | expect(state.supportsToolUpdate).toBe(true); | |
| 691 | expect(state.hostStatus).toBe(before.hostStatus); | |
| 692 | expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); | |
| 693 | expect(state.revision).toBeGreaterThan(before.revision); | |
| 694 | expect(starts).toEqual([]); | |
| 695 | expect(agentStarts).toEqual([]); | |
| 696 | expect(requests).toEqual([]); | |
| 697 | yield* service.updateTool("hub"); | |
| 698 | expect(installed).toEqual(["agent", "hub"]); | |
| 699 | }).pipe(Effect.scoped), | |
| 700 | ); | |
| 701 | ||
| 702 | it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => | |
| 703 | Effect.gen(function* () { | |
| 704 | let attempts = 0; | |
| 705 | const { service, starts, agentStarts } = yield* fixture( | |
| 706 | Effect.void, | |
| 707 | undefined, | |
| 708 | false, | |
| 709 | undefined, | |
| 710 | false, | |
| 711 | () => | |
| 712 | Effect.suspend(() => | |
| 713 | ++attempts === 1 | |
| 714 | ? Effect.fail( | |
| 715 | new DeviceOperationError({ | |
| 716 | operation: "update device tool", | |
| 717 | reason: "command_failed", | |
| 718 | cause: new Error("offline"), | |
| 719 | }), | |
| 720 | ) | |
| 721 | : Effect.void, | |
| 722 | ), | |
| 723 | ); | |
| 724 | const before = yield* service.state; | |
| 725 | const result = yield* service.updateTool("agent").pipe(Effect.result); | |
| 726 | expect(result._tag).toBe("Failure"); | |
| 727 | expect(yield* service.state).toEqual(before); | |
| 728 | yield* service.updateTool("agent"); | |
| 729 | expect(attempts).toBe(2); | |
| 730 | expect(starts).toEqual([]); | |
| 731 | expect(agentStarts).toEqual([]); | |
| 732 | }).pipe(Effect.scoped), | |
| 733 | ); |
apps/server/src/device/DeviceService.ts+33-1| ... | ... | @@ -38,7 +38,7 @@ import { |
| 38 | 38 | import * as FileSystem from "effect/FileSystem"; |
| 39 | 39 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; |
| 40 | 40 | import * as Path from "effect/Path"; |
| 41 | import { ensureAgentDevice } from "./DeviceToolchain.ts"; | |
| 41 | import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; | |
| 42 | 42 | import * as ServerConfig from "../config.ts"; |
| 43 | 43 | import { |
| 44 | 44 | agentDeviceConfigPath, |
| ... | ... | @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< |
| 126 | 126 | ) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 127 | 127 | /** Refreshes devices only after device support has been enabled. */ |
| 128 | 128 | readonly list: Effect.Effect<DeviceServiceState, DeviceError>; |
| 129 | readonly updateTool: (tool: "hub" | "agent") => Effect.Effect<DeviceServiceState, DeviceError>; | |
| 129 | 130 | readonly inspect: Effect.Effect<DeviceServiceState>; |
| 130 | 131 | readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 131 | 132 | readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>; |
| ... | ... | @@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 182 | 183 | reason: "Agent configuration is unavailable in this device service.", |
| 183 | 184 | }), |
| 184 | 185 | ), |
| 186 | installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>, | |
| 185 | 187 | ) { |
| 186 | 188 | const settings = yield* ServerSettings.ServerSettingsService; |
| 187 | 189 | const lifecycleLock = yield* Semaphore.make(1); |
| ... | ... | @@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 205 | 207 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ |
| 206 | 208 | state: { |
| 207 | 209 | supportsHostRetry: true, |
| 210 | supportsToolUpdate: installTool !== undefined, | |
| 208 | 211 | supportsToolInspection: true, |
| 209 | 212 | hosts: initialHosts, |
| 210 | 213 | hostStatus: initialSettings.enabled ? "idle" : "disabled", |
| ... | ... | @@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 899 | 902 | return { |
| 900 | 903 | ...DeviceService.of({ |
| 901 | 904 | testHost, |
| 905 | updateTool: (tool) => | |
| 906 | lifecycleLock.withPermit( | |
| 907 | Effect.gen(function* () { | |
| 908 | if (!installTool) | |
| 909 | return yield* Effect.fail( | |
| 910 | new DeviceOperationError({ | |
| 911 | operation: "update device tool", | |
| 912 | reason: "request_failed", | |
| 913 | cause: new Error("Tool installation is unavailable in this device service."), | |
| 914 | }), | |
| 915 | ); | |
| 916 | yield* installTool(tool); | |
| 917 | return yield* inspect; | |
| 918 | }), | |
| 919 | ), | |
| 902 | 920 | retryHost, |
| 903 | 921 | inspect, |
| 904 | 922 | agentCli: Effect.fail( |
| ... | ... | @@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () { |
| 1021 | 1039 | ), |
| 1022 | 1040 | ), |
| 1023 | 1041 | configureAgent, |
| 1042 | (tool) => | |
| 1043 | (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( | |
| 1044 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 1045 | Effect.provideService(Path.Path, path), | |
| 1046 | Effect.provideService(ProcessRunner.ProcessRunner, runner), | |
| 1047 | Effect.mapError( | |
| 1048 | (cause) => | |
| 1049 | new DeviceOperationError({ | |
| 1050 | operation: "update device tool", | |
| 1051 | reason: "command_failed", | |
| 1052 | cause, | |
| 1053 | }), | |
| 1054 | ), | |
| 1055 | ), | |
| 1024 | 1056 | ); |
| 1025 | 1057 | const hostContext = |
| 1026 | 1058 | yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>(); |
apps/server/src/ws.ts+6-4| ... | ... | @@ -3456,13 +3456,15 @@ const makeWsRpcLayer = ( |
| 3456 | 3456 | [WS_METHODS.deviceList]: (input) => |
| 3457 | 3457 | observeRpcEffect( |
| 3458 | 3458 | WS_METHODS.deviceList, |
| 3459 | input.inspectOnly | |
| 3459 | input.inspectOnly && !input.updateTool | |
| 3460 | 3460 | ? deviceService.inspect |
| 3461 | 3461 | : authorizeEffect( |
| 3462 | 3462 | requiredScopeForDeviceList(input), |
| 3463 | input.retryHostId | |
| 3464 | ? deviceService.retryHost(input.retryHostId) | |
| 3465 | : deviceService.list, | |
| 3463 | input.updateTool | |
| 3464 | ? deviceService.updateTool(input.updateTool) | |
| 3465 | : input.retryHostId | |
| 3466 | ? deviceService.retryHost(input.retryHostId) | |
| 3467 | : deviceService.list, | |
| 3466 | 3468 | ), |
| 3467 | 3469 | { |
| 3468 | 3470 | "rpc.aggregate": "device", |
apps/web/src/components/device/DeviceToolVersions.tsx+1-1| ... | ... | @@ -57,7 +57,7 @@ export function DeviceToolVersions({ |
| 57 | 57 | .filter(([name]) => !kind || name === label) |
| 58 | 58 | .map(([name, tool]) => ( |
| 59 | 59 | <div key={name} className="space-y-2 py-3 first:pt-0 last:pb-0"> |
| 60 | <p className="text-xs font-medium">{name}</p> | |
| 60 | {!kind ? <p className="text-xs font-medium">{name}</p> : null} | |
| 61 | 61 | <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs"> |
| 62 | 62 | <dt className="text-muted-foreground">Running</dt> |
| 63 | 63 | <dd className="text-right font-mono">{tool.runningVersion ?? "Not running"}</dd> |
apps/web/src/components/settings/IntegrationsSettings.tsx+63-17| ... | ... | @@ -642,7 +642,9 @@ function DeviceIntegrationControls({ |
| 642 | 642 | ); |
| 643 | 643 | const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); |
| 644 | 644 | const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); |
| 645 | const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); | |
| 645 | const [pending, setPending] = useState< | |
| 646 | "hub" | "check" | "agent" | "update-hub" | "update-agent" | null | |
| 647 | >(null); | |
| 646 | 648 | const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; |
| 647 | 649 | const [platformsRevealed, setPlatformsRevealed] = useState(false); |
| 648 | 650 | // Keep diagnostics visible through subsequent agent setup and refresh phases. |
| ... | ... | @@ -685,20 +687,64 @@ function DeviceIntegrationControls({ |
| 685 | 687 | } |
| 686 | 688 | }; |
| 687 | 689 | |
| 688 | const checkVersions = state.supportsToolInspection ? ( | |
| 689 | <Button | |
| 690 | size="sm" | |
| 691 | variant="outline" | |
| 692 | disabled={!environmentId || pending !== null || busy} | |
| 693 | onClick={() => { | |
| 694 | if (!environmentId) return; | |
| 695 | setPending("check"); | |
| 696 | void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null)); | |
| 697 | }} | |
| 698 | > | |
| 699 | {pending === "check" ? "Checking…" : "Check versions"} | |
| 700 | </Button> | |
| 701 | ) : null; | |
| 690 | const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>( | |
| 691 | null, | |
| 692 | ); | |
| 693 | const localTools = state.hosts.find((host) => host.kind === "local")?.tools; | |
| 694 | const versionActions = (tool: "hub" | "agent") => { | |
| 695 | const version = localTools?.[tool]; | |
| 696 | const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion); | |
| 697 | return ( | |
| 698 | <div className="space-y-2"> | |
| 699 | <div className="flex flex-wrap gap-2"> | |
| 700 | {state.supportsToolUpdate && needsUpdate ? ( | |
| 701 | <Button | |
| 702 | size="sm" | |
| 703 | disabled={!environmentId || pending !== null || busy} | |
| 704 | onClick={() => { | |
| 705 | if (!environmentId) return; | |
| 706 | setUpdateError(null); | |
| 707 | setPending(`update-${tool}`); | |
| 708 | void list({ environmentId, input: { updateTool: tool } }) | |
| 709 | .then((result) => { | |
| 710 | if (result._tag === "Failure") | |
| 711 | setUpdateError({ | |
| 712 | tool, | |
| 713 | message: | |
| 714 | "Update failed. Check this host's network connection and try again.", | |
| 715 | }); | |
| 716 | }) | |
| 717 | .finally(() => setPending(null)); | |
| 718 | }} | |
| 719 | > | |
| 720 | {pending === `update-${tool}` ? "Updating…" : `Update to v${version.requiredVersion}`} | |
| 721 | </Button> | |
| 722 | ) : null} | |
| 723 | {state.supportsToolInspection ? ( | |
| 724 | <Button | |
| 725 | size="sm" | |
| 726 | variant="outline" | |
| 727 | disabled={!environmentId || pending !== null || busy} | |
| 728 | onClick={() => { | |
| 729 | if (!environmentId) return; | |
| 730 | setPending("check"); | |
| 731 | void list({ environmentId, input: { inspectOnly: true } }).finally(() => | |
| 732 | setPending(null), | |
| 733 | ); | |
| 734 | }} | |
| 735 | > | |
| 736 | {pending === "check" ? "Checking…" : "Check versions"} | |
| 737 | </Button> | |
| 738 | ) : null} | |
| 739 | </div> | |
| 740 | {updateError?.tool === tool ? ( | |
| 741 | <p role="alert" className="text-xs text-destructive"> | |
| 742 | {updateError.message} | |
| 743 | </p> | |
| 744 | ) : null} | |
| 745 | </div> | |
| 746 | ); | |
| 747 | }; | |
| 702 | 748 | |
| 703 | 749 | return ( |
| 704 | 750 | <> |
| ... | ... | @@ -710,7 +756,7 @@ function DeviceIntegrationControls({ |
| 710 | 756 | control={ |
| 711 | 757 | <> |
| 712 | 758 | <DeviceToolVersions |
| 713 | action={checkVersions} | |
| 759 | action={versionActions("hub")} | |
| 714 | 760 | kind="hub" |
| 715 | 761 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 716 | 762 | /> |
| ... | ... | @@ -774,7 +820,7 @@ function DeviceIntegrationControls({ |
| 774 | 820 | control={ |
| 775 | 821 | <> |
| 776 | 822 | <DeviceToolVersions |
| 777 | action={checkVersions} | |
| 823 | action={versionActions("agent")} | |
| 778 | 824 | kind="agent" |
| 779 | 825 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 780 | 826 | /> |
packages/contracts/src/device.ts+3| ... | ... | @@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; |
| 129 | 129 | |
| 130 | 130 | export const DeviceServiceState = Schema.Struct({ |
| 131 | 131 | supportsHostRetry: Schema.optional(Schema.Boolean), |
| 132 | supportsToolUpdate: Schema.optional(Schema.Boolean), | |
| 132 | 133 | supportsToolInspection: Schema.optional(Schema.Boolean), |
| 133 | 134 | hosts: Schema.Array(DeviceHostSummary), |
| 134 | 135 | hostStatus: DeviceHostStatus, |
| ... | ... | @@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({ |
| 154 | 155 | export type DeviceServiceState = typeof DeviceServiceState.Type; |
| 155 | 156 | |
| 156 | 157 | export const DeviceListInput = Schema.Struct({ |
| 158 | /** Install this server's pinned tool without enabling access or starting helpers. */ | |
| 159 | updateTool: Schema.optional(Schema.Literals(["hub", "agent"])), | |
| 157 | 160 | /** Read inventory without installing tools or starting helpers. */ |
| 158 | 161 | inspectOnly: Schema.optional(Schema.Boolean), |
| 159 | 162 | /** Retry this host only, including agent tools if access was already granted. */ |