From 564a8c213b706248fa58d7f92767d17075e14fe1 Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Sat, 22 Aug 2026 00:54:09 -0700 Subject: [PATCH 1/9] intial exec-server base - add localCtx and remoteCtx interfaces for local/remote spawn support - misc code cleanup for spawn and run opts --- src/common/cmd.ts | 7 +--- src/common/ctx/ctx.ts | 15 +++++++ src/common/ctx/localCtx.ts | 28 +++++++++++++ src/common/ctx/remoteCtx.ts | 83 +++++++++++++++++++++++++++++++++++++ src/common/ctx/runctx.ts | 7 ++++ src/common/opts.ts | 22 ++++++++++ src/common/spawn.ts | 17 +------- 7 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 src/common/ctx/ctx.ts create mode 100644 src/common/ctx/localCtx.ts create mode 100644 src/common/ctx/remoteCtx.ts create mode 100644 src/common/ctx/runctx.ts create mode 100644 src/common/opts.ts diff --git a/src/common/cmd.ts b/src/common/cmd.ts index 4e4be8e..25a115e 100644 --- a/src/common/cmd.ts +++ b/src/common/cmd.ts @@ -1,12 +1,7 @@ import * as common from "./spawn"; +import { RunOpts } from "./opts"; import { getLogSink } from "../extension/log"; -export interface RunOpts { - cwd?: string, - env?: common.Envs, - stdin?: string | undefined, -} - export async function run(cmdArgs: string[], opts: RunOpts) { getLogSink().trace(`cmd.run: [${cmdArgs.join(", ")}]: opts: ${JSON.stringify(opts)}`); return await common.spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() }); diff --git a/src/common/ctx/ctx.ts b/src/common/ctx/ctx.ts new file mode 100644 index 0000000..c18b048 --- /dev/null +++ b/src/common/ctx/ctx.ts @@ -0,0 +1,15 @@ +import * as localCtx from "./localCtx"; +import { RunCtx } from "./runctx"; + +let RUNCTX: RunCtx | undefined; + +export function setRunCtx(ctx: RunCtx) { + RUNCTX = ctx; +} + +export function getRunCtx(): RunCtx { + if (!RUNCTX) { + RUNCTX = new localCtx.LocalRunCtx(); + } + return RUNCTX; +} diff --git a/src/common/ctx/localCtx.ts b/src/common/ctx/localCtx.ts new file mode 100644 index 0000000..bc2c38d --- /dev/null +++ b/src/common/ctx/localCtx.ts @@ -0,0 +1,28 @@ +import { RunCtx } from "./runctx"; +import { HostUserInfo } from "../utils"; +import { formatCmdErr, spawn } from "../spawn"; +import { getLogSink } from "../../extension/log"; +import { SpawnError } from "../../extension/error"; +import { CmdResult, RunOpts } from "../opts"; + +export class LocalRunCtx implements RunCtx { + async run(cmdArgs: string[], opts: RunOpts): Promise { + return await spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() }); + } + + async getHostUserInfo(): Promise { + const userName = await spawn("id", ["-nu"], { log: getLogSink() }); + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + if (userName.exit === 0) { + return { + uid: process.getuid!(), + gid: process.getgid!(), + name: userName.stdout.trim(), + }; + } + else { + throw new SpawnError(`Could not query host user info (uid, gid, name): ${formatCmdErr(userName)}`); + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + } +} diff --git a/src/common/ctx/remoteCtx.ts b/src/common/ctx/remoteCtx.ts new file mode 100644 index 0000000..557bc4d --- /dev/null +++ b/src/common/ctx/remoteCtx.ts @@ -0,0 +1,83 @@ +import * as vscode from "vscode"; + +import { RunCtx } from "./runctx"; +import { HostUserInfo } from "../utils"; +import { formatCmdErr } from "../spawn"; +import { getLogSink } from "../../extension/log"; +import { SpawnError } from "../../extension/error"; +import { CmdResult, RunOpts } from "../opts"; + +let cmdCount: number = 0; + +export class ExecRemoteRunCtx implements RunCtx { + constructor( + private readonly execServer: vscode.ExecServer, + ) { } + + async run(cmdArgs: string[], opts: RunOpts): Promise { + cmdCount += 1; + const cmdId = cmdCount; + const cmdStr = () => `[CMD${String(cmdId).padStart(4, "0")}]:`; + + const [cmd, ...args] = cmdArgs; + const log = getLogSink(); + log.info(`RemoteRunCtx.spawn[${cmdStr()}]: [${cmdArgs.join(", ")}]`); + + const env: Record = opts.env + ? Object.fromEntries( + Object.entries(opts.env) + .filter((e): e is [string, string] => e[1] !== undefined), + ) + : {}; + + const spawned = await this.execServer.spawn(cmd, args, { + cwd: opts.cwd, + env: env, + }); + + if (opts.stdin !== undefined) { + spawned.stdin.write(new TextEncoder().encode(opts.stdin)); + spawned.stdin.end(); + } + + let stdout = ""; + let stderr = ""; + const decoder = new TextDecoder("utf-8"); + + spawned.stdout.onDidReceiveMessage((data: Uint8Array) => { + const chunk = decoder.decode(data, { stream: true }); + stdout += chunk; + log.error(`RemoteRunCtx.stdout.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}`, chunk); + }); + + spawned.stderr.onDidReceiveMessage((data: Uint8Array) => { + const chunk = decoder.decode(data, { stream: true }); + stderr += chunk; + log.trace(`RemoteRunCtx.stderr.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}`, chunk); + }); + + const exit = await spawned.onExit; + + return { exit: exit.status, stdout, stderr }; + } + + async getHostUserInfo(): Promise { + const uid = await this.run(["id", "-u"], {}); + const gid = await this.run(["id", "-g"], {}); + const username = await this.run(["id", "-un"], {}); + + if (uid.exit !== 0 || gid.exit !== 0) { + throw new SpawnError(`RemoteRunCtx.getHostUserInfo: Could not query remote host user info: uid: ${formatCmdErr(uid)} gid: ${formatCmdErr(gid)}`); + } + + if (username.exit !== 0) { + getLogSink().warn(`RemoteRunCtx.getHostUserInfo: Could not query remote host user info: ${formatCmdErr(username)}`); + } + + return { + uid: parseInt(uid.stdout.trim(), 10), + gid: parseInt(gid.stdout.trim(), 10), + name: username.stdout.trim(), + }; + } +} diff --git a/src/common/ctx/runctx.ts b/src/common/ctx/runctx.ts new file mode 100644 index 0000000..00be58e --- /dev/null +++ b/src/common/ctx/runctx.ts @@ -0,0 +1,7 @@ +import { HostUserInfo } from "../utils"; +import { CmdResult, RunOpts } from "../opts"; + +export interface RunCtx { + run(cmdArgs: string[], opts: RunOpts): Promise, + getHostUserInfo(): Promise, +} diff --git a/src/common/opts.ts b/src/common/opts.ts new file mode 100644 index 0000000..0fc3444 --- /dev/null +++ b/src/common/opts.ts @@ -0,0 +1,22 @@ +import { LogOutputChannel } from "vscode"; + +export type Envs = Record; + +export interface SpawnOpts { + env?: Envs, + cwd?: string, + stdin?: string | undefined, + log: LogOutputChannel, +} + +export interface RunOpts { + cwd?: string, + env?: Envs, + stdin?: string | undefined, +} + +export interface CmdResult { + exit: number | NodeJS.Signals, + stdout: string, + stderr: string, +}; diff --git a/src/common/spawn.ts b/src/common/spawn.ts index 66d4b7e..2b90165 100644 --- a/src/common/spawn.ts +++ b/src/common/spawn.ts @@ -1,24 +1,9 @@ -import { LogOutputChannel } from "vscode"; import * as chproc from "node:child_process"; - -export type Envs = Record; - -export interface CmdResult { - exit: number | NodeJS.Signals, - stdout: string, - stderr: string, -}; +import { CmdResult, SpawnOpts } from "./opts"; let cmdCount: number = 0; /* eslint-disable @typescript-eslint/no-confusing-void-expression */ -export interface SpawnOpts { - env?: Envs, - cwd?: string, - stdin?: string | undefined, - log: LogOutputChannel, -} - export function spawn( cmd: string, args: string[], From 97f033ab95d5cde6933432fafaaa2e758a002ddb Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Sun, 23 Aug 2026 13:08:10 -0700 Subject: [PATCH 2/9] exec-chaining p2 Add `FsCtx` and `ExecCtx` over execServer and local for a common runner interface over local and remote contexts. `RemoteExecCtx` wraps execServer. `LocalExecCtx` is just a wrapper around `vscode.workspace.fs*` for filesystem ops and native spawn when on a local machine. On remote session (say ssh), `vscode.Uri` resolves files correctly, but native spawn won't work. So explicitly request execServer into `LocalExecCtx` for spawn when on a remote. All fs and exec/spawn ops now go through `LocalExecCtx` and `RemoteExecCtx`. Window reload/create now correctly appends chain when a remote is detected. --- src/common/cmd.ts | 6 +- src/common/ctx/ctx.ts | 22 ++++-- src/common/ctx/execCtx.ts | 24 ++++++ src/common/ctx/localCtx.ts | 46 +++++++++++- src/common/ctx/remoteCtx.ts | 144 +++++++++++++++++++++++++----------- src/common/ctx/runctx.ts | 7 -- src/common/utils.ts | 18 +---- src/extension.ts | 12 +++ src/extension/commands.ts | 7 +- src/extension/workspace.ts | 14 ++-- src/parser/parser.ts | 7 +- src/remote/resolver.ts | 59 +++++++++++++-- 12 files changed, 271 insertions(+), 95 deletions(-) create mode 100644 src/common/ctx/execCtx.ts delete mode 100644 src/common/ctx/runctx.ts diff --git a/src/common/cmd.ts b/src/common/cmd.ts index 25a115e..2825626 100644 --- a/src/common/cmd.ts +++ b/src/common/cmd.ts @@ -1,8 +1,8 @@ -import * as common from "./spawn"; import { RunOpts } from "./opts"; import { getLogSink } from "../extension/log"; +import { getExecCtx } from "./ctx/ctx"; export async function run(cmdArgs: string[], opts: RunOpts) { - getLogSink().trace(`cmd.run: [${cmdArgs.join(", ")}]: opts: ${JSON.stringify(opts)}`); - return await common.spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() }); + getLogSink().debug(`cmd.run: [${cmdArgs.join(", ")}]: opts: ${JSON.stringify(opts)}`); + return await getExecCtx().run(cmdArgs, opts); } diff --git a/src/common/ctx/ctx.ts b/src/common/ctx/ctx.ts index c18b048..4c8faea 100644 --- a/src/common/ctx/ctx.ts +++ b/src/common/ctx/ctx.ts @@ -1,15 +1,21 @@ +import { getLogSink } from "../../extension/log"; import * as localCtx from "./localCtx"; -import { RunCtx } from "./runctx"; +import { ExecCtx } from "./execCtx"; -let RUNCTX: RunCtx | undefined; +let EXEC_CTX: ExecCtx | undefined; +let callCount: number = 0; -export function setRunCtx(ctx: RunCtx) { - RUNCTX = ctx; +export function setExecCtx(ctx: ExecCtx) { + EXEC_CTX = ctx; } -export function getRunCtx(): RunCtx { - if (!RUNCTX) { - RUNCTX = new localCtx.LocalRunCtx(); +export function getExecCtx(): ExecCtx { + callCount++; + + getLogSink().debug(`getExecCtx: ${callCount.toString().padStart(4, "0")}`); + + if (!EXEC_CTX) { + EXEC_CTX = new localCtx.LocalExecCtx(); } - return RUNCTX; + return EXEC_CTX; } diff --git a/src/common/ctx/execCtx.ts b/src/common/ctx/execCtx.ts new file mode 100644 index 0000000..820e054 --- /dev/null +++ b/src/common/ctx/execCtx.ts @@ -0,0 +1,24 @@ +import * as vscode from "vscode"; + +import { HostUserInfo } from "../utils"; +import { CmdResult, RunOpts } from "../opts"; + +export enum ExecCtxKind { + Local = "Local", + LocalSSH = "LocalSSH", // counterintuitive, but this means "local workspace" is on a remote machine + ExecServer = "ExecServer", // devcontainer over chained resolver (devcontainer over ssh remote) +} + +export interface FsCtx { + stat(filePath: vscode.Uri): Promise, + read(filePath: vscode.Uri): Promise, + write(filePath: vscode.Uri, content: string): Promise, +}; + +export interface ExecCtx { + kind: ExecCtxKind, + fs: FsCtx, + run(cmdArgs: string[], opts: RunOpts): Promise, + getHostUserInfo(): Promise, + env(): Promise, +} diff --git a/src/common/ctx/localCtx.ts b/src/common/ctx/localCtx.ts index bc2c38d..74f869f 100644 --- a/src/common/ctx/localCtx.ts +++ b/src/common/ctx/localCtx.ts @@ -1,12 +1,46 @@ -import { RunCtx } from "./runctx"; +import * as vscode from "vscode"; + +import { ExecCtx, ExecCtxKind, FsCtx } from "./execCtx"; import { HostUserInfo } from "../utils"; import { formatCmdErr, spawn } from "../spawn"; import { getLogSink } from "../../extension/log"; import { SpawnError } from "../../extension/error"; import { CmdResult, RunOpts } from "../opts"; +import { spawnRemote } from "./remoteCtx"; + +class LocalFsCtx implements FsCtx { + async stat(path: vscode.Uri): Promise { + return vscode.workspace.fs.stat(path); + } + + async read(path: vscode.Uri): Promise { + const bytes = await vscode.workspace.fs.readFile(path); + return new TextDecoder("utf-8").decode(bytes); + } + + async write(path: vscode.Uri, content: string): Promise { + await vscode.workspace.fs.writeFile( + path, Buffer.from(content)); + } +} + +export class LocalExecCtx implements ExecCtx { + kind: ExecCtxKind; + execServer: vscode.ExecServer | undefined; + fs: FsCtx; + + constructor(execServer?: vscode.ExecServer) { + // By ensuring file paths are vscode.Uri, remote-local files are resolved automatically by vscode + // nothing special to do here + this.kind = (execServer === undefined) ? ExecCtxKind.LocalSSH : ExecCtxKind.Local; + this.execServer = execServer; + this.fs = new LocalFsCtx(); + } -export class LocalRunCtx implements RunCtx { async run(cmdArgs: string[], opts: RunOpts): Promise { + if (this.execServer) { + return await spawnRemote(this.execServer, cmdArgs, opts); + } return await spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() }); } @@ -25,4 +59,12 @@ export class LocalRunCtx implements RunCtx { } /* eslint-enable @typescript-eslint/no-non-null-assertion */ } + + // eslint-disable-next-line @typescript-eslint/require-await + async env(): Promise { + return { + env: process.env as vscode.ProcessEnv, + osPlatform: process.platform, + }; + } } diff --git a/src/common/ctx/remoteCtx.ts b/src/common/ctx/remoteCtx.ts index 557bc4d..864f57a 100644 --- a/src/common/ctx/remoteCtx.ts +++ b/src/common/ctx/remoteCtx.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { RunCtx } from "./runctx"; +import { ExecCtx, ExecCtxKind, FsCtx } from "./execCtx"; import { HostUserInfo } from "../utils"; import { formatCmdErr } from "../spawn"; import { getLogSink } from "../../extension/log"; @@ -9,69 +9,63 @@ import { CmdResult, RunOpts } from "../opts"; let cmdCount: number = 0; -export class ExecRemoteRunCtx implements RunCtx { +class RemoteFsCtx implements FsCtx { constructor( - private readonly execServer: vscode.ExecServer, + private readonly remoteFs: vscode.RemoteFileSystem, ) { } - async run(cmdArgs: string[], opts: RunOpts): Promise { - cmdCount += 1; - const cmdId = cmdCount; - const cmdStr = () => `[CMD${String(cmdId).padStart(4, "0")}]:`; - - const [cmd, ...args] = cmdArgs; - const log = getLogSink(); - log.info(`RemoteRunCtx.spawn[${cmdStr()}]: [${cmdArgs.join(", ")}]`); - - const env: Record = opts.env - ? Object.fromEntries( - Object.entries(opts.env) - .filter((e): e is [string, string] => e[1] !== undefined), - ) - : {}; - - const spawned = await this.execServer.spawn(cmd, args, { - cwd: opts.cwd, - env: env, + async stat(path: vscode.Uri): Promise { + return this.remoteFs.stat(path.fsPath); + } + + async read(path: vscode.Uri): Promise { + const stream = await this.remoteFs.read(path.fsPath); + const chunks: Uint8Array[] = []; + + stream.onDidReceiveMessage((data) => { + chunks.push(data); }); - if (opts.stdin !== undefined) { - spawned.stdin.write(new TextEncoder().encode(opts.stdin)); - spawned.stdin.end(); - } + await stream.onEnd; - let stdout = ""; - let stderr = ""; const decoder = new TextDecoder("utf-8"); + return decoder.decode(Buffer.concat(chunks)); + } - spawned.stdout.onDidReceiveMessage((data: Uint8Array) => { - const chunk = decoder.decode(data, { stream: true }); - stdout += chunk; - log.error(`RemoteRunCtx.stdout.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}`, chunk); - }); + async write(path: vscode.Uri, content: string): Promise { + const { stream, done } = await this.remoteFs.write(path.fsPath); + stream.write(new TextEncoder().encode(content)); + stream.end(); + await done; + } +} - spawned.stderr.onDidReceiveMessage((data: Uint8Array) => { - const chunk = decoder.decode(data, { stream: true }); - stderr += chunk; - log.trace(`RemoteRunCtx.stderr.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}`, chunk); - }); +export class RemoteExecCtx implements ExecCtx { + kind: ExecCtxKind; + fs: RemoteFsCtx; - const exit = await spawned.onExit; + constructor( + private readonly execServer: vscode.ExecServer, + ) { + this.kind = ExecCtxKind.ExecServer; + this.fs = new RemoteFsCtx(execServer.fs); + } - return { exit: exit.status, stdout, stderr }; + public async run(cmdArgs: string[], opts: RunOpts): Promise { + return spawnRemote(this.execServer, cmdArgs, opts); } - async getHostUserInfo(): Promise { + public async getHostUserInfo(): Promise { const uid = await this.run(["id", "-u"], {}); const gid = await this.run(["id", "-g"], {}); const username = await this.run(["id", "-un"], {}); if (uid.exit !== 0 || gid.exit !== 0) { - throw new SpawnError(`RemoteRunCtx.getHostUserInfo: Could not query remote host user info: uid: ${formatCmdErr(uid)} gid: ${formatCmdErr(gid)}`); + throw new SpawnError(`RemoteExecCtx.getHostUserInfo: Could not query remote host user info: uid: ${formatCmdErr(uid)} gid: ${formatCmdErr(gid)}`); } if (username.exit !== 0) { - getLogSink().warn(`RemoteRunCtx.getHostUserInfo: Could not query remote host user info: ${formatCmdErr(username)}`); + getLogSink().warn(`RemoteExecCtx.getHostUserInfo: Could not query remote host user info: ${formatCmdErr(username)}`); } return { @@ -80,4 +74,68 @@ export class ExecRemoteRunCtx implements RunCtx { name: username.stdout.trim(), }; } + + async env() { + return this.execServer.env(); + } +} + +export async function spawnRemote(execServer: vscode.ExecServer, cmdArgs: string[], opts: RunOpts) { + cmdCount += 1; + const cmdId = cmdCount; + + const [cmd, ...args] = cmdArgs; + const log = getLogSink(); + + const cmdStr = () => `CMD${String(cmdId).padStart(4, "0")}`; + log.info(`RemoteExecCtx.spawn[${cmdStr()}]: [${cmdArgs.join(", ")}]`); + + const env: Record = opts.env + ? Object.fromEntries( + Object.entries(opts.env) + .filter((e): e is [string, string] => e[1] !== undefined), + ) + : {}; + + const spawned = await execServer.spawn(cmd, args, { + cwd: opts.cwd, + env: env, + }); + + if (opts.stdin !== undefined) { + spawned.stdin.write(new TextEncoder().encode(opts.stdin)); + spawned.stdin.end(); + } + + let stdout = ""; + let stderr = ""; + const decoder = new TextDecoder("utf-8"); + + spawned.stdout.onDidReceiveMessage((data: Uint8Array) => { + const chunk = decoder.decode(data, { stream: true }); + stdout += chunk; + log.debug(`RemoteExecCtx.stdout.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}, chunk: '${chunk.trim()}'`); + }); + + spawned.stderr.onDidReceiveMessage((data: Uint8Array) => { + const chunk = decoder.decode(data, { stream: true }); + stderr += chunk; + log.debug(`RemoteExecCtx.stderr.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}, chunk: '${chunk.trim()}'`); + }); + + const exit = await spawned.onExit; + const info: CmdResult = { + exit: exit.status, + stdout: stdout, + stderr: stderr, + }; + + if (exit.status === 0) { + log.info(`RemoteExecCtx[${cmdStr()}]: stdout: ${info.stdout.trim()}`); + } + else { + log.error(`RemoteExecCtx[${cmdStr()}]: ${formatCmdErr(info)}`); + } + + return { exit: exit.status, stdout, stderr }; } diff --git a/src/common/ctx/runctx.ts b/src/common/ctx/runctx.ts deleted file mode 100644 index 00be58e..0000000 --- a/src/common/ctx/runctx.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { HostUserInfo } from "../utils"; -import { CmdResult, RunOpts } from "../opts"; - -export interface RunCtx { - run(cmdArgs: string[], opts: RunOpts): Promise, - getHostUserInfo(): Promise, -} diff --git a/src/common/utils.ts b/src/common/utils.ts index c692f5d..def5992 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -2,10 +2,8 @@ import * as vscode from "vscode"; import * as fs from "node:fs/promises"; import path from "node:path"; -import { run } from "../common/cmd"; -import { formatCmdErr } from "../common/spawn"; -import { SpawnError } from "../extension/error"; import { Settings } from "../extension/settings"; +import { getExecCtx } from "./ctx/ctx"; export interface HostUserInfo { uid: number, @@ -52,19 +50,7 @@ export function getEngineCmd(s: Settings): string[] { } export async function getHostUserInfo(): Promise { - const userName = await run(["/bin/sh", "-c", "id -n -u $UID"], {}); - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - if (userName.exit === 0) { - return { - uid: process.getuid!(), - gid: process.getgid!(), - name: userName.stdout.trim(), - }; - } - else { - throw new SpawnError(`Could not query host user info (uid, gid, name): ${formatCmdErr(userName)}`); - } - /* eslint-enable @typescript-eslint/no-non-null-assertion */ + return getExecCtx().getHostUserInfo(); } export interface ProductJson { diff --git a/src/extension.ts b/src/extension.ts index c187af3..2216563 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,8 @@ import { AUTHORITY_BASE, DevContainerResolver } from "./remote/resolver"; import { createDevcontainerConfigWatcher, isRemoteDevcontainerSession, onWorkspaceReady } from "./extension/workspace"; import { checkVersionAndNotify } from "./extension/releaseNotes"; import { checkLegacySettings } from "./extension/settings"; +import { setExecCtx } from "./common/ctx/ctx"; +import { LocalExecCtx } from "./common/ctx/localCtx"; export function activate(ctx: vscode.ExtensionContext) { const logger = initLogs(ctx); @@ -45,6 +47,16 @@ export function activate(ctx: vscode.ExtensionContext) { } }); } + else { + // by "local", we mean "workspace-local". So on a remote machine (say ssh), local + // means workspace-local. So spawn has to use the underlying resolver's exec server + // plead it for one + if (vscode.env.remoteAuthority /* && !isRemoteDevcontainerSession() */) { + vscode.workspace.getRemoteExecServer(vscode.env.remoteAuthority).then((server) => { + if (server) { setExecCtx(new LocalExecCtx(server)); } + }); + } + } checkVersionAndNotify(ctx); checkLegacySettings(); diff --git a/src/extension/commands.ts b/src/extension/commands.ts index 16c2a6c..1896137 100644 --- a/src/extension/commands.ts +++ b/src/extension/commands.ts @@ -12,6 +12,7 @@ import { getLogfilePath, getLogSink } from "./log"; import { EXTENSION_ID } from "../common/constants"; import { InternalError } from "./error"; import { getEngineCmd } from "../common/utils"; +import { getExecCtx } from "../common/ctx/ctx"; enum RebuildPrompt { RebuildNoCache = "Yes (Rebuild without cache)", @@ -74,7 +75,8 @@ export async function onRemoteReady(ctx: vscode.ExtensionContext): Promise const localWsf = getLocalWorkspaceFolder(); const devcontainerJson = await findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); - const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig); + const hostEnv = await getExecCtx().env(); + const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env); const cmds = cc.getLifecycleCmd(LifecycleCmd.postAttach); @@ -166,7 +168,8 @@ export async function remotePromptRebuildIfStale(ctx: vscode.ExtensionContext) { const localWsf = getLocalWorkspaceFolder(); const devcontainerJson = await findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); - const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig); + const hostEnv = await getExecCtx().env(); + const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env); isConfigStale(localWsf, cc).then((isStale) => { if (isStale) { diff --git a/src/extension/workspace.ts b/src/extension/workspace.ts index 8aa9cf5..bf6a466 100644 --- a/src/extension/workspace.ts +++ b/src/extension/workspace.ts @@ -6,6 +6,7 @@ import { getLogSink } from "./log"; import { ConfigError } from "./error"; import { AUTHORITY_BASE, decodeRemoteAuthority } from "../remote/resolver"; import * as cmds from "../extension/commands"; +import { getExecCtx } from "../common/ctx/ctx"; export enum NotificationLevel { Info, @@ -33,8 +34,9 @@ export function getWorkspaceId(localWsp: string): string { } async function fileExists(filePath: vscode.Uri): Promise { + getLogSink().debug(`fileExists check: '${filePath.toString(true)}'`); try { - await vscode.workspace.fs.stat(filePath); + await getExecCtx().fs.stat(filePath); return true; } catch { @@ -43,10 +45,11 @@ async function fileExists(filePath: vscode.Uri): Promise { } export async function findDevcontainerJson(dir: vscode.Uri): Promise { + getLogSink().debug(`Searching '${dir.toString(true)}' for config files ...`); const filePaths = ConfigPaths(dir); for (const f of filePaths) { if (await fileExists(f)) { - getLogSink().info(`Using devcontainer.json at ${f.toString(true)}`); + getLogSink().info(`Using devcontainer.json at '${f.toString(true)}'`); return f; } } @@ -59,9 +62,10 @@ export function isRemoteDevcontainerSession(): boolean { } export function getLocalWorkspaceFolder(): vscode.Uri { - const remote = vscode.env.remoteAuthority; - if (remote?.startsWith(AUTHORITY_BASE)) { - return decodeRemoteAuthority(remote); + getLogSink().info(`getLocalWorkspaceFolder: remote is ${vscode.env.remoteAuthority}`); + if (isRemoteDevcontainerSession()) { + const remote = vscode.env.remoteAuthority; + return decodeRemoteAuthority(remote!); // eslint-disable-line @typescript-eslint/no-non-null-assertion } // for remotes that aren't devcontainer (say, ssh), the workspace // is "local" from extension's pov, so use "local" workspace diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 9b1cf91..b264cef 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -3,12 +3,15 @@ import * as jc from "jsonc-parser"; import * as schema from "./schema"; import { ParseError } from "../extension/error"; +import { getExecCtx } from "../common/ctx/ctx"; +import { getLogSink } from "../extension/log"; /* eslint-disable @typescript-eslint/unified-signatures */ export async function parseDevcontainer(fpath: vscode.Uri): Promise; export async function parseDevcontainer(contents: string): Promise; export async function parseDevcontainer(arg: string | vscode.Uri): Promise { if (typeof arg === "string") { + getLogSink().debug(`parseDevcontainer(${arg.length}: string):`); const parseInfo = schema.ConfigSchema.safeParse(jc.parse(arg)); if (parseInfo.success) { return parseInfo.data; @@ -18,10 +21,10 @@ export async function parseDevcontainer(arg: string | vscode.Uri): Promise { try { - const bytes = await vscode.workspace.fs.readFile(arg); - return new TextDecoder("utf-8").decode(bytes); + return await getExecCtx().fs.read(arg); } catch (e) { throw new ParseError(`Could not read devcontainer.json file at ${arg.toString(true)}: ${JSON.stringify(e)}`); diff --git a/src/remote/resolver.ts b/src/remote/resolver.ts index eadcd2e..a0723ec 100644 --- a/src/remote/resolver.ts +++ b/src/remote/resolver.ts @@ -10,6 +10,8 @@ import { BuildOptIntent } from "../common/globalState"; import { EngineError, InstallError, InternalError } from "../extension/error"; import { getExtensionList, getSettings } from "../extension/settings"; import { DEVCONTAINER_SERVER_LISTEN_PORT } from "../common/constants"; +import { setExecCtx, getExecCtx } from "../common/ctx/ctx"; +import { RemoteExecCtx } from "../common/ctx/remoteCtx"; export const AUTHORITY_BASE: string = "devcontainer-remote"; @@ -88,10 +90,19 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc void this.extensionCtx; // TODO } - resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { - this.localWsf = decodeRemoteAuthority(authority); + resolve(_authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { + const fullAuthority = vscode.env.remoteAuthority!; // eslint-disable-line @typescript-eslint/no-non-null-assertion - getLogSink().info(`Starting remote session from ${this.localWsf.toString(true)} (authority ${authority}, attempt #${context.resolveAttempt})...`); + if (context.execServer) { + getLogSink().info(`Exec server was provided: '${fullAuthority}'`); + setExecCtx(new RemoteExecCtx(context.execServer)); + // if exec server, any commands below run on the remote machine + } + + // authority has just our part, but we need the entirety for full Uri + this.localWsf = decodeRemoteAuthority(fullAuthority); + + getLogSink().info(`Starting remote session from ${this.localWsf.toString(true)} (authority ${fullAuthority}, attempt #${context.resolveAttempt})...`); const localWsfBasename = path.parse(this.localWsf.fsPath).base; @@ -110,7 +121,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc cancellationToken: vscode.CancellationToken, ): Promise { try { - return await this.createWindowTask(progress, cancellationToken); + return await this.createWindowTask(context, progress, cancellationToken); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); @@ -127,7 +138,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc } } - private async createWindowTask(progress: vscode.Progress<{ message?: string, increment?: number }>, _2: vscode.CancellationToken): Promise { + private async createWindowTask(context: vscode.RemoteAuthorityResolverContext, progress: vscode.Progress<{ message?: string, increment?: number }>, _2: vscode.CancellationToken): Promise { const buildOpt = BuildOptIntent.get(this.extensionCtx) ?? BuildOpts.Default; const settings = getSettings(); const engine = (() => { @@ -144,7 +155,8 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc const devcontainerJson = await findDevcontainerJson(this.localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); - const containerConfig = ContainerConfig.create(this.localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, process.env, { engine: engine }); + const hostEnv = await getExecCtx().env(); + const containerConfig = ContainerConfig.create(this.localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env, { engine: engine }); progress.report({ message: "Building image and starting container...", increment: 15 }); this.containerState = await ContainerState.create(this.localWsf.fsPath, containerConfig, settings, buildOpt); @@ -180,7 +192,14 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc vscode.commands.executeCommand("setContext", "forwardedPortsViewEnabled", true); vscode.commands.executeCommand("setContext", "forwardedPortsFeaturesEnabled", true); - return new vscode.ResolvedAuthority(host, port, ctkn); + // if exec server, host and port are on the remote machine. forward it out to the local machine + if (context.execServer) { + const msg = connectToRemote(context.execServer, host, port); + return new vscode.ManagedResolvedAuthority(() => { return msg; }, ctkn); + } + else { + return new vscode.ResolvedAuthority(host, port, ctkn); + } } // getCanonicalURI?(uri: vscode.Uri): vscode.ProviderResult { @@ -231,3 +250,29 @@ async function notifyResolverError(message: string) { return; } } + +// better name lol +async function connectToRemote(execServer: vscode.ExecServer, host: string, port: number): Promise { + const { stream, done } = await execServer.tcpConnect(host, port); + + const onDidClose = new vscode.EventEmitter(); + const onDidEnd = new vscode.EventEmitter(); + + done.then( + () => { + onDidEnd.fire(); + onDidClose.fire(undefined); + }, + (err: unknown) => { + onDidClose.fire(err instanceof Error ? err : new Error(String(err))); + }, + ); + + return { + onDidReceiveMessage: stream.onDidReceiveMessage, + onDidClose: onDidClose.event, + onDidEnd: onDidEnd.event, + send: (data: Uint8Array) => { stream.write(data); }, + end: () => { stream.end(); }, + }; +} From c70fb78a36d25b19c5e5c5222267946138bae8d0 Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Tue, 25 Aug 2026 01:18:43 -0700 Subject: [PATCH 3/9] add ssh helpers --- src/extension/ssh.ts | 143 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/extension/ssh.ts diff --git a/src/extension/ssh.ts b/src/extension/ssh.ts new file mode 100644 index 0000000..db6f672 --- /dev/null +++ b/src/extension/ssh.ts @@ -0,0 +1,143 @@ +// These are copied and adapted from https://github.com/jeanp413/open-remote-ssh/ +// +// https://github.com/jeanp413/open-remote-ssh/blob/41e50958631e8bb127d4786b65ba8e35e3fb1245/src/serverSetup.ts#L28-L75 +// https://github.com/jeanp413/open-remote-ssh/blob/41e50958631e8bb127d4786b65ba8e35e3fb1245/src/ssh/sshDestination.ts +// https://github.com/jeanp413/open-remote-ssh/blob/41e50958631e8bb127d4786b65ba8e35e3fb1245/LICENSE.txt +// +// MIT License +// +// Copyright (c) 2022 +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/* eslint-disable @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment */ +/** + * Matches a hostname against a pattern that may contain wildcards. + * Returns a specificity score: higher scores indicate more specific matches. + * Returns -1 if no match. + */ +function matchHostnamePattern(hostname: string, pattern: string): number { + // Exact match has highest priority + if (hostname === pattern) { + return 1000; + } + + // Catch-all wildcard has lowest priority + if (pattern === "*") { + return 1; + } + + // Convert wildcard pattern to regex + // Escape special regex characters except * + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + + const regex = new RegExp(`^${regexPattern}$`); + + if (regex.test(hostname)) { + // Calculate specificity based on the number of non-wildcard characters + // More specific patterns (more characters) get higher scores + const nonWildcardChars = pattern.replace(/\*/g, "").length; + return 10 + nonWildcardChars; + } + + return -1; +} + +/** + * Finds the best matching path for a hostname from a map of patterns to paths. + * Supports wildcards with priority: exact match > specific wildcard > general wildcard. + */ +export function findSSHServerInstallPath(hostname: string, pathMap: Record): string | undefined { + let bestMatch: { pattern: string, path: string, score: number } | undefined; + + for (const [pattern, path] of Object.entries(pathMap)) { + const score = matchHostnamePattern(hostname, pattern); + + if (score > 0) { + if (!bestMatch || score > bestMatch.score) { + bestMatch = { pattern, path, score }; + } + } + } + + return bestMatch?.path; +} + +// MIT + +export class SSHDestination { + constructor( + public readonly hostname: string, + public readonly user?: string, + public readonly port?: number, + ) { + } + + static parse(dest: string): SSHDestination { + let user: string | undefined; + const atPos = dest.lastIndexOf("@"); + if (atPos !== -1) { + user = dest.substring(0, atPos); + } + + let port: number | undefined; + const colonPos = dest.lastIndexOf(":"); + if (colonPos !== -1) { + port = parseInt(dest.substring(colonPos + 1), 10); + } + + const start = atPos !== -1 ? atPos + 1 : 0; + const end = colonPos !== -1 ? colonPos : dest.length; + const hostname = dest.substring(start, end); + + return new SSHDestination(hostname, user, port); + } + + toString(): string { + let result = this.hostname; + if (this.user) { + result = `${this.user}@` + result; + } + if (this.port) { + result = result + `:${this.port}`; + } + return result; + } + + // vscode.uri implementation lowercases the authority, so when reopen or restore + // a remote session from the recently openend list the connection fails + static parseEncoded(dest: string): SSHDestination { + try { + const data = JSON.parse(Buffer.from(dest, "hex").toString()); + return new SSHDestination(data.hostName, data.user, data.port); + } + catch { + // ignore + } + + return SSHDestination.parse(dest.replace(/\\x([0-9a-f]{2})/g, (_, charCode) => String.fromCharCode(parseInt(charCode, 16)))); + } + + toEncodedString(): string { + return this.toString().replace(/[A-Z]/g, ch => `\\x${ch.charCodeAt(0).toString(16).toLowerCase()}`); + } +} +/* eslint-enable @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment */ From 03b423afc2a60f8ef0256827e4177ab864f6e278 Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Sat, 29 Aug 2026 20:23:51 -0700 Subject: [PATCH 4/9] local / remote settings for exec-chaining - `getSettings` fetches remote machine's global settings through `execServer` when exec chaining because vscode's `getConfiguration` returns local global settings - update tests / simplify a little skips all the async `getSettings` logic, since they always run with local ctx. remote is TODO. --- src/common/utils.ts | 13 +++++ src/extension/commands.ts | 4 +- src/extension/remoteSettings.ts | 96 +++++++++++++++++++++++++++++++++ src/extension/settings.ts | 34 ++++++++---- src/remote/resolver.ts | 11 ++-- 5 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 src/extension/remoteSettings.ts diff --git a/src/common/utils.ts b/src/common/utils.ts index def5992..82e5c07 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -77,3 +77,16 @@ export async function getProductJson(): Promise { serverDownloadUrlTemplate: jsonData.serverDownloadUrlTemplate, }; } + +export function getRemoteAuthorities() { + const auth = vscode.env.remoteAuthority; + + if (!auth) { return [auth]; } + + const inx = auth.indexOf("@"); + + if (inx > 0) { + return auth.split("@"); + } + else { return [auth]; } +} diff --git a/src/extension/commands.ts b/src/extension/commands.ts index 1896137..c1e070e 100644 --- a/src/extension/commands.ts +++ b/src/extension/commands.ts @@ -25,7 +25,7 @@ export function getCmd(suffix: string) { } export async function getContainerEngineVersion() { - const engine = getSettings().dockerPath; + const engine = (await getSettings()).dockerPath; const { stdout } = await cmd.run([engine, "--version"], {}); vscode.window.showInformationMessage(`${engine} version: ${stdout}`); } @@ -150,7 +150,7 @@ function promptBuildOpt() { } export async function isConfigStale(localWsf: vscode.Uri, cc: ContainerConfig): Promise { - const cmd = getEngineCmd(getSettings()); + const cmd = getEngineCmd(await getSettings()); const containerConfigId = await queryContainerConfigId(cmd, localWsf.fsPath); if (!containerConfigId) { diff --git a/src/extension/remoteSettings.ts b/src/extension/remoteSettings.ts new file mode 100644 index 0000000..4809d01 --- /dev/null +++ b/src/extension/remoteSettings.ts @@ -0,0 +1,96 @@ +import * as vscode from "vscode"; +import path from "node:path"; + +import * as jc from "jsonc-parser"; + +import { getExecCtx } from "../common/ctx/ctx"; +import { getProductJson, getRemoteAuthorities } from "../common/utils"; +import { InternalError } from "./error"; +import { getConfig, Settings } from "./settings"; +import { findSSHServerInstallPath, SSHDestination } from "./ssh"; +import { getLogSink } from "./log"; + +/** + * + * `vscode.workspace.getConfiguration` during execServer chaining reads only the + * user config, not remote machine's. User may have configured different extension + * settings there, so we must read those ourselves. + * By default, for vscodium, it's at `$HOME/.vscodium-server/data/Machine/settings.json` + * This path can be configured with `remote.SSH.serverInstallPath`, a key-value pair + * of regex keys (for server/hostnames) and values as paths. + * + * TODO: Windows support, and merge local settings + * + * @returns `Settings` on the remote machine + */ +export async function getRemoteserverConfiguration(): Promise { + getLogSink().info(`Fetching remote settings '${vscode.env.remoteAuthority}'`); + const productJson = await getProductJson(); + + // get hostname from ssh + // and chained is always at inx 1, that we chain to + const auths = getRemoteAuthorities(); + const sshAuthority = auths[1]; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (auths === undefined || sshAuthority === undefined) { + // man i dunno + throw new InternalError("getRemoteserverConfiguration: Expected SSH authority to exist"); + } + + const hostname = SSHDestination.parse(sshAuthority).hostname; + const remoteSysenv = await getExecCtx().env(); + + if (!("HOME" in remoteSysenv.env)) { + throw new InternalError("HOME was undefined on remote env"); + } + const homedir = remoteSysenv.env.HOME; + + const DEFAULT_DIR = path.posix.join(homedir, productJson.serverDataFolderName); + + const readConfig = async (installPath: string): Promise => { + getLogSink().info(`getRemoteserverConfiguration: remote SSH install at: ${installPath}`); + const cfgPath = path.posix.join(installPath, "data/Machine/settings.json"); + const raw = (await getExecCtx().fs.read(vscode.Uri.file(cfgPath))).trim(); + getLogSink().debug(`getRemoteserverConfiguration: raw cfg: ${raw}`); + + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + const remoteSettings = jc.parse(raw); + return { + dockerPath: remoteSettings["dev.containers.dockerPath"] ?? "docker", + extraArgs: remoteSettings["dev.containers.extraArgs"] ?? [], + defaultExtensions: remoteSettings["dev.containers.defaultExtensions"] ?? [], + }; + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + }; + + const serverInstallPathMap = vscode.workspace + .getConfiguration("remote.SSH") + .get>("serverInstallPath"); + + // default is home if not configured + const remoteInstallPath = findSSHServerInstallPath(sshAuthority, serverInstallPathMap ?? {}); + + const logmsg = remoteInstallPath + ? `serverInstallPath for host ${hostname} = ${remoteInstallPath}` + : `no serverInstallPath for host ${hostname}, using default ${DEFAULT_DIR}`; + + getLogSink().info(`getRemoteserverConfiguration: remote.SSH.serverInstallPath[${hostname}]: ${logmsg}`); + + try { + return await readConfig(remoteInstallPath ?? DEFAULT_DIR); + } + catch (e: unknown) { + if (e instanceof Error) { + getLogSink().error(`getRemoteserverConfiguration: returning local cfg, reading remote config failed: ${e.message}`); + } + else { + getLogSink().error(`getRemoteserverConfiguration: returning local cfg, reading remote config failed with unknown error: ${JSON.stringify(e)}`); + } + return { + dockerPath: getConfig("dockerPath") ?? "docker", + extraArgs: getConfig("extraArgs") ?? [], + defaultExtensions: getConfig("defaultExtensions") ?? [], + }; + } +} diff --git a/src/extension/settings.ts b/src/extension/settings.ts index d8fb70c..0e0c101 100644 --- a/src/extension/settings.ts +++ b/src/extension/settings.ts @@ -1,22 +1,40 @@ import * as vscode from "vscode"; +import { getExecCtx } from "../common/ctx/ctx"; +import { ExecCtxKind } from "../common/ctx/execCtx"; +import { getRemoteserverConfiguration } from "./remoteSettings"; +import { getLogSink } from "./log"; + export interface Settings { dockerPath: string, extraArgs: string[], defaultExtensions: string[], } +let _settings: Settings | undefined; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters export function getConfig(key: string): T | undefined { return vscode.workspace.getConfiguration("dev.containers").get(key); } -export function getSettings(): Settings { - return { - dockerPath: getConfig("dockerPath") ?? "docker", - extraArgs: getConfig("extraArgs") ?? [], - defaultExtensions: getConfig("defaultExtensions") ?? [], - }; +export async function getSettings(): Promise { + if (!_settings) { + _settings = await (async () => { + if (getExecCtx().kind !== ExecCtxKind.ExecServer) { + getLogSink().info("Fetching local settings"); + return { + dockerPath: getConfig("dockerPath") ?? "docker", + extraArgs: getConfig("extraArgs") ?? [], + defaultExtensions: getConfig("defaultExtensions") ?? [], + }; + } + else { + return await getRemoteserverConfiguration(); + } + })(); + } + return _settings; } // TODO: remove after 0.8.0 @@ -40,7 +58,3 @@ export function checkLegacySettings() { }); } } - -export function getExtensionList(): string[] { - return (getConfig("defaultExtensions") ?? []); -} diff --git a/src/remote/resolver.ts b/src/remote/resolver.ts index a0723ec..7c74814 100644 --- a/src/remote/resolver.ts +++ b/src/remote/resolver.ts @@ -8,7 +8,7 @@ import { ContainerConfig, ContainerEngine } from "../engine/container"; import { parseDevcontainer } from "../parser/parser"; import { BuildOptIntent } from "../common/globalState"; import { EngineError, InstallError, InternalError } from "../extension/error"; -import { getExtensionList, getSettings } from "../extension/settings"; +import { getSettings } from "../extension/settings"; import { DEVCONTAINER_SERVER_LISTEN_PORT } from "../common/constants"; import { setExecCtx, getExecCtx } from "../common/ctx/ctx"; import { RemoteExecCtx } from "../common/ctx/remoteCtx"; @@ -140,7 +140,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc private async createWindowTask(context: vscode.RemoteAuthorityResolverContext, progress: vscode.Progress<{ message?: string, increment?: number }>, _2: vscode.CancellationToken): Promise { const buildOpt = BuildOptIntent.get(this.extensionCtx) ?? BuildOpts.Default; - const settings = getSettings(); + const settings = await getSettings(); const engine = (() => { const e = path.parse(settings.dockerPath).base; if (e === "podman") { return ContainerEngine.podman; } @@ -180,7 +180,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc progress.report({ message: "Installing server...", increment: 30 }); const { host, port } = await this.containerState.installServer([ - ...getExtensionList(), + ...settings.defaultExtensions, ...parsedConfig.customizations?.vscode?.extensions ?? [], ]); this.serverHostPort = port; @@ -194,8 +194,9 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc // if exec server, host and port are on the remote machine. forward it out to the local machine if (context.execServer) { - const msg = connectToRemote(context.execServer, host, port); - return new vscode.ManagedResolvedAuthority(() => { return msg; }, ctkn); + return new vscode.ManagedResolvedAuthority(() => { + return connectToRemote(context.execServer!, host, port); // eslint-disable-line @typescript-eslint/no-non-null-assertion + }, ctkn); } else { return new vscode.ResolvedAuthority(host, port, ctkn); From ae5d19b7abebf7e5f264d4b4a36f9f0bd9a9a79e Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Sun, 30 Aug 2026 15:18:16 -0700 Subject: [PATCH 5/9] update remote label to include ssh hostname --- README.md | 1 - src/remote/resolver.ts | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c3f78b..9679f02 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ These features are not supported, but might be added in the future. - config: `hostRequirements` - config: `waitFor` - named config files `.devcontainer//devcontainer.json` -- devcontainer on a remote machine (wip!) While this extension might work with `vscode`, it is not supported. diff --git a/src/remote/resolver.ts b/src/remote/resolver.ts index 7c74814..438950e 100644 --- a/src/remote/resolver.ts +++ b/src/remote/resolver.ts @@ -12,6 +12,7 @@ import { getSettings } from "../extension/settings"; import { DEVCONTAINER_SERVER_LISTEN_PORT } from "../common/constants"; import { setExecCtx, getExecCtx } from "../common/ctx/ctx"; import { RemoteExecCtx } from "../common/ctx/remoteCtx"; +import { SSHDestination } from "../extension/ssh"; export const AUTHORITY_BASE: string = "devcontainer-remote"; @@ -164,6 +165,20 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc const containerId = await this.containerState.getContainerId(); const localWsfBasename = path.parse(this.localWsf.fsPath).base; + const remoteHost = (() => { + const auth = vscode.env.remoteAuthority ?? ""; + if (!auth.includes("@")) { return undefined; } + const [_devc, ssh, ..._rest] = auth.split("@"); + const [_sshAuthority, sshEncoded] = ssh.split("+"); + return SSHDestination.parseEncoded(sshEncoded).hostname; + })(); + + // '📦 (container)' for local + // '📦 (host:container)' for ssh-chained + const containerTag = remoteHost + ? `${remoteHost}:${containerId.slice(0, 8)}` + : containerId.slice(0, 8); + // set status bar item this.statusItemFormatter = vscode.workspace.registerResourceLabelFormatter({ @@ -173,7 +188,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc label: "${path}", separator: "/", tildify: true, - workspaceSuffix: `📦 ${localWsfBasename} (${containerId.slice(0, 8)})`, + workspaceSuffix: `📦 ${localWsfBasename} (${containerTag})`, }, }); From f9e4e66336a7a0f9a9c9e564d3d2324819a106b8 Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Wed, 23 Sep 2026 19:22:08 -0700 Subject: [PATCH 6/9] misc fixes - add `fmtErr` helper for `Error` - command to open devcontainer.json config (`showDevcontainerFile`) file now opens it in the active workspace (say, in container) correctly, instead of attempting to open it on the host - config watcher improvements: - extract config watcher to a cmd and reuse that - set up file watcher only after workspace is ready, using the previously added `onWorkspaceReady` - 500ms debounce to the file watcher - tweak imports in commands.ts --- src/common/utils.ts | 9 +++++++ src/extension.ts | 27 +++++++++++--------- src/extension/commands.ts | 44 ++++++++++++++++++++++----------- src/extension/remoteSettings.ts | 8 ++---- src/extension/workspace.ts | 44 ++++++++++++++++++++++++--------- src/parser/parser.ts | 3 ++- 6 files changed, 90 insertions(+), 45 deletions(-) diff --git a/src/common/utils.ts b/src/common/utils.ts index 82e5c07..22ced6a 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -5,6 +5,15 @@ import path from "node:path"; import { Settings } from "../extension/settings"; import { getExecCtx } from "./ctx/ctx"; +export function fmtErr(e: unknown): string { + if (e instanceof Error) { + return e.stack ?? e.message; + } + else { + return `unknown error: ${JSON.stringify(e)}`; + } +} + export interface HostUserInfo { uid: number, gid: number, diff --git a/src/extension.ts b/src/extension.ts index 2216563..45b28d0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,11 +4,12 @@ import * as cmds from "./extension/commands"; import { BuildOpts } from "./engine/lifecycle"; import { getLogSink, initLogs } from "./extension/log"; import { AUTHORITY_BASE, DevContainerResolver } from "./remote/resolver"; -import { createDevcontainerConfigWatcher, isRemoteDevcontainerSession, onWorkspaceReady } from "./extension/workspace"; +import { isRemoteDevcontainerSession, onWorkspaceReady } from "./extension/workspace"; import { checkVersionAndNotify } from "./extension/releaseNotes"; import { checkLegacySettings } from "./extension/settings"; import { setExecCtx } from "./common/ctx/ctx"; import { LocalExecCtx } from "./common/ctx/localCtx"; +import { fmtErr } from "./common/utils"; export function activate(ctx: vscode.ExtensionContext) { const logger = initLogs(ctx); @@ -29,32 +30,36 @@ export function activate(ctx: vscode.ExtensionContext) { logger, ); - void createDevcontainerConfigWatcher(ctx).then((watcher) => { - ctx.subscriptions.push(watcher); - }).catch((e: unknown) => { - getLogSink().error(`createDevcontainerConfigWatcher failed: ${JSON.stringify(e)}`); - }); - if (isRemoteDevcontainerSession()) { onWorkspaceReady()?.then(async () => { getLogSink().info("onWorkspaceReady: workspace ready"); + ctx.subscriptions.push(await cmds.setupConfigWatcher(ctx)); try { await cmds.onRemoteReady(ctx); getLogSink().info("onRemoteReady: OK"); } catch (e: unknown) { - getLogSink().error(`onRemoteReady: Error ${JSON.stringify(e)}`); + getLogSink().error(`onRemoteReady: Error ${fmtErr(e)}`); } }); } else { + void cmds.setupConfigWatcher(ctx).then((watcher) => { + ctx.subscriptions.push(watcher); + }); + // by "local", we mean "workspace-local". So on a remote machine (say ssh), local // means workspace-local. So spawn has to use the underlying resolver's exec server // plead it for one if (vscode.env.remoteAuthority /* && !isRemoteDevcontainerSession() */) { - vscode.workspace.getRemoteExecServer(vscode.env.remoteAuthority).then((server) => { - if (server) { setExecCtx(new LocalExecCtx(server)); } - }); + vscode.workspace.getRemoteExecServer(vscode.env.remoteAuthority).then( + (server: vscode.ExecServer | undefined) => { + if (server) { setExecCtx(new LocalExecCtx(server)); } + }, + (err: unknown) => { + getLogSink().error(`Could not getRemoteExecServer on '${vscode.env.remoteAuthority}'. Error: ${fmtErr(err)}`); + }, + ); } } diff --git a/src/extension/commands.ts b/src/extension/commands.ts index c1e070e..72523d9 100644 --- a/src/extension/commands.ts +++ b/src/extension/commands.ts @@ -1,17 +1,17 @@ import * as vscode from "vscode"; import * as cmd from "../common/cmd"; +import * as workspace from "./workspace"; import { getSettings } from "./settings"; import { ContainerConfig, LifecycleCmd } from "../engine/container"; import { parseDevcontainer } from "../parser/parser"; import { encodeRemoteAuthority } from "../remote/resolver"; -import { findDevcontainerJson, getLocalWorkspaceFolder, isRemoteDevcontainerSession, NotificationLevel, showNotification } from "./workspace"; import { BuildOpts, queryContainerConfigId } from "../engine/lifecycle"; import { BuildOptIntent } from "../common/globalState"; import { getLogfilePath, getLogSink } from "./log"; import { EXTENSION_ID } from "../common/constants"; import { InternalError } from "./error"; -import { getEngineCmd } from "../common/utils"; +import { fmtErr, getEngineCmd } from "../common/utils"; import { getExecCtx } from "../common/ctx/ctx"; enum RebuildPrompt { @@ -24,6 +24,16 @@ export function getCmd(suffix: string) { return `${EXTENSION_ID}.${suffix}`; } +export async function setupConfigWatcher(ctx: vscode.ExtensionContext): Promise { + try { + return await workspace.createDevcontainerConfigWatcher(ctx); + } + catch (e: unknown) { + getLogSink().error(`createDevcontainerConfigWatcher failed: ${fmtErr(e)}`); + return new vscode.Disposable(() => { }); + } +} + export async function getContainerEngineVersion() { const engine = (await getSettings()).dockerPath; const { stdout } = await cmd.run([engine, "--version"], {}); @@ -31,8 +41,8 @@ export async function getContainerEngineVersion() { } export async function openRemote(ctx: vscode.ExtensionContext, opts: BuildOpts = BuildOpts.Default) { - const localWsf = getLocalWorkspaceFolder(); - const devcontainerJson = await findDevcontainerJson(localWsf); + const localWsf = workspace.getLocalWorkspaceFolder(); + const devcontainerJson = await workspace.findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig); @@ -56,13 +66,17 @@ export async function openRemote(ctx: vscode.ExtensionContext, opts: BuildOpts = export async function openLocal() { await vscode.commands.executeCommand( "vscode.openFolder", - getLocalWorkspaceFolder(), + workspace.getLocalWorkspaceFolder(), ); } export async function showDevcontainerFile() { - const file = await findDevcontainerJson(getLocalWorkspaceFolder()); - vscode.commands.executeCommand("vscode.open", file); + // TODO: optimize this, since we're having to read the file + const localWsf = workspace.getLocalWorkspaceFolder(); + const file = await workspace.findDevcontainerJson(localWsf); + const parsedConfig = await parseDevcontainer(file); + const cc = ContainerConfig.create(localWsf.fsPath, file.fsPath, parsedConfig); + vscode.commands.executeCommand("vscode.open", workspace.remapToCurrentAuthority(localWsf, file, cc.getRemoteMountDir())); } export function showLogFile() { @@ -72,8 +86,8 @@ export function showLogFile() { export async function onRemoteReady(ctx: vscode.ExtensionContext): Promise { getLogSink().info("onRemoteReady"); - const localWsf = getLocalWorkspaceFolder(); - const devcontainerJson = await findDevcontainerJson(localWsf); + const localWsf = workspace.getLocalWorkspaceFolder(); + const devcontainerJson = await workspace.findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); const hostEnv = await getExecCtx().env(); const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env); @@ -117,11 +131,11 @@ async function _openRemote(ctx: vscode.ExtensionContext, localWsf: vscode.Uri, c getLogSink().info(`_openRemote: localWsf: ${localWsf.toString(true)} cc.Id: ${cc.getConfigId()} opts: ${opts}`); BuildOptIntent.set(ctx, opts); - if (isRemoteDevcontainerSession() && opts === BuildOpts.Default) { + if (workspace.isRemoteDevcontainerSession() && opts === BuildOpts.Default) { // nothing to do, already on remote return; } - if (isRemoteDevcontainerSession() && opts !== BuildOpts.Default) { + if (workspace.isRemoteDevcontainerSession() && opts !== BuildOpts.Default) { // this is when rebuild/nocache options are used from a remote session // force a window reload so that the intent is picked up on next load await vscode.commands.executeCommand("workbench.action.reloadWindow"); @@ -161,12 +175,12 @@ export async function isConfigStale(localWsf: vscode.Uri, cc: ContainerConfig): } export async function remotePromptRebuildIfStale(ctx: vscode.ExtensionContext) { - if (!isRemoteDevcontainerSession()) { + if (!workspace.isRemoteDevcontainerSession()) { throw new InternalError("checkRemoteIsStale: Expected remote session."); } - const localWsf = getLocalWorkspaceFolder(); - const devcontainerJson = await findDevcontainerJson(localWsf); + const localWsf = workspace.getLocalWorkspaceFolder(); + const devcontainerJson = await workspace.findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); const hostEnv = await getExecCtx().env(); const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env); @@ -190,7 +204,7 @@ export async function remotePromptRebuildIfStale(ctx: vscode.ExtensionContext) { } return; }).catch((e: unknown) => { - showNotification(NotificationLevel.Error, `Could not reopen: ${JSON.stringify(e)}`); + workspace.showNotification(workspace.NotificationLevel.Error, `Could not reopen: ${fmtErr(e)}`); }); } diff --git a/src/extension/remoteSettings.ts b/src/extension/remoteSettings.ts index 4809d01..6b51747 100644 --- a/src/extension/remoteSettings.ts +++ b/src/extension/remoteSettings.ts @@ -9,6 +9,7 @@ import { InternalError } from "./error"; import { getConfig, Settings } from "./settings"; import { findSSHServerInstallPath, SSHDestination } from "./ssh"; import { getLogSink } from "./log"; +import { fmtErr } from "../common/utils"; /** * @@ -81,12 +82,7 @@ export async function getRemoteserverConfiguration(): Promise { return await readConfig(remoteInstallPath ?? DEFAULT_DIR); } catch (e: unknown) { - if (e instanceof Error) { - getLogSink().error(`getRemoteserverConfiguration: returning local cfg, reading remote config failed: ${e.message}`); - } - else { - getLogSink().error(`getRemoteserverConfiguration: returning local cfg, reading remote config failed with unknown error: ${JSON.stringify(e)}`); - } + getLogSink().error(`getRemoteserverConfiguration: returning local cfg, reading remote config failed ${fmtErr(e)}`); return { dockerPath: getConfig("dockerPath") ?? "docker", extraArgs: getConfig("extraArgs") ?? [], diff --git a/src/extension/workspace.ts b/src/extension/workspace.ts index bf6a466..1f1b003 100644 --- a/src/extension/workspace.ts +++ b/src/extension/workspace.ts @@ -7,6 +7,9 @@ import { ConfigError } from "./error"; import { AUTHORITY_BASE, decodeRemoteAuthority } from "../remote/resolver"; import * as cmds from "../extension/commands"; import { getExecCtx } from "../common/ctx/ctx"; +import { fmtErr } from "../common/utils"; +import { parseDevcontainer } from "../parser/parser"; +import { ContainerConfig } from "../engine/container"; export enum NotificationLevel { Info, @@ -117,30 +120,32 @@ export async function createDevcontainerConfigWatcher(ctx: vscode.ExtensionConte getLogSink().error(`Watcher: No workspace or config found: ${e.message}`); } else { - getLogSink().error(`Unknown error: ${JSON.stringify(e)}`); + getLogSink().error(`Unknown error: ${fmtErr(e)}`); } return new vscode.Disposable(() => { }); } - const configName = path.posix.basename(configPath.path); - const configDir = vscode.Uri.joinPath(configPath, ".."); + const localWsf = getLocalWorkspaceFolder(); + const parsedConfig = await parseDevcontainer(configPath); + const cc = ContainerConfig.create(localWsf.fsPath, configPath.fsPath, parsedConfig); + const watchPath = remapToCurrentAuthority(localWsf, configPath, cc.getRemoteMountDir()); + const configName = path.posix.basename(watchPath.path); + const configDir = vscode.Uri.joinPath(watchPath, ".."); const pattern = new vscode.RelativePattern(configDir, configName); const watcher = vscode.workspace.createFileSystemWatcher(pattern); - watcher.onDidChange(async () => { - if (isRemoteDevcontainerSession()) { + let debounceTimer: ReturnType | undefined; + watcher.onDidChange(() => { + if (!isRemoteDevcontainerSession()) { return; } + if (debounceTimer) { clearTimeout(debounceTimer); } + debounceTimer = setTimeout(async () => { try { await cmds.remotePromptRebuildIfStale(ctx); } catch (e: unknown) { - if (e instanceof Error) { - getLogSink().error(`remotePromptRebuildIfStale failed ${e.message}`); - } - else { - getLogSink().error(`remotePromptRebuildIfStale failed ${JSON.stringify(e)}`); - } + getLogSink().error(`remotePromptRebuildIfStale failed: ${fmtErr(e)}`); } - } + }, 500); }); watcher.onDidCreate(() => { updateHasConfigContext(true); }); watcher.onDidDelete(() => { updateHasConfigContext(false); }); @@ -148,6 +153,21 @@ export async function createDevcontainerConfigWatcher(ctx: vscode.ExtensionConte return watcher; } +export function remapToCurrentAuthority(localWsf: vscode.Uri, filePath: vscode.Uri, remoteMountDir: string): vscode.Uri { + if (!isRemoteDevcontainerSession()) { + return filePath; + } + const authority = vscode.env.remoteAuthority; + if (!authority) { return filePath; } + + const relativePath = path.posix.relative(localWsf.path, filePath.path); + return vscode.Uri.from({ + scheme: "vscode-remote", + authority, + path: path.posix.join(remoteMountDir, relativePath), + }); +} + export function onWorkspaceReady() { // wait for remote workspace const wsf = vscode.workspace.workspaceFolders; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index b264cef..112edae 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -5,6 +5,7 @@ import * as schema from "./schema"; import { ParseError } from "../extension/error"; import { getExecCtx } from "../common/ctx/ctx"; import { getLogSink } from "../extension/log"; +import { fmtErr } from "../common/utils"; /* eslint-disable @typescript-eslint/unified-signatures */ export async function parseDevcontainer(fpath: vscode.Uri): Promise; @@ -27,7 +28,7 @@ export async function parseDevcontainer(arg: string | vscode.Uri): Promise Date: Thu, 24 Sep 2026 01:13:51 -0700 Subject: [PATCH 7/9] misc bug fixes - fix LocalExecCtx's kind. was incorrectly swapped - fix shared `TextDecoder` in `spawnRemote`. `stdout` and `stderr` now have their own decoders - `getRemoteserverConfiguration` fixes: - pass hostname instead of authority to `findSSHServerInstallPath` - SSHDestination.parse takes in just the encoded bit, without the authority --- src/common/ctx/localCtx.ts | 2 +- src/common/ctx/remoteCtx.ts | 9 +++++---- src/common/utils.ts | 4 ++-- src/extension/remoteSettings.ts | 11 +++++++---- src/remote/resolver.ts | 1 + 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/common/ctx/localCtx.ts b/src/common/ctx/localCtx.ts index 74f869f..f819463 100644 --- a/src/common/ctx/localCtx.ts +++ b/src/common/ctx/localCtx.ts @@ -32,7 +32,7 @@ export class LocalExecCtx implements ExecCtx { constructor(execServer?: vscode.ExecServer) { // By ensuring file paths are vscode.Uri, remote-local files are resolved automatically by vscode // nothing special to do here - this.kind = (execServer === undefined) ? ExecCtxKind.LocalSSH : ExecCtxKind.Local; + this.kind = (execServer === undefined) ? ExecCtxKind.Local : ExecCtxKind.LocalSSH; this.execServer = execServer; this.fs = new LocalFsCtx(); } diff --git a/src/common/ctx/remoteCtx.ts b/src/common/ctx/remoteCtx.ts index 864f57a..caf421d 100644 --- a/src/common/ctx/remoteCtx.ts +++ b/src/common/ctx/remoteCtx.ts @@ -99,7 +99,7 @@ export async function spawnRemote(execServer: vscode.ExecServer, cmdArgs: string const spawned = await execServer.spawn(cmd, args, { cwd: opts.cwd, - env: env, + env: { ...env, BUILDKIT_PROGRESS: "plain" }, }); if (opts.stdin !== undefined) { @@ -109,16 +109,17 @@ export async function spawnRemote(execServer: vscode.ExecServer, cmdArgs: string let stdout = ""; let stderr = ""; - const decoder = new TextDecoder("utf-8"); + const stdoutDecoder = new TextDecoder("utf-8"); + const stderrDecoder = new TextDecoder("utf-8"); spawned.stdout.onDidReceiveMessage((data: Uint8Array) => { - const chunk = decoder.decode(data, { stream: true }); + const chunk = stdoutDecoder.decode(data, { stream: true }); stdout += chunk; log.debug(`RemoteExecCtx.stdout.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}, chunk: '${chunk.trim()}'`); }); spawned.stderr.onDidReceiveMessage((data: Uint8Array) => { - const chunk = decoder.decode(data, { stream: true }); + const chunk = stderrDecoder.decode(data, { stream: true }); stderr += chunk; log.debug(`RemoteExecCtx.stderr.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}, chunk: '${chunk.trim()}'`); }); diff --git a/src/common/utils.ts b/src/common/utils.ts index 22ced6a..f48fa80 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -87,10 +87,10 @@ export async function getProductJson(): Promise { }; } -export function getRemoteAuthorities() { +export function getRemoteAuthorities(): string[] | undefined { const auth = vscode.env.remoteAuthority; - if (!auth) { return [auth]; } + if (!auth) { return undefined; } const inx = auth.indexOf("@"); diff --git a/src/extension/remoteSettings.ts b/src/extension/remoteSettings.ts index 6b51747..077dfe3 100644 --- a/src/extension/remoteSettings.ts +++ b/src/extension/remoteSettings.ts @@ -31,17 +31,20 @@ export async function getRemoteserverConfiguration(): Promise { // get hostname from ssh // and chained is always at inx 1, that we chain to const auths = getRemoteAuthorities(); - const sshAuthority = auths[1]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (auths === undefined || sshAuthority === undefined) { + if (auths === undefined || auths[1] === undefined) { // man i dunno throw new InternalError("getRemoteserverConfiguration: Expected SSH authority to exist"); } - const hostname = SSHDestination.parse(sshAuthority).hostname; + const sshRemote = auths[1]; + const [sshAuthority, encodedSSH] = sshRemote.split("+"); + const hostname = SSHDestination.parseEncoded(encodedSSH).hostname; const remoteSysenv = await getExecCtx().env(); + getLogSink().info(`SSH authority is '${sshAuthority}'+'${hostname}' (encoded: '${encodedSSH}')`); + if (!("HOME" in remoteSysenv.env)) { throw new InternalError("HOME was undefined on remote env"); } @@ -70,7 +73,7 @@ export async function getRemoteserverConfiguration(): Promise { .get>("serverInstallPath"); // default is home if not configured - const remoteInstallPath = findSSHServerInstallPath(sshAuthority, serverInstallPathMap ?? {}); + const remoteInstallPath = findSSHServerInstallPath(hostname, serverInstallPathMap ?? {}); const logmsg = remoteInstallPath ? `serverInstallPath for host ${hostname} = ${remoteInstallPath}` diff --git a/src/remote/resolver.ts b/src/remote/resolver.ts index 438950e..aeb1591 100644 --- a/src/remote/resolver.ts +++ b/src/remote/resolver.ts @@ -280,6 +280,7 @@ async function connectToRemote(execServer: vscode.ExecServer, host: string, port onDidClose.fire(undefined); }, (err: unknown) => { + onDidEnd.fire(); onDidClose.fire(err instanceof Error ? err : new Error(String(err))); }, ); From 86c6c76961672399eb168cdc43d787107bbc02c2 Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Thu, 24 Sep 2026 02:32:15 -0700 Subject: [PATCH 8/9] move `getSettings` into `execCtx` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit all settings queries now happen through execCtx interface, removing a lot of bad spaghetti mamma mia 🤌 --- src/common/ctx/execCtx.ts | 2 ++ src/common/ctx/localCtx.ts | 16 +++++++++ src/common/ctx/remoteCtx.ts | 15 +++++++++ src/{extension => common}/remoteSettings.ts | 12 +++---- src/common/settings.ts | 29 ++++++++++++++++ src/common/utils.ts | 6 +++- src/engine/lifecycle.ts | 2 +- src/extension/commands.ts | 5 ++- src/extension/settings.ts | 37 --------------------- src/extension/workspace.ts | 11 +++--- src/remote/resolver.ts | 12 +++++-- src/tests/common.ts | 2 +- 12 files changed, 90 insertions(+), 59 deletions(-) rename src/{extension => common}/remoteSettings.ts (92%) create mode 100644 src/common/settings.ts diff --git a/src/common/ctx/execCtx.ts b/src/common/ctx/execCtx.ts index 820e054..05a1274 100644 --- a/src/common/ctx/execCtx.ts +++ b/src/common/ctx/execCtx.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { HostUserInfo } from "../utils"; import { CmdResult, RunOpts } from "../opts"; +import { Settings } from "../settings"; export enum ExecCtxKind { Local = "Local", @@ -21,4 +22,5 @@ export interface ExecCtx { run(cmdArgs: string[], opts: RunOpts): Promise, getHostUserInfo(): Promise, env(): Promise, + getSettings(): Promise, } diff --git a/src/common/ctx/localCtx.ts b/src/common/ctx/localCtx.ts index f819463..19bf671 100644 --- a/src/common/ctx/localCtx.ts +++ b/src/common/ctx/localCtx.ts @@ -7,6 +7,7 @@ import { getLogSink } from "../../extension/log"; import { SpawnError } from "../../extension/error"; import { CmdResult, RunOpts } from "../opts"; import { spawnRemote } from "./remoteCtx"; +import * as settings from "../settings"; class LocalFsCtx implements FsCtx { async stat(path: vscode.Uri): Promise { @@ -67,4 +68,19 @@ export class LocalExecCtx implements ExecCtx { osPlatform: process.platform, }; } + + // eslint-disable-next-line @typescript-eslint/require-await + async getSettings(): Promise { + // for user-local or remote-local workspace, settings is just + // querying it via vscode's API it does the right thing even + // if on a remote machine + const ret = settings.withDefaults({ + dockerPath: settings.getConfig("dockerPath"), + extraArgs: settings.getConfig("extraArgs"), + defaultExtensions: settings.getConfig("defaultExtensions"), + }); + + getLogSink().debug(`LocalExecCtx.getSettings(): ${JSON.stringify(ret)}`); + return ret; + } } diff --git a/src/common/ctx/remoteCtx.ts b/src/common/ctx/remoteCtx.ts index caf421d..35bdc5b 100644 --- a/src/common/ctx/remoteCtx.ts +++ b/src/common/ctx/remoteCtx.ts @@ -6,6 +6,8 @@ import { formatCmdErr } from "../spawn"; import { getLogSink } from "../../extension/log"; import { SpawnError } from "../../extension/error"; import { CmdResult, RunOpts } from "../opts"; +import { getRemoteserverConfiguration } from "../remoteSettings"; +import * as settings from "../settings"; let cmdCount: number = 0; @@ -78,6 +80,19 @@ export class RemoteExecCtx implements ExecCtx { async env() { return this.execServer.env(); } + + async getSettings(): Promise { + // using the vscode api is fine iff the remote is already connected. + // This is called mostly if not exclusively during exec-chained connection, + // so we need to parse settings ourselves from the remote machine. + // Since we're already doing it ourselves anyway, this is what will be used + // even after a remote connection instead of the vscode api + // TODO: Note that this doesn't combine { ...local, ...machine, ...user }, + // fetches exactly the machine's global config (the ssh host's settings) + const ret = await getRemoteserverConfiguration(); + getLogSink().debug(`RemoteExecCtx.getSettings(): ${JSON.stringify(ret)}`); + return ret; + } } export async function spawnRemote(execServer: vscode.ExecServer, cmdArgs: string[], opts: RunOpts) { diff --git a/src/extension/remoteSettings.ts b/src/common/remoteSettings.ts similarity index 92% rename from src/extension/remoteSettings.ts rename to src/common/remoteSettings.ts index 077dfe3..17b5c25 100644 --- a/src/extension/remoteSettings.ts +++ b/src/common/remoteSettings.ts @@ -3,13 +3,12 @@ import path from "node:path"; import * as jc from "jsonc-parser"; -import { getExecCtx } from "../common/ctx/ctx"; -import { getProductJson, getRemoteAuthorities } from "../common/utils"; -import { InternalError } from "./error"; +import { getExecCtx } from "./ctx/ctx"; +import { getProductJson, getRemoteAuthorities, fmtErr } from "./utils"; +import { InternalError } from "../extension/error"; import { getConfig, Settings } from "./settings"; -import { findSSHServerInstallPath, SSHDestination } from "./ssh"; -import { getLogSink } from "./log"; -import { fmtErr } from "../common/utils"; +import { findSSHServerInstallPath, SSHDestination } from "../extension/ssh"; +import { getLogSink } from "../extension/log"; /** * @@ -21,6 +20,7 @@ import { fmtErr } from "../common/utils"; * of regex keys (for server/hostnames) and values as paths. * * TODO: Windows support, and merge local settings + * merged = { ...local, ...machine, ...user }? * * @returns `Settings` on the remote machine */ diff --git a/src/common/settings.ts b/src/common/settings.ts new file mode 100644 index 0000000..5ccf4ef --- /dev/null +++ b/src/common/settings.ts @@ -0,0 +1,29 @@ +import * as vscode from "vscode"; + +export interface Settings { + dockerPath: string, + extraArgs: string[], + defaultExtensions: string[], +} + +function getDefaults(): Settings { + return { + dockerPath: "docker", + extraArgs: [], + defaultExtensions: [], + }; +} + +export function withDefaults(queried: Partial): Settings { + const defaults = getDefaults(); + return { + dockerPath: queried.dockerPath ?? defaults.dockerPath, + extraArgs: queried.extraArgs ?? defaults.extraArgs, + defaultExtensions: queried.defaultExtensions ?? defaults.defaultExtensions, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function getConfig(key: string): T | undefined { + return vscode.workspace.getConfiguration("dev.containers").get(key); +} diff --git a/src/common/utils.ts b/src/common/utils.ts index f48fa80..45a833e 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as fs from "node:fs/promises"; import path from "node:path"; -import { Settings } from "../extension/settings"; +import { Settings } from "./settings"; import { getExecCtx } from "./ctx/ctx"; export function fmtErr(e: unknown): string { @@ -99,3 +99,7 @@ export function getRemoteAuthorities(): string[] | undefined { } else { return [auth]; } } + +export function consume(..._: unknown[]) { + void (_); +} diff --git a/src/engine/lifecycle.ts b/src/engine/lifecycle.ts index b2f2bda..c15980b 100644 --- a/src/engine/lifecycle.ts +++ b/src/engine/lifecycle.ts @@ -9,7 +9,7 @@ import { ContainerConfig, ContainerEngine, LifecycleCmd } from "./container"; import { EngineError, InstallError, InternalError } from "../extension/error"; import { NotificationLevel, showNotification } from "../extension/workspace"; -import * as settings from "../extension/settings"; +import * as settings from "../common/settings"; import * as server from "../remote/installServer"; import { EXTENSION_ID, DEVCONTAINER_SERVER_LISTEN_PORT, CAT_PIPE_STDIN_WORKAROUND } from "../common/constants"; diff --git a/src/extension/commands.ts b/src/extension/commands.ts index 72523d9..3e3036e 100644 --- a/src/extension/commands.ts +++ b/src/extension/commands.ts @@ -2,7 +2,6 @@ import * as vscode from "vscode"; import * as cmd from "../common/cmd"; import * as workspace from "./workspace"; -import { getSettings } from "./settings"; import { ContainerConfig, LifecycleCmd } from "../engine/container"; import { parseDevcontainer } from "../parser/parser"; import { encodeRemoteAuthority } from "../remote/resolver"; @@ -35,7 +34,7 @@ export async function setupConfigWatcher(ctx: vscode.ExtensionContext): Promise< } export async function getContainerEngineVersion() { - const engine = (await getSettings()).dockerPath; + const engine = (await getExecCtx().getSettings()).dockerPath; const { stdout } = await cmd.run([engine, "--version"], {}); vscode.window.showInformationMessage(`${engine} version: ${stdout}`); } @@ -164,7 +163,7 @@ function promptBuildOpt() { } export async function isConfigStale(localWsf: vscode.Uri, cc: ContainerConfig): Promise { - const cmd = getEngineCmd(await getSettings()); + const cmd = getEngineCmd(await getExecCtx().getSettings()); const containerConfigId = await queryContainerConfigId(cmd, localWsf.fsPath); if (!containerConfigId) { diff --git a/src/extension/settings.ts b/src/extension/settings.ts index 0e0c101..3c3cc9e 100644 --- a/src/extension/settings.ts +++ b/src/extension/settings.ts @@ -1,42 +1,5 @@ import * as vscode from "vscode"; -import { getExecCtx } from "../common/ctx/ctx"; -import { ExecCtxKind } from "../common/ctx/execCtx"; -import { getRemoteserverConfiguration } from "./remoteSettings"; -import { getLogSink } from "./log"; - -export interface Settings { - dockerPath: string, - extraArgs: string[], - defaultExtensions: string[], -} - -let _settings: Settings | undefined; - -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -export function getConfig(key: string): T | undefined { - return vscode.workspace.getConfiguration("dev.containers").get(key); -} - -export async function getSettings(): Promise { - if (!_settings) { - _settings = await (async () => { - if (getExecCtx().kind !== ExecCtxKind.ExecServer) { - getLogSink().info("Fetching local settings"); - return { - dockerPath: getConfig("dockerPath") ?? "docker", - extraArgs: getConfig("extraArgs") ?? [], - defaultExtensions: getConfig("defaultExtensions") ?? [], - }; - } - else { - return await getRemoteserverConfiguration(); - } - })(); - } - return _settings; -} - // TODO: remove after 0.8.0 const LEGACY_KEYS = ["engine", "extraArgs", "containerBinary"]; diff --git a/src/extension/workspace.ts b/src/extension/workspace.ts index 1f1b003..128eee2 100644 --- a/src/extension/workspace.ts +++ b/src/extension/workspace.ts @@ -138,14 +138,11 @@ export async function createDevcontainerConfigWatcher(ctx: vscode.ExtensionConte watcher.onDidChange(() => { if (!isRemoteDevcontainerSession()) { return; } if (debounceTimer) { clearTimeout(debounceTimer); } - debounceTimer = setTimeout(async () => { - try { - await cmds.remotePromptRebuildIfStale(ctx); - } - catch (e: unknown) { + debounceTimer = setTimeout(() => { + cmds.remotePromptRebuildIfStale(ctx).catch((e: unknown) => { getLogSink().error(`remotePromptRebuildIfStale failed: ${fmtErr(e)}`); - } - }, 500); + }); + }, 250); }); watcher.onDidCreate(() => { updateHasConfigContext(true); }); watcher.onDidDelete(() => { updateHasConfigContext(false); }); diff --git a/src/remote/resolver.ts b/src/remote/resolver.ts index aeb1591..15a37a4 100644 --- a/src/remote/resolver.ts +++ b/src/remote/resolver.ts @@ -8,11 +8,11 @@ import { ContainerConfig, ContainerEngine } from "../engine/container"; import { parseDevcontainer } from "../parser/parser"; import { BuildOptIntent } from "../common/globalState"; import { EngineError, InstallError, InternalError } from "../extension/error"; -import { getSettings } from "../extension/settings"; import { DEVCONTAINER_SERVER_LISTEN_PORT } from "../common/constants"; import { setExecCtx, getExecCtx } from "../common/ctx/ctx"; import { RemoteExecCtx } from "../common/ctx/remoteCtx"; import { SSHDestination } from "../extension/ssh"; +import * as utils from "../common/utils"; export const AUTHORITY_BASE: string = "devcontainer-remote"; @@ -141,7 +141,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc private async createWindowTask(context: vscode.RemoteAuthorityResolverContext, progress: vscode.Progress<{ message?: string, increment?: number }>, _2: vscode.CancellationToken): Promise { const buildOpt = BuildOptIntent.get(this.extensionCtx) ?? BuildOpts.Default; - const settings = await getSettings(); + const settings = await getExecCtx().getSettings(); const engine = (() => { const e = path.parse(settings.dockerPath).base; if (e === "podman") { return ContainerEngine.podman; } @@ -167,9 +167,15 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc const remoteHost = (() => { const auth = vscode.env.remoteAuthority ?? ""; - if (!auth.includes("@")) { return undefined; } + if (!auth.includes("@")) { + return undefined; + } + const [_devc, ssh, ..._rest] = auth.split("@"); const [_sshAuthority, sshEncoded] = ssh.split("+"); + + utils.consume(_devc, _rest, _sshAuthority); + return SSHDestination.parseEncoded(sshEncoded).hostname; })(); diff --git a/src/tests/common.ts b/src/tests/common.ts index 67047a2..bee5090 100644 --- a/src/tests/common.ts +++ b/src/tests/common.ts @@ -11,7 +11,7 @@ import { findDevcontainerJson } from "../extension/workspace"; import { parseDevcontainer } from "../parser/parser"; import { ContainerConfig } from "../engine/container"; import { Config } from "../parser/schema"; -import { Settings } from "../extension/settings"; +import { Settings } from "../common/settings"; import * as utils from "../common/utils"; const DEBUG_TESTS: boolean = process.env.DEBUG_TESTS !== undefined From 442ded3aa1977c8f8a7c0024657c422b0cefdfb4 Mon Sep 17 00:00:00 2001 From: Mythreya Kuricheti Date: Thu, 24 Sep 2026 04:12:48 -0700 Subject: [PATCH 9/9] add a way to override test timeout sometimes container builds take too long because of network activity or server delays --- src/common/tests/spawn.test.ts | 6 +++--- src/engine/tests/stage2.test.ts | 20 ++++++++++---------- src/tests/common.ts | 6 ++++++ src/tests/fixtures/img-basic/test.ts | 6 +++--- src/tests/fixtures/img-pull/test.ts | 8 ++++---- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/common/tests/spawn.test.ts b/src/common/tests/spawn.test.ts index db191cb..02909b3 100644 --- a/src/common/tests/spawn.test.ts +++ b/src/common/tests/spawn.test.ts @@ -4,7 +4,7 @@ import { spawn } from "../spawn"; import { getLogSink } from "../../extension/log"; import { ContainerInspectResult } from "../../engine/lifecycle"; -import { initMocks, jsonFormat, getMockSettings } from "../../tests/common"; +import { initMocks, jsonFormat, getMockSettings, getTestTimeout } from "../../tests/common"; function getcwd() { return __dirname; @@ -43,7 +43,7 @@ describe.skipIf(!SETTINGS.dockerPath)("cmd spawn tests", () => { const remove = await spawn(engine, ["rm", create.stdout.trim()], opts()); expect(create.exit).eq(0); expect(remove.exit).eq(0); - }, 10_000); + }, getTestTimeout(10)); test("run and exec command", async () => { const create = await spawn(engine, ["run", "--name", "turtles", "-d", "ubuntu:24.04", ...bashSleepCmd], opts()); @@ -69,5 +69,5 @@ describe.skipIf(!SETTINGS.dockerPath)("cmd spawn tests", () => { const rm = await spawn(engine, ["rm", "turtles"], opts()); expect(rm.exit).eq(0); } - }, 30_000); + }, getTestTimeout(30)); }); diff --git a/src/engine/tests/stage2.test.ts b/src/engine/tests/stage2.test.ts index 3face53..1a44522 100644 --- a/src/engine/tests/stage2.test.ts +++ b/src/engine/tests/stage2.test.ts @@ -9,7 +9,7 @@ import { HostUserInfo } from "../../common/utils"; import * as schema from "../../parser/schema"; import * as lf from "../lifecycle"; -import { getMockSettings, initMocks } from "../../tests/common"; +import { getMockSettings, getTestTimeout, initMocks } from "../../tests/common"; import { CAT_PIPE_STDIN_WORKAROUND } from "../../common/constants"; function testWsf(label: string) { @@ -78,7 +78,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const warnMsg = Array.from(output.matchAll(lf.STAGE2_WARN_MSG_REGEX)); expect(warnMsg.length).eq(1); expect(warnMsg[0][1]).includes("Using user root"); - }, 45_000); + }, getTestTimeout(45)); test("remaps existing user UID/GID to host values", async () => { const { localWsf, cfgPath } = testWsf("remap-basic"); @@ -89,7 +89,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const { uid, gid } = await getRemoteUserInfo(cc, "ubuntu"); expect(uid).eq("5000"); expect(gid).eq("5000"); - }, 45_000); + }, getTestTimeout(45)); test("remaps UID when host GID already exists in container", async () => { // Regression test for upstream bugs: @@ -104,7 +104,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const { uid, gid } = await getRemoteUserInfo(cc, "ubuntu"); expect(uid).eq("2345"); expect(gid).eq("100"); - }, 45_000); + }, getTestTimeout(45)); test("UPDATE_REMOTE_UID=false skips remapping", async () => { const { localWsf, cfgPath } = testWsf("skip-remap"); @@ -118,7 +118,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const { uid } = await getRemoteUserInfo(cc, "ubuntu"); expect(uid).not.eq("9999"); - }, 45_000); + }, getTestTimeout(45)); test("nonexistent user fails with descriptive error", async () => { const { localWsf, cfgPath } = testWsf("no-user"); @@ -130,7 +130,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const errMsg = Array.from(output.matchAll(lf.STAGE2_ERR_MSG_REGEX)); expect(errMsg.length).toBeGreaterThanOrEqual(1); expect(errMsg[0][1]).includes("does not exist in container"); - }, 45_000); + }, getTestTimeout(45)); test("UID conflict: moves colliding user before remapping", async () => { const { localWsf, cfgPath } = testWsf("uid-conflict"); @@ -159,7 +159,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { // original owner of uid 1000 moved to 1000+1234 const { uid: movedUid } = await getRemoteUserInfo(cc, "ubuntu"); expect(movedUid).eq("2234"); - }, 45_000); + }, getTestTimeout(45)); }); describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () => { @@ -192,7 +192,7 @@ describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () const cleanup = await container.engineExec(["rm", `${wsDir}/${marker}`]); expect(cleanup.exit).eq(0); - }, 60_000); + }, getTestTimeout(60)); test("non-root remoteUser: uid inside container matches host uid", async () => { const { localWsf, cfgPath } = testWsf("podman-keepid-uid"); @@ -204,7 +204,7 @@ describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () const res = await container.engineExec(["id", "-u"]); expect(res.exit).eq(0); expect(res.stdout.trim()).eq(String(process.getuid?.())); - }, 60_000); + }, getTestTimeout(60)); test("root remoteUser: no keep-id, workspace still accessible", async () => { const { localWsf, cfgPath } = testWsf("podman-root"); @@ -223,5 +223,5 @@ describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () const cleanup = await container.engineExec(["rm", `${wsDir}/podman-root-test`]); expect(cleanup.exit).eq(0); - }, 60_000); + }, getTestTimeout(60)); }); diff --git a/src/tests/common.ts b/src/tests/common.ts index bee5090..f805abb 100644 --- a/src/tests/common.ts +++ b/src/tests/common.ts @@ -135,3 +135,9 @@ export async function setupFixture(opts: { name: string, testDir: string }): Pro return { localWsf: testDir, localWsfBasename: path.parse(testDir).base, config: config }; } /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment */ + +export function getTestTimeout(defaultDurSeconds: number): number { + const envTimeout = process.env.TEST_TIMEOUT; + const timeout = envTimeout ? Number.parseInt(envTimeout) : defaultDurSeconds; + return timeout * 1000; +} diff --git a/src/tests/fixtures/img-basic/test.ts b/src/tests/fixtures/img-basic/test.ts index c844212..4674783 100644 --- a/src/tests/fixtures/img-basic/test.ts +++ b/src/tests/fixtures/img-basic/test.ts @@ -7,7 +7,7 @@ import { ContainerInspectResult, ContainerState } from "../../../engine/lifecycl import { ContainerConfig } from "../../../engine/container"; import { parseEnv } from "../../../common/utils"; -import { initMocks, setupFixture, jsonFormat, getMockSettings, TEST_CODIUM_INFO } from "../../common"; +import { initMocks, setupFixture, jsonFormat, getMockSettings, TEST_CODIUM_INFO, getTestTimeout } from "../../common"; const SETTINGS = getMockSettings(); initMocks(); @@ -32,7 +32,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-basic", async cc = ContainerConfig.create(localWsf, devcPath, config, localEnv); container = await ContainerState.create(localWsf, cc, SETTINGS); - }, 60 * 1000); + }, getTestTimeout(60)); test("workspace mounts exists", async () => { const inspectResult = await run([engine, "inspect", container.getContainerName(), ...jsonFormat], { cwd: localWsf, env: localEnv }); @@ -127,7 +127,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-basic", async })(); expect(healthVersion).eq(TEST_CODIUM_INFO.commit); - }, 60 * 1000); + }, getTestTimeout(60)); test("connection token is stable across calls", async () => { const token1 = await container.getConnectionToken(); diff --git a/src/tests/fixtures/img-pull/test.ts b/src/tests/fixtures/img-pull/test.ts index 4ec68af..0ae2153 100644 --- a/src/tests/fixtures/img-pull/test.ts +++ b/src/tests/fixtures/img-pull/test.ts @@ -6,7 +6,7 @@ import { ContainerState } from "../../../engine/lifecycle"; import { ContainerConfig } from "../../../engine/container"; import * as schema from "../../../parser/schema"; -import { initMocks, setupFixture, jsonFormat, getMockSettings } from "../../common"; +import { initMocks, setupFixture, jsonFormat, getMockSettings, getTestTimeout } from "../../common"; const SETTINGS = getMockSettings(); initMocks(); @@ -51,7 +51,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-pull", async const cId = await container.getContainerId(); expect(cId.length).toBeGreaterThan(16); - }, 60_000); + }, getTestTimeout(60)); test("getConnectionToken returns correct token over multiple install (without force)", async () => { const firstInstall = await container.installServer(); @@ -100,7 +100,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-pull", async const iterTok = await container.getConnectionToken(); expect(iterTok).eq(token); } - }, 60_000); + }, getTestTimeout(60)); test("getConnectionToken dies on stopped container", async () => { const installServer = await container.installServer(); @@ -108,5 +108,5 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-pull", async await container.stopContainer(); await expect(container.getConnectionToken()).rejects.toThrow(Error); - }, 60_000); + }, getTestTimeout(60)); });