diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index 747934b66..6f77b1e21 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -28,7 +28,7 @@ const runningScript: WorkspaceScriptPayload = { type: "service", hostname: "main.web.localhost", port: 3000, - url: "http://main.web.localhost:6767", + proxyUrl: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index a00e24bf9..34d3cfb03 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -50,7 +50,7 @@ const runningScript: WorkspaceScriptPayload = { type: "service", hostname: "main.web.localhost", port: 3000, - url: "http://main.web.localhost:6767", + proxyUrl: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -61,7 +61,7 @@ const stoppedScript: WorkspaceScriptPayload = { type: "service", hostname: "main.api.localhost", port: 3001, - url: "http://main.api.localhost:6767", + proxyUrl: "http://main.api.localhost:6767", lifecycle: "stopped", health: null, exitCode: null, diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 1e3ca7943..852378495 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -5,6 +5,7 @@ import { ChevronDown, ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, @@ -12,6 +13,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; import { openExternalUrl } from "@/utils/open-external-url"; +import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; type Script = WorkspaceDescriptor["scripts"][number]; @@ -42,6 +44,7 @@ export function WorkspaceScriptsButton({ const { theme } = useUnistyles(); const toast = useToast(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const startScriptMutation = useMutation({ mutationFn: async (scriptName: string) => { @@ -105,7 +108,8 @@ export function WorkspaceScriptsButton({ {scripts.map((script) => { const isRunning = script.lifecycle === "running"; 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; let dotColor: string; @@ -133,7 +137,9 @@ export function WorkspaceScriptsButton({ styles.scriptRow, hovered && isLinkable && styles.scriptRowHovered, ]} - onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined} + onPress={ + isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined + } disabled={!isLinkable} > {({ hovered }) => ( @@ -156,9 +162,9 @@ export function WorkspaceScriptsButton({ > {script.scriptName} - {isService && isRunning && script.url ? ( + {isService && isRunning && serviceLink.labelUrl ? ( - {script.url.replace(/^https?:\/\//, "")} + {serviceLink.labelUrl.replace(/^https?:\/\//, "")} ) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 58f2f8341..d78557644 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -41,7 +41,7 @@ describe("normalizeWorkspaceDescriptor", () => { type: "service" as const, hostname: "main.web.localhost", port: 3000, - url: "http://main.web.localhost:6767", + proxyUrl: "http://main.web.localhost:6767", lifecycle: "running" as const, health: "healthy" as const, exitCode: null, @@ -69,7 +69,7 @@ describe("normalizeWorkspaceDescriptor", () => { type: "service", hostname: "main.web.localhost", port: 3000, - url: "http://main.web.localhost:6767", + proxyUrl: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -118,7 +118,7 @@ describe("mergeWorkspaces", () => { type: "service", hostname: "main.web.localhost", port: 3000, - url: "http://main.web.localhost:6767", + proxyUrl: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -133,7 +133,7 @@ describe("mergeWorkspaces", () => { type: "service", hostname: "main.web.localhost", port: 3000, - url: "http://main.web.localhost:6767", + proxyUrl: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, diff --git a/packages/app/src/utils/workspace-script-links.test.ts b/packages/app/src/utils/workspace-script-links.test.ts new file mode 100644 index 000000000..6a235203e --- /dev/null +++ b/packages/app/src/utils/workspace-script-links.test.ts @@ -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", + }); + }); +}); diff --git a/packages/app/src/utils/workspace-script-links.ts b/packages/app/src/utils/workspace-script-links.ts new file mode 100644 index 000000000..a903df173 --- /dev/null +++ b/packages/app/src/utils/workspace-script-links.ts @@ -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, + }; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f3f594860..06770fb68 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -708,6 +708,7 @@ export async function createPaseoDaemon( scriptRuntimeStore, handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), ); diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index f11eb3190..ad13a369c 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -82,7 +82,7 @@ describe("script-status-projection", () => { type: "script", hostname: "typecheck", port: null, - url: null, + proxyUrl: null, lifecycle: "stopped", health: null, exitCode: 0, @@ -92,7 +92,7 @@ describe("script-status-projection", () => { type: "service", hostname: "web.localhost", port: 3000, - url: "http://web.localhost:6767", + proxyUrl: "http://web.localhost:6767", lifecycle: "stopped", health: null, exitCode: null, @@ -144,7 +144,7 @@ describe("script-status-projection", () => { type: "service", hostname: "feature-card.web.localhost", port: 4321, - url: "http://feature-card.web.localhost:6767", + proxyUrl: "http://feature-card.web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -195,7 +195,7 @@ describe("script-status-projection", () => { type: "service", hostname: "web.localhost", port: 4321, - url: "http://web.localhost:6767", + proxyUrl: "http://web.localhost:6767", lifecycle: "running", health: null, exitCode: null, @@ -239,7 +239,7 @@ describe("script-status-projection", () => { type: "service", hostname: "docs.localhost", port: 3002, - url: "http://docs.localhost:6767", + proxyUrl: "http://docs.localhost:6767", lifecycle: "running", health: null, exitCode: null, @@ -277,7 +277,7 @@ describe("script-status-projection", () => { type: "script", hostname: "typecheck", port: null, - url: null, + proxyUrl: null, lifecycle: "running", health: null, exitCode: null, @@ -342,7 +342,7 @@ describe("script-status-projection", () => { type: "service", hostname: "api.localhost", port: 3001, - url: "http://api.localhost:6767", + proxyUrl: "http://api.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -352,7 +352,7 @@ describe("script-status-projection", () => { type: "script", hostname: "typecheck", port: null, - url: null, + proxyUrl: null, lifecycle: "stopped", health: null, exitCode: null, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index ea9154816..60ce72a09 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -33,7 +33,7 @@ function resolveWorkspaceBranchName(workspaceDirectory: string): string | null { 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) { return null; } @@ -64,7 +64,7 @@ function createConfiguredPayload(input: { type: input.type, hostname, 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", health: null, exitCode: null, @@ -117,9 +117,9 @@ export function buildWorkspaceScriptPayloads( hostname: type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName, port: type === "service" ? (routeEntry?.port ?? payload.port) : null, - url: + proxyUrl: type === "service" - ? toServiceUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort) + ? toServiceProxyUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort) : null, lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle, health: @@ -146,7 +146,7 @@ export function buildWorkspaceScriptPayloads( type, hostname, 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, health: type === "service" && routeEntry diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bec490c74..73cf0d0d8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -437,6 +437,7 @@ export type SessionOptions = { newBranch: string | null, ) => void; getDaemonTcpPort?: () => number | null; + getDaemonTcpHost?: () => string | null; resolveScriptHealth?: (hostname: string) => ScriptHealthState | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; @@ -621,6 +622,7 @@ export class Session { newBranch: string | null, ) => void; private readonly getDaemonTcpPort: (() => number | null) | null; + private readonly getDaemonTcpHost: (() => string | null) | null; private readonly resolveScriptHealth: | ((hostname: string) => ScriptHealthState | null) | null; @@ -684,6 +686,7 @@ export class Session { scriptRuntimeStore, onBranchChanged, getDaemonTcpPort, + getDaemonTcpHost, resolveScriptHealth, voice, voiceBridge, @@ -728,6 +731,7 @@ export class Session { this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.onBranchChanged = onBranchChanged; this.getDaemonTcpPort = getDaemonTcpPort ?? null; + this.getDaemonTcpHost = getDaemonTcpHost ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => @@ -6429,6 +6433,7 @@ export class Session { branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"), scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, + daemonListenHost: this.getDaemonTcpHost?.() ?? null, routeStore: this.scriptRouteStore, runtimeStore: this.scriptRuntimeStore, terminalManager: this.terminalManager, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 17c732ad4..73d06b9df 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -253,6 +253,7 @@ export class VoiceAssistantWebSocketServer { private readonly scriptRouteStore: ScriptRouteStore | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly getDaemonTcpPort: (() => number | null) | null; + private readonly getDaemonTcpHost: (() => string | null) | null; private readonly resolveScriptHealth: | ((hostname: string) => ScriptHealthState | null) | null; @@ -334,6 +335,7 @@ export class VoiceAssistantWebSocketServer { newBranch: string | null, ) => void, getDaemonTcpPort?: () => number | null, + getDaemonTcpHost?: () => string | null, resolveScriptHealth?: (hostname: string) => ScriptHealthState | null, ) { this.logger = logger.child({ module: "websocket-server" }); @@ -385,6 +387,7 @@ export class VoiceAssistantWebSocketServer { this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.onBranchChanged = onBranchChanged ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; + this.getDaemonTcpHost = getDaemonTcpHost ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, @@ -698,6 +701,7 @@ export class VoiceAssistantWebSocketServer { scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, + getDaemonTcpHost: this.getDaemonTcpHost ?? undefined, resolveScriptHealth: this.resolveScriptHealth ?? undefined, voice: { ...(this.voice ?? {}), diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 5f5592ae2..aa26442cf 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -657,4 +657,45 @@ describe("runAsyncWorktreeBootstrap", () => { 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 }> = + []; + + 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", + ); + }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 7a517d9db..a553717f1 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -753,6 +753,7 @@ type SpawnWorkspaceScriptOptions = { branchName: string | null; scriptName: string; daemonPort?: number | null; + daemonListenHost?: string | null; routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; terminalManager: TerminalManager; @@ -760,6 +761,24 @@ type SpawnWorkspaceScriptOptions = { 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( options: SpawnWorkspaceScriptOptions, ): Promise { @@ -769,6 +788,7 @@ export async function spawnWorkspaceScript( branchName, scriptName, daemonPort, + daemonListenHost, routeStore, runtimeStore, terminalManager, @@ -805,7 +825,7 @@ export async function spawnWorkspaceScript( const env = serviceScript ? { PORT: String(port), - HOST: "127.0.0.1", + HOST: resolveServiceBindHost(daemonListenHost), ...(daemonPort !== null && daemonPort !== undefined ? { PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`, @@ -898,6 +918,7 @@ export async function spawnWorktreeScripts(options: { workspaceId: string; branchName: string | null; daemonPort?: number | null; + daemonListenHost?: string | null; routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; terminalManager: TerminalManager; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 72234419d..4c728d669 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1866,7 +1866,7 @@ export const WorkspaceScriptPayloadSchema = z.object({ type: z.enum(["script", "service"]).optional().default("service"), hostname: z.string(), port: z.number().int().positive().nullable(), - url: z.string().nullable(), + proxyUrl: z.string().nullable().optional().default(null), lifecycle: WorkspaceScriptLifecycleSchema, health: WorkspaceScriptHealthSchema.nullable(), exitCode: z.number().nullable().optional().default(null), diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index e84f78c65..6cedf4659 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -139,7 +139,7 @@ describe("workspace message schemas", () => { scriptName: "web", hostname: "web.localhost", port: 3000, - url: "http://web.localhost:6767", + proxyUrl: "http://web.localhost:6767", lifecycle: "running", health: "healthy", }, @@ -158,7 +158,7 @@ describe("workspace message schemas", () => { type: "service", hostname: "web.localhost", port: 3000, - url: "http://web.localhost:6767", + proxyUrl: "http://web.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -205,7 +205,7 @@ describe("workspace message schemas", () => { scriptName: "web", hostname: "web.localhost", port: null, - url: null, + proxyUrl: null, lifecycle: "stopped", health: null, },