| 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,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin |
| 82 | AuthOrchestrationOperateScope, | 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,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope |
| 184 | 184 | ||
| 185 | /** Retrying can install or restart tools even though ordinary listing is readable. */ | 185 | /** Retrying can install or restart tools even though ordinary listing is readable. */ |
| 186 | export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => | 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,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | DeviceId, | 4 | DeviceId, |
| 5 | DeviceOperationError, | ||
| 5 | LOCAL_DEVICE_HOST_ID, | 6 | LOCAL_DEVICE_HOST_ID, |
| 6 | ThreadId, | 7 | ThreadId, |
| 7 | type DeviceServiceState, | 8 | type DeviceServiceState, |
| ... | @@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* ( |
| 68 | failListAfterShutdown = false, | 69 | failListAfterShutdown = false, |
| 69 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, | 70 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, |
| 70 | inspectError = false, | 71 | inspectError = false, |
| 72 | installTool?: Parameters<typeof makeWithHosts>[3], | ||
| 71 | ) { | 73 | ) { |
| 72 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); | 74 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); |
| 73 | const starts: string[] = []; | 75 | const starts: string[] = []; |
| ... | @@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( |
| 129 | starts.push("stop"); | 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 | Effect.provideService(DeviceHost.DeviceHost, host), | 140 | Effect.provideService(DeviceHost.DeviceHost, host), |
| 134 | Effect.provideService( | 141 | Effect.provideService( |
| 135 | ServerSettingsService, | 142 | ServerSettingsService, |
| ... | @@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i | ... | @@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i |
| 660 | expect(starts).toEqual([]); | 667 | expect(starts).toEqual([]); |
| 661 | }).pipe(Effect.scoped), | 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,7 +38,7 @@ import { |
| 38 | import * as FileSystem from "effect/FileSystem"; | 38 | import * as FileSystem from "effect/FileSystem"; |
| 39 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; | 39 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; |
| 40 | import * as Path from "effect/Path"; | 40 | import * as Path from "effect/Path"; |
| 41 | import { ensureAgentDevice } from "./DeviceToolchain.ts"; | 41 | import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; |
| 42 | import * as ServerConfig from "../config.ts"; | 42 | import * as ServerConfig from "../config.ts"; |
| 43 | import { | 43 | import { |
| 44 | agentDeviceConfigPath, | 44 | agentDeviceConfigPath, |
| ... | @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< | ... | @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< |
| 126 | ) => Effect.Effect<DeviceServiceState, DeviceError>; | 126 | ) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 127 | /** Refreshes devices only after device support has been enabled. */ | 127 | /** Refreshes devices only after device support has been enabled. */ |
| 128 | readonly list: Effect.Effect<DeviceServiceState, DeviceError>; | 128 | readonly list: Effect.Effect<DeviceServiceState, DeviceError>; |
| 129 | readonly updateTool: (tool: "hub" | "agent") => Effect.Effect<DeviceServiceState, DeviceError>; | ||
| 129 | readonly inspect: Effect.Effect<DeviceServiceState>; | 130 | readonly inspect: Effect.Effect<DeviceServiceState>; |
| 130 | readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>; | 131 | readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 131 | readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>; | 132 | readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>; |
| ... | @@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 182 | reason: "Agent configuration is unavailable in this device service.", | 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 | const settings = yield* ServerSettings.ServerSettingsService; | 188 | const settings = yield* ServerSettings.ServerSettingsService; |
| 187 | const lifecycleLock = yield* Semaphore.make(1); | 189 | const lifecycleLock = yield* Semaphore.make(1); |
| ... | @@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 205 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ | 207 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ |
| 206 | state: { | 208 | state: { |
| 207 | supportsHostRetry: true, | 209 | supportsHostRetry: true, |
| 210 | supportsToolUpdate: installTool !== undefined, | ||
| 208 | supportsToolInspection: true, | 211 | supportsToolInspection: true, |
| 209 | hosts: initialHosts, | 212 | hosts: initialHosts, |
| 210 | hostStatus: initialSettings.enabled ? "idle" : "disabled", | 213 | hostStatus: initialSettings.enabled ? "idle" : "disabled", |
| ... | @@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 899 | return { | 902 | return { |
| 900 | ...DeviceService.of({ | 903 | ...DeviceService.of({ |
| 901 | testHost, | 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 | retryHost, | 920 | retryHost, |
| 903 | inspect, | 921 | inspect, |
| 904 | agentCli: Effect.fail( | 922 | agentCli: Effect.fail( |
| ... | @@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () { | ... | @@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () { |
| 1021 | ), | 1039 | ), |
| 1022 | ), | 1040 | ), |
| 1023 | configureAgent, | 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 | const hostContext = | 1057 | const hostContext = |
| 1026 | yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>(); | 1058 | yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>(); |
apps/server/src/ws.ts+6-4| ... | @@ -3456,13 +3456,15 @@ const makeWsRpcLayer = ( | ... | @@ -3456,13 +3456,15 @@ const makeWsRpcLayer = ( |
| 3456 | [WS_METHODS.deviceList]: (input) => | 3456 | [WS_METHODS.deviceList]: (input) => |
| 3457 | observeRpcEffect( | 3457 | observeRpcEffect( |
| 3458 | WS_METHODS.deviceList, | 3458 | WS_METHODS.deviceList, |
| 3459 | input.inspectOnly | 3459 | input.inspectOnly && !input.updateTool |
| 3460 | ? deviceService.inspect | 3460 | ? deviceService.inspect |
| 3461 | : authorizeEffect( | 3461 | : authorizeEffect( |
| 3462 | requiredScopeForDeviceList(input), | 3462 | requiredScopeForDeviceList(input), |
| 3463 | input.retryHostId | 3463 | input.updateTool |
| 3464 | ? deviceService.retryHost(input.retryHostId) | 3464 | ? deviceService.updateTool(input.updateTool) |
| 3465 | : deviceService.list, | 3465 | : input.retryHostId |
| 3466 | ? deviceService.retryHost(input.retryHostId) | ||
| 3467 | : deviceService.list, | ||
| 3466 | ), | 3468 | ), |
| 3467 | { | 3469 | { |
| 3468 | "rpc.aggregate": "device", | 3470 | "rpc.aggregate": "device", |
apps/web/src/components/device/DeviceToolVersions.tsx+1-1| ... | @@ -57,7 +57,7 @@ export function DeviceToolVersions({ | ... | @@ -57,7 +57,7 @@ export function DeviceToolVersions({ |
| 57 | .filter(([name]) => !kind || name === label) | 57 | .filter(([name]) => !kind || name === label) |
| 58 | .map(([name, tool]) => ( | 58 | .map(([name, tool]) => ( |
| 59 | <div key={name} className="space-y-2 py-3 first:pt-0 last:pb-0"> | 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 | <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs"> | 61 | <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs"> |
| 62 | <dt className="text-muted-foreground">Running</dt> | 62 | <dt className="text-muted-foreground">Running</dt> |
| 63 | <dd className="text-right font-mono">{tool.runningVersion ?? "Not running"}</dd> | 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,7 +642,9 @@ function DeviceIntegrationControls({ |
| 642 | ); | 642 | ); |
| 643 | const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); | 643 | const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); |
| 644 | const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); | 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 | const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; | 648 | const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; |
| 647 | const [platformsRevealed, setPlatformsRevealed] = useState(false); | 649 | const [platformsRevealed, setPlatformsRevealed] = useState(false); |
| 648 | // Keep diagnostics visible through subsequent agent setup and refresh phases. | 650 | // Keep diagnostics visible through subsequent agent setup and refresh phases. |
| ... | @@ -685,20 +687,64 @@ function DeviceIntegrationControls({ | ... | @@ -685,20 +687,64 @@ function DeviceIntegrationControls({ |
| 685 | } | 687 | } |
| 686 | }; | 688 | }; |
| 687 | 689 | ||
| 688 | const checkVersions = state.supportsToolInspection ? ( | 690 | const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>( |
| 689 | <Button | 691 | null, |
| 690 | size="sm" | 692 | ); |
| 691 | variant="outline" | 693 | const localTools = state.hosts.find((host) => host.kind === "local")?.tools; |
| 692 | disabled={!environmentId || pending !== null || busy} | 694 | const versionActions = (tool: "hub" | "agent") => { |
| 693 | onClick={() => { | 695 | const version = localTools?.[tool]; |
| 694 | if (!environmentId) return; | 696 | const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion); |
| 695 | setPending("check"); | 697 | return ( |
| 696 | void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null)); | 698 | <div className="space-y-2"> |
| 697 | }} | 699 | <div className="flex flex-wrap gap-2"> |
| 698 | > | 700 | {state.supportsToolUpdate && needsUpdate ? ( |
| 699 | {pending === "check" ? "Checking…" : "Check versions"} | 701 | <Button |
| 700 | </Button> | 702 | size="sm" |
| 701 | ) : null; | 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 | return ( | 749 | return ( |
| 704 | <> | 750 | <> |
| ... | @@ -710,7 +756,7 @@ function DeviceIntegrationControls({ | ... | @@ -710,7 +756,7 @@ function DeviceIntegrationControls({ |
| 710 | control={ | 756 | control={ |
| 711 | <> | 757 | <> |
| 712 | <DeviceToolVersions | 758 | <DeviceToolVersions |
| 713 | action={checkVersions} | 759 | action={versionActions("hub")} |
| 714 | kind="hub" | 760 | kind="hub" |
| 715 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | 761 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 716 | /> | 762 | /> |
| ... | @@ -774,7 +820,7 @@ function DeviceIntegrationControls({ | ... | @@ -774,7 +820,7 @@ function DeviceIntegrationControls({ |
| 774 | control={ | 820 | control={ |
| 775 | <> | 821 | <> |
| 776 | <DeviceToolVersions | 822 | <DeviceToolVersions |
| 777 | action={checkVersions} | 823 | action={versionActions("agent")} |
| 778 | kind="agent" | 824 | kind="agent" |
| 779 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | 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,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; |
| 129 | 129 | ||
| 130 | export const DeviceServiceState = Schema.Struct({ | 130 | export const DeviceServiceState = Schema.Struct({ |
| 131 | supportsHostRetry: Schema.optional(Schema.Boolean), | 131 | supportsHostRetry: Schema.optional(Schema.Boolean), |
| 132 | supportsToolUpdate: Schema.optional(Schema.Boolean), | ||
| 132 | supportsToolInspection: Schema.optional(Schema.Boolean), | 133 | supportsToolInspection: Schema.optional(Schema.Boolean), |
| 133 | hosts: Schema.Array(DeviceHostSummary), | 134 | hosts: Schema.Array(DeviceHostSummary), |
| 134 | hostStatus: DeviceHostStatus, | 135 | hostStatus: DeviceHostStatus, |
| ... | @@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({ | ... | @@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({ |
| 154 | export type DeviceServiceState = typeof DeviceServiceState.Type; | 155 | export type DeviceServiceState = typeof DeviceServiceState.Type; |
| 155 | 156 | ||
| 156 | export const DeviceListInput = Schema.Struct({ | 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 | /** Read inventory without installing tools or starting helpers. */ | 160 | /** Read inventory without installing tools or starting helpers. */ |
| 158 | inspectOnly: Schema.optional(Schema.Boolean), | 161 | inspectOnly: Schema.optional(Schema.Boolean), |
| 159 | /** Retry this host only, including agent tools if access was already granted. */ | 162 | /** Retry this host only, including agent tools if access was already granted. */ |