Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9101a34
Use typed Environment HttpApi clients
juliusmarminge May 29, 2026
9e747da
Refine Environment HttpApi middleware and typed errors
juliusmarminge May 29, 2026
86a5954
Refactor environment auth around scoped token exchange
juliusmarminge May 29, 2026
e8df5ac
Implement granular environment authorization scopes
juliusmarminge May 29, 2026
18a70f0
Refine environment HTTP authentication boundaries
juliusmarminge May 29, 2026
c0c862f
Refine environment HTTP typed error boundaries
juliusmarminge May 29, 2026
f9c9f4d
Add environment client presentation metadata exchange
juliusmarminge May 29, 2026
58fd91b
Report typed Effect failures in API logs
juliusmarminge May 30, 2026
2bbd266
Use typed environment HTTP handlers in web tests
juliusmarminge May 30, 2026
aeef8a7
Align typed environment HTTP browser tests with Vitest
juliusmarminge May 30, 2026
bd8c4d1
Add scoped environment pairing links
juliusmarminge May 30, 2026
c693156
Show explicit client authorization scopes
juliusmarminge May 30, 2026
558f84b
Compact authorization scope display
juliusmarminge May 30, 2026
596b8d6
Fix environment auth review issues
juliusmarminge May 30, 2026
a97a1da
Preserve environment auth persistence errors
juliusmarminge Jun 1, 2026
7d87249
Tidy auth credential error helpers
juliusmarminge Jun 1, 2026
295145c
refactor environment auth service layout
juliusmarminge Jun 1, 2026
a6cdbfb
trace environment auth service boundaries
juliusmarminge Jun 1, 2026
67cd500
use Effect.fn operators for auth route errors
juliusmarminge Jun 1, 2026
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@t3tools/client-runtime": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
ensureSshEnvironment,
fetchSshEnvironmentDescriptor,
fetchSshSessionState,
issueSshWebSocketToken,
issueSshWebSocketTicket,
resolveSshPasswordPrompt,
} from "./methods/sshEnvironment.ts";
import {
Expand Down Expand Up @@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
yield* ipc.handle(fetchSshEnvironmentDescriptor);
yield* ipc.handle(bootstrapSshBearerSession);
yield* ipc.handle(fetchSshSessionState);
yield* ipc.handle(issueSshWebSocketToken);
yield* ipc.handle(issueSshWebSocketTicket);
yield* ipc.handle(resolveSshPasswordPrompt);

yield* ipc.handle(getServerExposureState);
Expand Down
115 changes: 115 additions & 0 deletions apps/desktop/src/ipc/methods/sshEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { assert, describe, it } from "@effect/vitest";
import { SshHttpBridgeError } from "@t3tools/ssh/errors";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";

import {
DesktopSshEnvironmentRequestError,
fetchSshEnvironmentDescriptor,
} from "./sshEnvironment.ts";

function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
return HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
}

function makeHttpClientLayer(
handler: (
request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>,
) {
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => handler(request)),
);
}

describe("SSH environment IPC", () => {
it.effect("fetches and decodes the remote environment descriptor", () => {
const requestUrls: string[] = [];
const layer = makeHttpClientLayer((request) =>
Effect.sync(() => {
requestUrls.push(request.url);
return jsonResponse(request, {
environmentId: "remote-env",
label: "Remote Devbox",
platform: { os: "linux", arch: "x64" },
serverVersion: "1.2.3",
capabilities: { repositoryIdentity: true },
});
}),
);

return Effect.gen(function* () {
const descriptor = yield* fetchSshEnvironmentDescriptor.handler({
httpBaseUrl: "http://127.0.0.1:41773/",
});

assert.deepEqual(descriptor, {
environmentId: "remote-env",
label: "Remote Devbox",
platform: { os: "linux", arch: "x64" },
serverVersion: "1.2.3",
capabilities: { repositoryIdentity: true },
});
assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
}).pipe(Effect.provide(layer));
});

it.effect("wraps schema decode failures in a typed request error", () => {
const layer = makeHttpClientLayer((request) =>
Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
);

return Effect.gen(function* () {
const exit = yield* Effect.exit(
fetchSshEnvironmentDescriptor.handler({
httpBaseUrl: "http://127.0.0.1:41773/",
}),
);
assert(Exit.isFailure(exit));
const failure = Cause.findErrorOption(exit.cause);
assert(Option.isSome(failure));
const error = failure.value;

assert.instanceOf(error, DesktopSshEnvironmentRequestError);
assert.equal(error.operation, "fetch-environment-descriptor");
assert.equal(error.cause instanceof SshHttpBridgeError, false);
}).pipe(Effect.provide(layer));
});

it.effect("rejects non-loopback HTTP endpoints before issuing a request", () => {
let requestCount = 0;
const layer = makeHttpClientLayer((request) =>
Effect.sync(() => {
requestCount += 1;
return jsonResponse(request, {});
}),
);

return Effect.gen(function* () {
const exit = yield* Effect.exit(
fetchSshEnvironmentDescriptor.handler({
httpBaseUrl: "http://remote.example.com:41773/",
}),
);
assert(Exit.isFailure(exit));
const failure = Cause.findErrorOption(exit.cause);
assert(Option.isSome(failure));
const error = failure.value;

assert.instanceOf(error, DesktopSshEnvironmentRequestError);
assert.instanceOf(error.cause, SshHttpBridgeError);
assert.equal(requestCount, 0);
}).pipe(Effect.provide(layer));
});
});
78 changes: 63 additions & 15 deletions apps/desktop/src/ipc/methods/sshEnvironment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
bootstrapRemoteBearerSession,
fetchRemoteEnvironmentDescriptor,
fetchRemoteSessionState,
issueRemoteWebSocketTicket,
} from "@t3tools/client-runtime";
import {
DesktopDiscoveredSshHostSchema,
DesktopSshBearerBootstrapInputSchema,
Expand All @@ -9,18 +15,47 @@ import {
DesktopSshPasswordPromptCancelledType,
DesktopSshPasswordPromptResolutionInputSchema,
ExecutionEnvironmentDescriptor,
AuthBearerBootstrapResult,
AuthAccessTokenResult,
AuthSessionState,
AuthWebSocketTokenResult,
AuthWebSocketTicketResult,
} from "@t3tools/contracts";
import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as IpcChannels from "../channels.ts";
import { makeIpcMethod } from "../DesktopIpc.ts";
import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts";

type DesktopSshEnvironmentRequestOperation =
| "fetch-environment-descriptor"
| "bootstrap-bearer-session"
| "fetch-session-state"
| "issue-websocket-ticket";

export class DesktopSshEnvironmentRequestError extends Data.TaggedError(
"DesktopSshEnvironmentRequestError",
)<{
readonly operation: DesktopSshEnvironmentRequestOperation;
readonly cause: unknown;
}> {
override get message() {
return `SSH remote API request failed during ${this.operation}.`;
}
}

const withLoopbackSshApi =
<A, E, R>(
operation: DesktopSshEnvironmentRequestOperation,
use: (httpBaseUrl: string) => Effect.Effect<A, E, R>,
) =>
(httpBaseUrl: string): Effect.Effect<A, DesktopSshEnvironmentRequestError, R> =>
resolveLoopbackSshHttpBaseUrl(httpBaseUrl).pipe(
Effect.flatMap(use),
Effect.mapError((cause) => new DesktopSshEnvironmentRequestError({ operation, cause })),
);

export const discoverSshHosts = makeIpcMethod({
channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL,
Expand Down Expand Up @@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({
payload: DesktopSshHttpBaseUrlInputSchema,
result: ExecutionEnvironmentDescriptor,
handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) {
const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl });
return yield* withLoopbackSshApi("fetch-environment-descriptor", (resolvedHttpBaseUrl) =>
fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolvedHttpBaseUrl }),
)(httpBaseUrl);
}),
});

export const bootstrapSshBearerSession = makeIpcMethod({
channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
payload: DesktopSshBearerBootstrapInputSchema,
result: AuthBearerBootstrapResult,
result: AuthAccessTokenResult,
handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({
httpBaseUrl,
credential,
}) {
const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential });
return yield* withLoopbackSshApi("bootstrap-bearer-session", (resolvedHttpBaseUrl) =>
bootstrapRemoteBearerSession({
httpBaseUrl: resolvedHttpBaseUrl,
credential,
}),
)(httpBaseUrl);
}),
});

Expand All @@ -95,21 +135,29 @@ export const fetchSshSessionState = makeIpcMethod({
httpBaseUrl,
bearerToken,
}) {
const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken });
return yield* withLoopbackSshApi("fetch-session-state", (resolvedHttpBaseUrl) =>
fetchRemoteSessionState({
httpBaseUrl: resolvedHttpBaseUrl,
bearerToken,
}),
)(httpBaseUrl);
}),
});

export const issueSshWebSocketToken = makeIpcMethod({
export const issueSshWebSocketTicket = makeIpcMethod({
channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
payload: DesktopSshBearerRequestInputSchema,
result: AuthWebSocketTokenResult,
handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({
result: AuthWebSocketTicketResult,
handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({
httpBaseUrl,
bearerToken,
}) {
const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken });
return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) =>
issueRemoteWebSocketTicket({
httpBaseUrl: resolvedHttpBaseUrl,
bearerToken,
}),
)(httpBaseUrl);
}),
});

Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts";
import * as DesktopState from "./app/DesktopState.ts";
import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
Expand Down Expand Up @@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll(
DesktopObservability.layer,
).pipe(Layer.provideMerge(desktopEnvironmentLayer));

const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe(
const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
Layer.provideMerge(DesktopSshPasswordPrompts.layer()),
);

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}),
fetchSshSessionState: (httpBaseUrl, bearerToken) =>
ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }),
issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>
issueSshWebSocketTicket: (httpBaseUrl, bearerToken) =>
ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }),
onSshPasswordPrompt: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
Expand Down
79 changes: 0 additions & 79 deletions apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts

This file was deleted.

Loading
Loading