Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/sbx-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
removeSandbox,
restoreHomeCredentials,
sanitizeEnvForSbx,
withLocalBinOnPath,
SBX_DEFAULT_NAME,
} from './sbx-manager';
import * as fs from 'fs';
Expand Down Expand Up @@ -547,6 +548,24 @@ describe('sbx-manager', () => {
expect(callOptions.timeout).toBe(5 * 60 * 1000);
});

it('wraps the command so ~/.local/bin is on PATH after login init', async () => {
mockExecaFn.mockResolvedValueOnce({ exitCode: 0 });

await execInSandbox('awf-agent-test', 'copilot --version');

const args: string[] = mockExecaFn.mock.calls[0][1];
// The command runs via a login shell (`bash -lc`) whose /etc/profile can
// reset PATH, so the export must be embedded in the command string.
expect(args).toContain('-lc');
const shellCommand = args[args.length - 1];
expect(shellCommand).toBe(
'export PATH="$HOME/.local/bin${PATH:+:$PATH}"; copilot --version',
);
expect(shellCommand.indexOf('.local/bin')).toBeLessThan(
shellCommand.indexOf('copilot --version'),
);
});

it('does not set timeout when timeoutMinutes is not specified', async () => {
mockExecaFn.mockResolvedValueOnce({ exitCode: 0 });

Expand All @@ -557,6 +576,25 @@ describe('sbx-manager', () => {
});
});

describe('withLocalBinOnPath', () => {
it('prepends ~/.local/bin using the runtime $HOME', () => {
expect(withLocalBinOnPath('copilot')).toBe(
'export PATH="$HOME/.local/bin${PATH:+:$PATH}"; copilot',
);
});

it('guards against an empty PATH producing a trailing colon', () => {
// ${PATH:+:$PATH} appends the existing PATH only when it is non-empty, so
// no empty element (which the shell treats as the cwd) is introduced.
expect(withLocalBinOnPath('x')).toContain('${PATH:+:$PATH}');
});

it('preserves the original command verbatim', () => {
const cmd = 'foo && bar | baz > out.txt';
expect(withLocalBinOnPath(cmd).endsWith(`; ${cmd}`)).toBe(true);
});
});

describe('removeSandbox', () => {
it('warns when sbx rm exits non-zero', async () => {
mockExecaFn
Expand Down
24 changes: 22 additions & 2 deletions src/sbx-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { copyEnvEntries } from './env-utils';
import { logger } from './logger';
import { HOME_TOOL_SUBDIRS } from './services/agent-volumes/home-whitelist';
import { credentialEntriesUnderMountedParents } from './config/mount-policy';
import { getRealUserHome } from './host-identity';

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

/**
* Wraps an agent command so the rootless install dir (~/.local/bin) is on PATH
* when it runs. sbx executes commands via a login shell (`bash -lc`), which
* sources /etc/profile — and on Debian/Ubuntu that unconditionally resets PATH,
* discarding anything injected via `--env PATH=...`. Prepending the export to
* the command itself runs AFTER login initialization, so a copilot binary
* installed rootless to ~/.local/bin (install_copilot_cli.sh --rootless) stays
* resolvable by name. `$HOME` resolves to the injected HOME (getRealUserHome),
* which matches the wholesale-mounted home tool dirs.
*/
export function withLocalBinOnPath(command: string): string {
return `export PATH="$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`;
}

/**
* Executes a command inside the sandbox, streaming stdout/stderr.
* Returns the exit code of the command.
Expand All @@ -364,7 +384,7 @@ export async function execInSandbox(
}
}

args.push(name, 'bash', '-lc', command);
args.push(name, 'bash', '-lc', withLocalBinOnPath(command));

try {
const result = await execa('sbx', args, {
Expand Down
Loading