Skip to content

Commit 5a37265

Browse files
lpcoxCopilot
andauthored
fix: add ~/.local/bin to sandbox PATH for rootless copilot installs (#6407)
* fix: add ~/.local/bin to sbx PATH for rootless copilot installs The sbx microVM runs `bash -lc` with the injected PATH from buildCoreEnvironment() and, unlike the compose runtimes, never runs entrypoint.sh (which already prepends $HOME/.local/bin). As a result a copilot binary installed rootless to ~/.local/bin (via install_copilot_cli.sh --rootless on ARC/DinD runners) was mounted into the VM but not resolvable by name. Prepend ${HOME}/.local/bin to the core PATH so all three runtimes (Docker, gVisor, sbx) can resolve rootless-installed binaries. Harmless for the compose runtimes, which rebuild PATH in entrypoint.sh anyway. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f534fc19-5261-4351-b4c7-78e205dc07a9 * fix: make rootless copilot resolution robust in sbx Address review feedback on the sbx PATH approach: 1. Login shell resets PATH: execInSandbox runs `bash -lc`, and Debian/ Ubuntu /etc/profile unconditionally resets PATH, discarding anything injected via `--env PATH=...`. Revert the ineffective core PATH change and instead prepend the export inside the command string (withLocalBinOnPath), which runs after login initialization and cannot be clobbered by the profile. 2. Sudo home mismatch: createSandbox mounted home tool dirs from process.env.HOME while buildCoreEnvironment injects HOME from getRealUserHome() (honors SUDO_USER). Under sudo these diverge (e.g. /root vs /home/alice), so .local was mounted where the guest $HOME never points. Resolve the mount home via getRealUserHome() too. The injected command uses $HOME at runtime, which equals the injected HOME, so it targets the same tree that is now mounted. Tests cover the actual sbx invocation path (execInSandbox arg wrapping) and the helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f534fc19-5261-4351-b4c7-78e205dc07a9 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 42165d7 commit 5a37265

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

src/sbx-manager.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
removeSandbox,
66
restoreHomeCredentials,
77
sanitizeEnvForSbx,
8+
withLocalBinOnPath,
89
SBX_DEFAULT_NAME,
910
} from './sbx-manager';
1011
import * as fs from 'fs';
@@ -547,6 +548,24 @@ describe('sbx-manager', () => {
547548
expect(callOptions.timeout).toBe(5 * 60 * 1000);
548549
});
549550

551+
it('wraps the command so ~/.local/bin is on PATH after login init', async () => {
552+
mockExecaFn.mockResolvedValueOnce({ exitCode: 0 });
553+
554+
await execInSandbox('awf-agent-test', 'copilot --version');
555+
556+
const args: string[] = mockExecaFn.mock.calls[0][1];
557+
// The command runs via a login shell (`bash -lc`) whose /etc/profile can
558+
// reset PATH, so the export must be embedded in the command string.
559+
expect(args).toContain('-lc');
560+
const shellCommand = args[args.length - 1];
561+
expect(shellCommand).toBe(
562+
'export PATH="$HOME/.local/bin${PATH:+:$PATH}"; copilot --version',
563+
);
564+
expect(shellCommand.indexOf('.local/bin')).toBeLessThan(
565+
shellCommand.indexOf('copilot --version'),
566+
);
567+
});
568+
550569
it('does not set timeout when timeoutMinutes is not specified', async () => {
551570
mockExecaFn.mockResolvedValueOnce({ exitCode: 0 });
552571

@@ -557,6 +576,25 @@ describe('sbx-manager', () => {
557576
});
558577
});
559578

579+
describe('withLocalBinOnPath', () => {
580+
it('prepends ~/.local/bin using the runtime $HOME', () => {
581+
expect(withLocalBinOnPath('copilot')).toBe(
582+
'export PATH="$HOME/.local/bin${PATH:+:$PATH}"; copilot',
583+
);
584+
});
585+
586+
it('guards against an empty PATH producing a trailing colon', () => {
587+
// ${PATH:+:$PATH} appends the existing PATH only when it is non-empty, so
588+
// no empty element (which the shell treats as the cwd) is introduced.
589+
expect(withLocalBinOnPath('x')).toContain('${PATH:+:$PATH}');
590+
});
591+
592+
it('preserves the original command verbatim', () => {
593+
const cmd = 'foo && bar | baz > out.txt';
594+
expect(withLocalBinOnPath(cmd).endsWith(`; ${cmd}`)).toBe(true);
595+
});
596+
});
597+
560598
describe('removeSandbox', () => {
561599
it('warns when sbx rm exits non-zero', async () => {
562600
mockExecaFn

src/sbx-manager.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { copyEnvEntries } from './env-utils';
2929
import { logger } from './logger';
3030
import { HOME_TOOL_SUBDIRS } from './services/agent-volumes/home-whitelist';
3131
import { credentialEntriesUnderMountedParents } from './config/mount-policy';
32+
import { getRealUserHome } from './host-identity';
3233

3334
/** Name prefix for AWF-managed sandboxes. */
3435
const SBX_NAME_PREFIX = 'awf-agent';
@@ -271,7 +272,12 @@ export async function createSandbox(config: SbxConfig): Promise<string> {
271272
// Those specific paths are moved aside on the host BEFORE `sbx create` (see
272273
// scrubHomeCredentials below) and restored after teardown, so the benign tool
273274
// state stays available while the secrets never enter the microVM.
274-
const homePath = process.env.HOME || '/home/runner';
275+
// Resolve the home the SAME way buildCoreEnvironment() does (getRealUserHome,
276+
// which honors SUDO_USER), so the wholesale-mounted tool dirs land at exactly
277+
// the $HOME the agent sees inside the VM. Using process.env.HOME here would
278+
// diverge under sudo (e.g. /root vs /home/alice), mounting .local at a path
279+
// the guest's $HOME never points at and hiding a rootless-installed binary.
280+
const homePath = getRealUserHome();
275281
for (const subdir of HOME_TOOL_SUBDIRS) {
276282
const hostSubdir = `${homePath}/${subdir}`;
277283
if (seenPaths.has(hostSubdir)) continue;
@@ -340,6 +346,20 @@ export async function createSandbox(config: SbxConfig): Promise<string> {
340346
return name;
341347
}
342348

349+
/**
350+
* Wraps an agent command so the rootless install dir (~/.local/bin) is on PATH
351+
* when it runs. sbx executes commands via a login shell (`bash -lc`), which
352+
* sources /etc/profile — and on Debian/Ubuntu that unconditionally resets PATH,
353+
* discarding anything injected via `--env PATH=...`. Prepending the export to
354+
* the command itself runs AFTER login initialization, so a copilot binary
355+
* installed rootless to ~/.local/bin (install_copilot_cli.sh --rootless) stays
356+
* resolvable by name. `$HOME` resolves to the injected HOME (getRealUserHome),
357+
* which matches the wholesale-mounted home tool dirs.
358+
*/
359+
export function withLocalBinOnPath(command: string): string {
360+
return `export PATH="$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`;
361+
}
362+
343363
/**
344364
* Executes a command inside the sandbox, streaming stdout/stderr.
345365
* Returns the exit code of the command.
@@ -364,7 +384,7 @@ export async function execInSandbox(
364384
}
365385
}
366386

367-
args.push(name, 'bash', '-lc', command);
387+
args.push(name, 'bash', '-lc', withLocalBinOnPath(command));
368388

369389
try {
370390
const result = await execa('sbx', args, {

0 commit comments

Comments
 (0)