Support remote workspace service links
This commit is contained in:
@@ -28,7 +28,7 @@ const runningScript: WorkspaceScriptPayload = {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "main.web.localhost",
|
hostname: "main.web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://main.web.localhost:6767",
|
proxyUrl: "http://main.web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const runningScript: WorkspaceScriptPayload = {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "main.web.localhost",
|
hostname: "main.web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://main.web.localhost:6767",
|
proxyUrl: "http://main.web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -61,7 +61,7 @@ const stoppedScript: WorkspaceScriptPayload = {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "main.api.localhost",
|
hostname: "main.api.localhost",
|
||||||
port: 3001,
|
port: 3001,
|
||||||
url: "http://main.api.localhost:6767",
|
proxyUrl: "http://main.api.localhost:6767",
|
||||||
lifecycle: "stopped",
|
lifecycle: "stopped",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ChevronDown, ExternalLink, Globe, LoaderCircle, Play, SquareTerminal }
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||||
|
|
||||||
type Script = WorkspaceDescriptor["scripts"][number];
|
type Script = WorkspaceDescriptor["scripts"][number];
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ export function WorkspaceScriptsButton({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||||
|
|
||||||
const startScriptMutation = useMutation({
|
const startScriptMutation = useMutation({
|
||||||
mutationFn: async (scriptName: string) => {
|
mutationFn: async (scriptName: string) => {
|
||||||
@@ -105,7 +108,8 @@ export function WorkspaceScriptsButton({
|
|||||||
{scripts.map((script) => {
|
{scripts.map((script) => {
|
||||||
const isRunning = script.lifecycle === "running";
|
const isRunning = script.lifecycle === "running";
|
||||||
const isService = (script.type ?? "service") === "service";
|
const isService = (script.type ?? "service") === "service";
|
||||||
const isLinkable = isService && isRunning && !!script.url;
|
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
|
||||||
|
const isLinkable = isService && isRunning && !!serviceLink.openUrl;
|
||||||
const exitCode = script.exitCode ?? null;
|
const exitCode = script.exitCode ?? null;
|
||||||
|
|
||||||
let dotColor: string;
|
let dotColor: string;
|
||||||
@@ -133,7 +137,9 @@ export function WorkspaceScriptsButton({
|
|||||||
styles.scriptRow,
|
styles.scriptRow,
|
||||||
hovered && isLinkable && styles.scriptRowHovered,
|
hovered && isLinkable && styles.scriptRowHovered,
|
||||||
]}
|
]}
|
||||||
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
|
onPress={
|
||||||
|
isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined
|
||||||
|
}
|
||||||
disabled={!isLinkable}
|
disabled={!isLinkable}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
@@ -156,9 +162,9 @@ export function WorkspaceScriptsButton({
|
|||||||
>
|
>
|
||||||
{script.scriptName}
|
{script.scriptName}
|
||||||
</Text>
|
</Text>
|
||||||
{isService && isRunning && script.url ? (
|
{isService && isRunning && serviceLink.labelUrl ? (
|
||||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||||
{script.url.replace(/^https?:\/\//, "")}
|
{serviceLink.labelUrl.replace(/^https?:\/\//, "")}
|
||||||
</Text>
|
</Text>
|
||||||
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
|
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
|
||||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
type: "service" as const,
|
type: "service" as const,
|
||||||
hostname: "main.web.localhost",
|
hostname: "main.web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://main.web.localhost:6767",
|
proxyUrl: "http://main.web.localhost:6767",
|
||||||
lifecycle: "running" as const,
|
lifecycle: "running" as const,
|
||||||
health: "healthy" as const,
|
health: "healthy" as const,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -69,7 +69,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "main.web.localhost",
|
hostname: "main.web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://main.web.localhost:6767",
|
proxyUrl: "http://main.web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -118,7 +118,7 @@ describe("mergeWorkspaces", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "main.web.localhost",
|
hostname: "main.web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://main.web.localhost:6767",
|
proxyUrl: "http://main.web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -133,7 +133,7 @@ describe("mergeWorkspaces", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "main.web.localhost",
|
hostname: "main.web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://main.web.localhost:6767",
|
proxyUrl: "http://main.web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||||
|
import type { ActiveConnection } from "@/runtime/host-runtime";
|
||||||
|
import { resolveWorkspaceScriptLink } from "./workspace-script-links";
|
||||||
|
|
||||||
|
const runningService: WorkspaceScriptPayload = {
|
||||||
|
scriptName: "web",
|
||||||
|
type: "service",
|
||||||
|
hostname: "feature.web.localhost",
|
||||||
|
port: 3000,
|
||||||
|
proxyUrl: "http://feature.web.localhost:6767",
|
||||||
|
lifecycle: "running",
|
||||||
|
health: "healthy",
|
||||||
|
exitCode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLink(activeConnection: ActiveConnection | null) {
|
||||||
|
return resolveWorkspaceScriptLink({
|
||||||
|
script: runningService,
|
||||||
|
activeConnection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveWorkspaceScriptLink", () => {
|
||||||
|
it("uses the local proxy URL for loopback TCP connections", () => {
|
||||||
|
expect(
|
||||||
|
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
|
||||||
|
).toEqual({
|
||||||
|
openUrl: "http://feature.web.localhost:6767",
|
||||||
|
labelUrl: "http://feature.web.localhost:6767",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the local proxy URL for socket and pipe connections", () => {
|
||||||
|
expect(
|
||||||
|
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
|
||||||
|
).toEqual({
|
||||||
|
openUrl: "http://feature.web.localhost:6767",
|
||||||
|
labelUrl: "http://feature.web.localhost:6767",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to daemon-host plus service port for direct network connections", () => {
|
||||||
|
expect(
|
||||||
|
resolveLink({
|
||||||
|
type: "directTcp",
|
||||||
|
endpoint: "mac-mini.tail123.ts.net:6767",
|
||||||
|
display: "mac-mini.tail123.ts.net:6767",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
openUrl: "http://mac-mini.tail123.ts.net:3000",
|
||||||
|
labelUrl: "http://mac-mini.tail123.ts.net:3000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the local proxy URL but disables opening over relay", () => {
|
||||||
|
expect(
|
||||||
|
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
|
||||||
|
).toEqual({
|
||||||
|
openUrl: null,
|
||||||
|
labelUrl: "http://feature.web.localhost:6767",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { parseHostPort } from "@server/shared/daemon-endpoints";
|
||||||
|
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||||
|
import type { ActiveConnection } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
|
export type ResolvedWorkspaceScriptLink = {
|
||||||
|
openUrl: string | null;
|
||||||
|
labelUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isLoopbackHost(host: string): boolean {
|
||||||
|
const normalizedHost = host.trim().toLowerCase();
|
||||||
|
return normalizedHost === "localhost" || normalizedHost === "127.0.0.1" || normalizedHost === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
|
||||||
|
try {
|
||||||
|
const { host, isIpv6 } = parseHostPort(endpoint);
|
||||||
|
const base = isIpv6 ? `[${host}]` : host;
|
||||||
|
return `http://${base}:${port}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceScriptLink(input: {
|
||||||
|
script: WorkspaceScriptPayload;
|
||||||
|
activeConnection: ActiveConnection | null;
|
||||||
|
}): ResolvedWorkspaceScriptLink {
|
||||||
|
const { script, activeConnection } = input;
|
||||||
|
if (script.type !== "service" || script.lifecycle !== "running") {
|
||||||
|
return { openUrl: null, labelUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeConnection) {
|
||||||
|
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection.type === "relay") {
|
||||||
|
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||||
|
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { host } = parseHostPort(activeConnection.endpoint);
|
||||||
|
if (isLoopbackHost(host)) {
|
||||||
|
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (script.port === null) {
|
||||||
|
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
|
||||||
|
return {
|
||||||
|
openUrl: directUrl,
|
||||||
|
labelUrl: directUrl ?? script.proxyUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -708,6 +708,7 @@ export async function createPaseoDaemon(
|
|||||||
scriptRuntimeStore,
|
scriptRuntimeStore,
|
||||||
handleBranchChange,
|
handleBranchChange,
|
||||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||||
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "script",
|
type: "script",
|
||||||
hostname: "typecheck",
|
hostname: "typecheck",
|
||||||
port: null,
|
port: null,
|
||||||
url: null,
|
proxyUrl: null,
|
||||||
lifecycle: "stopped",
|
lifecycle: "stopped",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -92,7 +92,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "web.localhost",
|
hostname: "web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://web.localhost:6767",
|
proxyUrl: "http://web.localhost:6767",
|
||||||
lifecycle: "stopped",
|
lifecycle: "stopped",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -144,7 +144,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "feature-card.web.localhost",
|
hostname: "feature-card.web.localhost",
|
||||||
port: 4321,
|
port: 4321,
|
||||||
url: "http://feature-card.web.localhost:6767",
|
proxyUrl: "http://feature-card.web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -195,7 +195,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "web.localhost",
|
hostname: "web.localhost",
|
||||||
port: 4321,
|
port: 4321,
|
||||||
url: "http://web.localhost:6767",
|
proxyUrl: "http://web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -239,7 +239,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "docs.localhost",
|
hostname: "docs.localhost",
|
||||||
port: 3002,
|
port: 3002,
|
||||||
url: "http://docs.localhost:6767",
|
proxyUrl: "http://docs.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -277,7 +277,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "script",
|
type: "script",
|
||||||
hostname: "typecheck",
|
hostname: "typecheck",
|
||||||
port: null,
|
port: null,
|
||||||
url: null,
|
proxyUrl: null,
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -342,7 +342,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "api.localhost",
|
hostname: "api.localhost",
|
||||||
port: 3001,
|
port: 3001,
|
||||||
url: "http://api.localhost:6767",
|
proxyUrl: "http://api.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -352,7 +352,7 @@ describe("script-status-projection", () => {
|
|||||||
type: "script",
|
type: "script",
|
||||||
hostname: "typecheck",
|
hostname: "typecheck",
|
||||||
port: null,
|
port: null,
|
||||||
url: null,
|
proxyUrl: null,
|
||||||
lifecycle: "stopped",
|
lifecycle: "stopped",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
|
|||||||
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
|
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
|
function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null {
|
||||||
if (daemonPort === null) {
|
if (daemonPort === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ function createConfiguredPayload(input: {
|
|||||||
type: input.type,
|
type: input.type,
|
||||||
hostname,
|
hostname,
|
||||||
port: input.type === "service" ? input.configuredPort : null,
|
port: input.type === "service" ? input.configuredPort : null,
|
||||||
url: input.type === "service" ? toServiceUrl(hostname, input.daemonPort) : null,
|
proxyUrl: input.type === "service" ? toServiceProxyUrl(hostname, input.daemonPort) : null,
|
||||||
lifecycle: "stopped",
|
lifecycle: "stopped",
|
||||||
health: null,
|
health: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -117,9 +117,9 @@ export function buildWorkspaceScriptPayloads(
|
|||||||
hostname:
|
hostname:
|
||||||
type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName,
|
type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName,
|
||||||
port: type === "service" ? (routeEntry?.port ?? payload.port) : null,
|
port: type === "service" ? (routeEntry?.port ?? payload.port) : null,
|
||||||
url:
|
proxyUrl:
|
||||||
type === "service"
|
type === "service"
|
||||||
? toServiceUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort)
|
? toServiceProxyUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort)
|
||||||
: null,
|
: null,
|
||||||
lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle,
|
lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle,
|
||||||
health:
|
health:
|
||||||
@@ -146,7 +146,7 @@ export function buildWorkspaceScriptPayloads(
|
|||||||
type,
|
type,
|
||||||
hostname,
|
hostname,
|
||||||
port: type === "service" ? (routeEntry?.port ?? null) : null,
|
port: type === "service" ? (routeEntry?.port ?? null) : null,
|
||||||
url: type === "service" ? toServiceUrl(hostname, options.daemonPort) : null,
|
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, options.daemonPort) : null,
|
||||||
lifecycle: runtimeEntry.lifecycle,
|
lifecycle: runtimeEntry.lifecycle,
|
||||||
health:
|
health:
|
||||||
type === "service" && routeEntry
|
type === "service" && routeEntry
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ export type SessionOptions = {
|
|||||||
newBranch: string | null,
|
newBranch: string | null,
|
||||||
) => void;
|
) => void;
|
||||||
getDaemonTcpPort?: () => number | null;
|
getDaemonTcpPort?: () => number | null;
|
||||||
|
getDaemonTcpHost?: () => string | null;
|
||||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
||||||
voice?: {
|
voice?: {
|
||||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
@@ -621,6 +622,7 @@ export class Session {
|
|||||||
newBranch: string | null,
|
newBranch: string | null,
|
||||||
) => void;
|
) => void;
|
||||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||||
|
private readonly getDaemonTcpHost: (() => string | null) | null;
|
||||||
private readonly resolveScriptHealth:
|
private readonly resolveScriptHealth:
|
||||||
| ((hostname: string) => ScriptHealthState | null)
|
| ((hostname: string) => ScriptHealthState | null)
|
||||||
| null;
|
| null;
|
||||||
@@ -684,6 +686,7 @@ export class Session {
|
|||||||
scriptRuntimeStore,
|
scriptRuntimeStore,
|
||||||
onBranchChanged,
|
onBranchChanged,
|
||||||
getDaemonTcpPort,
|
getDaemonTcpPort,
|
||||||
|
getDaemonTcpHost,
|
||||||
resolveScriptHealth,
|
resolveScriptHealth,
|
||||||
voice,
|
voice,
|
||||||
voiceBridge,
|
voiceBridge,
|
||||||
@@ -728,6 +731,7 @@ export class Session {
|
|||||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||||
this.onBranchChanged = onBranchChanged;
|
this.onBranchChanged = onBranchChanged;
|
||||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||||
|
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||||
if (this.terminalManager) {
|
if (this.terminalManager) {
|
||||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||||||
@@ -6429,6 +6433,7 @@ export class Session {
|
|||||||
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
|
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
|
||||||
scriptName: request.scriptName,
|
scriptName: request.scriptName,
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
|
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||||
routeStore: this.scriptRouteStore,
|
routeStore: this.scriptRouteStore,
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
runtimeStore: this.scriptRuntimeStore,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly scriptRouteStore: ScriptRouteStore | null;
|
private readonly scriptRouteStore: ScriptRouteStore | null;
|
||||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||||
|
private readonly getDaemonTcpHost: (() => string | null) | null;
|
||||||
private readonly resolveScriptHealth:
|
private readonly resolveScriptHealth:
|
||||||
| ((hostname: string) => ScriptHealthState | null)
|
| ((hostname: string) => ScriptHealthState | null)
|
||||||
| null;
|
| null;
|
||||||
@@ -334,6 +335,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
newBranch: string | null,
|
newBranch: string | null,
|
||||||
) => void,
|
) => void,
|
||||||
getDaemonTcpPort?: () => number | null,
|
getDaemonTcpPort?: () => number | null,
|
||||||
|
getDaemonTcpHost?: () => string | null,
|
||||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
@@ -385,6 +387,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||||
this.onBranchChanged = onBranchChanged ?? null;
|
this.onBranchChanged = onBranchChanged ?? null;
|
||||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||||
|
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||||
this.serverCapabilities = buildServerCapabilities({
|
this.serverCapabilities = buildServerCapabilities({
|
||||||
readiness: this.speech?.getReadiness() ?? null,
|
readiness: this.speech?.getReadiness() ?? null,
|
||||||
@@ -698,6 +701,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||||
|
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
||||||
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
||||||
voice: {
|
voice: {
|
||||||
...(this.voice ?? {}),
|
...(this.voice ?? {}),
|
||||||
|
|||||||
@@ -657,4 +657,45 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
exitCode: null,
|
exitCode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("binds services to the network when the daemon listens on a non-loopback host", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(repoDir, "paseo.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
scripts: {
|
||||||
|
web: {
|
||||||
|
type: "service",
|
||||||
|
command: "npm run dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'add remote service script config'", {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeStore = new ScriptRouteStore();
|
||||||
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
|
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
await spawnWorktreeScripts({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
workspaceId: repoDir,
|
||||||
|
branchName: "feature-remote-service",
|
||||||
|
daemonPort: 6767,
|
||||||
|
daemonListenHost: "100.64.0.20",
|
||||||
|
routeStore,
|
||||||
|
runtimeStore,
|
||||||
|
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createTerminalCalls).toHaveLength(1);
|
||||||
|
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
|
||||||
|
expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe(
|
||||||
|
"http://feature-remote-service.web.localhost:6767",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -753,6 +753,7 @@ type SpawnWorkspaceScriptOptions = {
|
|||||||
branchName: string | null;
|
branchName: string | null;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
daemonPort?: number | null;
|
daemonPort?: number | null;
|
||||||
|
daemonListenHost?: string | null;
|
||||||
routeStore: ScriptRouteStore;
|
routeStore: ScriptRouteStore;
|
||||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||||
terminalManager: TerminalManager;
|
terminalManager: TerminalManager;
|
||||||
@@ -760,6 +761,24 @@ type SpawnWorkspaceScriptOptions = {
|
|||||||
onLifecycleChanged?: () => void;
|
onLifecycleChanged?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isLoopbackListenHost(host: string | null | undefined): boolean {
|
||||||
|
if (!host) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHost = host.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalizedHost === "localhost" ||
|
||||||
|
normalizedHost === "127.0.0.1" ||
|
||||||
|
normalizedHost === "::1" ||
|
||||||
|
normalizedHost === "[::1]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveServiceBindHost(daemonListenHost: string | null | undefined): string {
|
||||||
|
return isLoopbackListenHost(daemonListenHost) ? "127.0.0.1" : "0.0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
export async function spawnWorkspaceScript(
|
export async function spawnWorkspaceScript(
|
||||||
options: SpawnWorkspaceScriptOptions,
|
options: SpawnWorkspaceScriptOptions,
|
||||||
): Promise<WorktreeScriptResult> {
|
): Promise<WorktreeScriptResult> {
|
||||||
@@ -769,6 +788,7 @@ export async function spawnWorkspaceScript(
|
|||||||
branchName,
|
branchName,
|
||||||
scriptName,
|
scriptName,
|
||||||
daemonPort,
|
daemonPort,
|
||||||
|
daemonListenHost,
|
||||||
routeStore,
|
routeStore,
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
@@ -805,7 +825,7 @@ export async function spawnWorkspaceScript(
|
|||||||
const env = serviceScript
|
const env = serviceScript
|
||||||
? {
|
? {
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOST: "127.0.0.1",
|
HOST: resolveServiceBindHost(daemonListenHost),
|
||||||
...(daemonPort !== null && daemonPort !== undefined
|
...(daemonPort !== null && daemonPort !== undefined
|
||||||
? {
|
? {
|
||||||
PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`,
|
PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`,
|
||||||
@@ -898,6 +918,7 @@ export async function spawnWorktreeScripts(options: {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
branchName: string | null;
|
branchName: string | null;
|
||||||
daemonPort?: number | null;
|
daemonPort?: number | null;
|
||||||
|
daemonListenHost?: string | null;
|
||||||
routeStore: ScriptRouteStore;
|
routeStore: ScriptRouteStore;
|
||||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||||
terminalManager: TerminalManager;
|
terminalManager: TerminalManager;
|
||||||
|
|||||||
@@ -1866,7 +1866,7 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
|||||||
type: z.enum(["script", "service"]).optional().default("service"),
|
type: z.enum(["script", "service"]).optional().default("service"),
|
||||||
hostname: z.string(),
|
hostname: z.string(),
|
||||||
port: z.number().int().positive().nullable(),
|
port: z.number().int().positive().nullable(),
|
||||||
url: z.string().nullable(),
|
proxyUrl: z.string().nullable().optional().default(null),
|
||||||
lifecycle: WorkspaceScriptLifecycleSchema,
|
lifecycle: WorkspaceScriptLifecycleSchema,
|
||||||
health: WorkspaceScriptHealthSchema.nullable(),
|
health: WorkspaceScriptHealthSchema.nullable(),
|
||||||
exitCode: z.number().nullable().optional().default(null),
|
exitCode: z.number().nullable().optional().default(null),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("workspace message schemas", () => {
|
|||||||
scriptName: "web",
|
scriptName: "web",
|
||||||
hostname: "web.localhost",
|
hostname: "web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://web.localhost:6767",
|
proxyUrl: "http://web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
},
|
},
|
||||||
@@ -158,7 +158,7 @@ describe("workspace message schemas", () => {
|
|||||||
type: "service",
|
type: "service",
|
||||||
hostname: "web.localhost",
|
hostname: "web.localhost",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
url: "http://web.localhost:6767",
|
proxyUrl: "http://web.localhost:6767",
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
@@ -205,7 +205,7 @@ describe("workspace message schemas", () => {
|
|||||||
scriptName: "web",
|
scriptName: "web",
|
||||||
hostname: "web.localhost",
|
hostname: "web.localhost",
|
||||||
port: null,
|
port: null,
|
||||||
url: null,
|
proxyUrl: null,
|
||||||
lifecycle: "stopped",
|
lifecycle: "stopped",
|
||||||
health: null,
|
health: null,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user