1414 */
1515
1616import { spawnSync } from "node:child_process" ;
17+ import { parseOpenShellSandboxId } from "../adapters/openshell/sandbox-identity" ;
1718import { openshellSandboxSshHost } from "../adapters/openshell/sandbox-ssh-host" ;
1819
1920// ---------------------------------------------------------------------------
@@ -95,21 +96,39 @@ export function parseForwardList(output: string | null | undefined): ForwardEntr
9596 return entries ;
9697}
9798
99+ /**
100+ * Does this command line belong to an interactive shell rather than a forward?
101+ *
102+ * OpenShell starts the dashboard port-forward through the same proxy and the
103+ * same `sandbox` host alias as `connect`, so the sandbox reference alone cannot
104+ * tell them apart. The interactive session is the one that asks for a TTY; the
105+ * forward runs `-N` with no remote command. Counting the forward would report a
106+ * session on every Ready sandbox.
107+ */
108+ function isInteractiveSshCommand ( command : string ) : boolean {
109+ return / (?: ^ | \s ) - t t (?: \s | $ ) / . test ( command ) || / R e q u e s t T T Y = f o r c e / . test ( command ) ;
110+ }
111+
98112/**
99113 * Parse process list output to find SSH processes targeting a specific sandbox.
100114 *
101- * Current SSH connections use `openshell-<sandboxName>.default`. During the
102- * supported v0.0.85 to v0.0.99 upgrade window, an already-running connection
103- * may still target the legacy `openshell-<sandboxName>` alias. We recognize
104- * both as complete tokens to avoid false positives when one sandbox name is a
105- * prefix of another (e.g., `dev` vs `dev-staging`).
115+ * Two shapes are recognized. OpenShell used to place the sandbox in the SSH
116+ * host itself (`openshell-<sandboxName>.default`, and the legacy
117+ * `openshell-<sandboxName>` from the v0.0.85 to v0.0.99 upgrade window); those
118+ * are matched as complete tokens so one sandbox name cannot match another it is
119+ * a prefix of (`dev` vs `dev-staging`). Newer OpenShell connects every sandbox
120+ * through the fixed `sandbox` alias and identifies the target with
121+ * `--sandbox-id <id>` on its proxy command instead, which left interactive
122+ * sessions invisible to every session-reporting surface (#9316). When the
123+ * caller knows the durable sandbox ID, that form is matched too.
106124 *
107125 * Input format: one line per process — `<PID> <full command line>`
108126 * (compatible with both `pgrep -a` on Linux and `ps -axo pid,command`)
109127 */
110128export function parseSshProcesses (
111129 pgrepOutput : string | null | undefined ,
112130 sandboxName : string ,
131+ sandboxId ?: string | null ,
113132) : SandboxSession [ ] {
114133 if ( ! pgrepOutput || typeof pgrepOutput !== "string" ) return [ ] ;
115134 if ( ! sandboxName ) return [ ] ;
@@ -118,6 +137,10 @@ export function parseSshProcesses(
118137 const hostPatterns = sshHosts . map (
119138 ( sshHost ) => [ sshHost , new RegExp ( `(?:^|\\s)${ escapeRegExp ( sshHost ) } (?:\\s|$)` ) ] as const ,
120139 ) ;
140+ const idPattern =
141+ sandboxId && sandboxId . trim ( )
142+ ? new RegExp ( `--sandbox-id[=\\s]+${ escapeRegExp ( sandboxId . trim ( ) ) } (?:\\s|$)` )
143+ : null ;
121144 const sessions : SandboxSession [ ] = [ ] ;
122145 const lines = pgrepOutput . split ( "\n" ) . filter ( Boolean ) ;
123146
@@ -126,10 +149,17 @@ export function parseSshProcesses(
126149 if ( ! pidMatch ) continue ;
127150
128151 const pid = Number . parseInt ( pidMatch [ 1 ] , 10 ) ;
152+ const command = pidMatch [ 2 ] ;
129153
130- const sshHost = hostPatterns . find ( ( [ , pattern ] ) => pattern . test ( pidMatch [ 2 ] ) ) ?. [ 0 ] ;
154+ const sshHost = hostPatterns . find ( ( [ , pattern ] ) => pattern . test ( command ) ) ?. [ 0 ] ;
131155 if ( sshHost ) {
132156 sessions . push ( { sandboxName, pid, sshHost } ) ;
157+ continue ;
158+ }
159+ // The proxied form carries no sandbox name, so it is only attributable
160+ // when the caller resolved the sandbox's durable ID.
161+ if ( idPattern ?. test ( command ) && isInteractiveSshCommand ( command ) ) {
162+ sessions . push ( { sandboxName, pid, sshHost : openshellSandboxSshHost ( sandboxName ) } ) ;
133163 }
134164 }
135165
@@ -216,6 +246,12 @@ export interface SessionDetectionDeps {
216246 getForwardList : ( ) => string | null ;
217247 /** Run `pgrep -a ssh` and return stdout. Null if unavailable. */
218248 getSshProcesses : ( ) => string | null ;
249+ /**
250+ * Resolve the sandbox's durable OpenShell ID, or null when it cannot be
251+ * determined. Only consulted when the process list contains a proxied
252+ * connection, which is the only shape that needs it (#9316).
253+ */
254+ resolveSandboxId ?: ( sandboxName : string ) => string | null ;
219255}
220256
221257/**
@@ -244,7 +280,12 @@ export function getActiveSandboxSessions(
244280 return { detected : false , sessions : [ ] } ;
245281 }
246282
247- const sshSessions = parseSshProcesses ( pgrepOutput , sandboxName ) ;
283+ // Resolving the ID costs an OpenShell call, so only pay it for the proxied
284+ // shape that cannot be attributed from the SSH host alone (#9316).
285+ const sandboxId = pgrepOutput . includes ( "--sandbox-id" )
286+ ? ( deps . resolveSandboxId ?.( sandboxName ) ?? null )
287+ : null ;
288+ const sshSessions = parseSshProcesses ( pgrepOutput , sandboxName , sandboxId ) ;
248289
249290 return {
250291 detected : true ,
@@ -298,5 +339,34 @@ export function createSystemDeps(openshellBinary: string): SessionDetectionDeps
298339 }
299340 } ,
300341 getSshProcesses : querySshProcesses ,
342+ resolveSandboxId : createOpenshellSandboxIdResolver ( openshellBinary ) ,
343+ } ;
344+ }
345+
346+ /**
347+ * Read a sandbox's durable ID via `openshell sandbox get`, memoized per process
348+ * and failing soft. Detection stays on SSH-host matching when the lookup fails,
349+ * so an unavailable OpenShell client never breaks the surrounding command.
350+ */
351+ function createOpenshellSandboxIdResolver (
352+ openshellBinary : string ,
353+ ) : ( sandboxName : string ) => string | null {
354+ const cache = new Map < string , string | null > ( ) ;
355+ return ( sandboxName : string ) : string | null => {
356+ const cached = cache . get ( sandboxName ) ;
357+ if ( cached !== undefined ) return cached ;
358+ let resolved : string | null = null ;
359+ try {
360+ const result = spawnSync ( openshellBinary , [ "sandbox" , "get" , sandboxName ] , {
361+ encoding : "utf-8" ,
362+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
363+ timeout : 5000 ,
364+ } ) ;
365+ resolved = result . status === 0 ? parseOpenShellSandboxId ( result . stdout || "" ) : null ;
366+ } catch {
367+ resolved = null ;
368+ }
369+ cache . set ( sandboxName , resolved ) ;
370+ return resolved ;
301371 } ;
302372}
0 commit comments