Skip to content
Merged
38 changes: 31 additions & 7 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,37 @@ In container mode, these paths are always mounted into the container:

- Your project working directory (read-write)
- The system temp directory (read-write)
- The LLxprt Code settings directory (read-write). This directory holds your
profiles (`profiles/*.json`) and a global `.env`; a profile containing an
inline `auth-key`, or that `.env`, is therefore readable from inside the
container. Prefer `/key save` over inline profile keys β€” see issue
- The LLxprt Code global **configuration** directory (read-write). This is your
platform-standard config directory (the same one resolved by
`Storage.getGlobalConfigDir()`, e.g. `~/.config/llxprt-code` on Linux or
`~/Library/Preferences/llxprt-code` on macOS). It is mounted at the equivalent
path inside the container (on Windows hosts the mount destination is the
translated POSIX form, e.g. `C:\Users\me\...` becomes `/c/Users/me/...`), and
`LLXPRT_CONFIG_HOME` is pinned to that destination so the in-container CLI
reads your `settings.json`, profiles, subagents, prompts, commands, policies,
hooks, global `LLXPRT.md`, `welcomeConfig.json`, `trustedFolders.json` and
user `skills/`. This directory holds a global `.env` and profiles
(`profiles/*.json`); a profile containing an inline `auth-key`, or that
`.env`, is therefore readable from inside the container. Prefer `/key save`
over inline profile keys β€” see issue
[#2957](https://github.com/vybestack/llxprt-code/issues/2957).
- Git configuration files, mounted read-only (see
[Git config passthrough](#git-config-passthrough))

> **What is _not_ mounted:** only the configuration directory crosses the
> boundary. The **data** directory (OAuth credentials, provider accounts,
> installation id, conversations, history, todos), the **cache** directory, and
> the **log/state** directory are _container-local and ephemeral_: they exist
> only for the lifetime of a sandboxed session and are discarded when the
> container exits. Anything written to them during a sandboxed session β€” including
> freshly minted credentials β€” is lost on exit. Their container paths are pinned
> with `LLXPRT_DATA_HOME`, `LLXPRT_CACHE_HOME` and `LLXPRT_LOG_HOME` from the
> container's _real_ HOME inside the sandbox entrypoint (so they follow the
> sandbox image's default user home, including custom images), keeping them
> separate from the mounted config directory. This is why you may need to
> re-authenticate inside a sandbox: the credential proxy (`LLXPRT_CREDENTIAL_SOCKET`)
> is the supported way to reach host secrets without bind-mounting credential files.

Additional paths are conditionally mounted based on your host environment and
profile configuration:

Expand Down Expand Up @@ -872,11 +895,12 @@ the container, removing a mandatory-access-control boundary. Use it only to
diagnose a SELinux denial, then remove it so label enforcement is restored.

**SSH not working in Podman on macOS** β€” use a stable socket path. The default
launchd socket paths are unreliable. Set up a dedicated socket:
launchd socket paths are unreliable. Set up a dedicated socket under a
non-legacy location (such as `~/.ssh/` or `$XDG_RUNTIME_DIR`):

```bash
ssh-agent -a ~/.llxprt/ssh-agent.sock
export SSH_AUTH_SOCK=~/.llxprt/ssh-agent.sock
ssh-agent -a ~/.ssh/ssh-agent.sock
export SSH_AUTH_SOCK=~/.ssh/ssh-agent.sock
ssh-add ~/.ssh/id_ed25519
llxprt --sandbox-engine podman --sandbox-profile-load dev
```
Expand Down
7 changes: 4 additions & 3 deletions docs/tutorials/sandbox-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,12 @@ If keys appear, SSH passthrough is working.

### Podman on macOS: reliable socket setup

If forwarding fails with launchd socket paths, switch to a dedicated socket:
If forwarding fails with launchd socket paths, switch to a dedicated socket
under a non-legacy location (such as `~/.ssh/` or `$XDG_RUNTIME_DIR`):

```bash
ssh-agent -a ~/.llxprt/ssh-agent.sock
export SSH_AUTH_SOCK=~/.llxprt/ssh-agent.sock
ssh-agent -a ~/.ssh/ssh-agent.sock
export SSH_AUTH_SOCK=~/.ssh/ssh-agent.sock
ssh-add ~/.ssh/id_ed25519
llxprt --sandbox-engine podman --sandbox-profile-load dev
```
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/config/pathMigration.profileRepair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isMigrationComplete,
repairProfiles,
runStartupMigrationWithPath,
MIGRATION_MARKER_VERSION,
type MigrationDestinations,
} from './pathMigration.js';
import {
Expand Down Expand Up @@ -530,7 +531,7 @@ describe('runStartupMigrationWithPath β€” marker orchestration', () => {
fs.mkdirSync(env.destinations.dataDir, { recursive: true });
fs.writeFileSync(
path.join(env.destinations.dataDir, '.migration-complete.json'),
JSON.stringify({ version: 1 }),
JSON.stringify({ version: MIGRATION_MARKER_VERSION }),
);
setupRepairCase(env, corruptCanonicalProfile(), validLegacyProfile());
const result = runStartupMigrationWithPath(env.legacyDir, env.destinations);
Expand Down Expand Up @@ -701,7 +702,7 @@ describe('runStartupMigrationWithPath β€” logging', () => {
fs.mkdirSync(env.destinations.dataDir, { recursive: true });
fs.writeFileSync(
path.join(env.destinations.dataDir, '.migration-complete.json'),
JSON.stringify({ version: 1 }),
JSON.stringify({ version: MIGRATION_MARKER_VERSION }),
);
setupRepairCase(env, corruptCanonicalProfile(), validLegacyProfile());
const writes: string[] = [];
Expand All @@ -727,7 +728,7 @@ describe('runStartupMigrationWithPath β€” logging', () => {
fs.mkdirSync(env.destinations.dataDir, { recursive: true });
fs.writeFileSync(
path.join(env.destinations.dataDir, '.migration-complete.json'),
JSON.stringify({ version: 1 }),
JSON.stringify({ version: MIGRATION_MARKER_VERSION }),
);
setupRepairCase(env, corruptCanonicalProfile(), validLegacyProfile());
const result = runStartupMigrationWithPath(env.legacyDir, env.destinations);
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/config/pathMigration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
performMigration,
isMigrationComplete,
markMigrationComplete,
MIGRATION_MARKER_VERSION,
type MigrationDestinations,
type MigrationResult,
} from './pathMigration.js';
Expand Down Expand Up @@ -212,7 +213,10 @@ describe('migration-completion marker (#2237)', () => {
);

it.each([
{ name: 'current', marker: JSON.stringify({ version: 1 }) },
{
name: 'current',
marker: JSON.stringify({ version: MIGRATION_MARKER_VERSION }),
},
{ name: 'newer', marker: JSON.stringify({ version: 999 }) },
])(
'treats a $name marker as complete without a rerun diagnostic',
Expand Down
24 changes: 22 additions & 2 deletions packages/cli/src/config/pathMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ const CONFIG_ENTRIES = new Set([
'.env',
'LLXPRT.md',
'.LLXPRT_SYSTEM',
// Issue #3081: these are read from the CONFIG directory but were missing
// from this set, so the migration categorizer routed them to DATA instead.
// welcomeConfig.json β€” getWelcomeConfigPath (USER_SETTINGS_DIR)
// trustedFolders.json β€” getTrustedFoldersPath (USER_SETTINGS_DIR)
// skills β€” Storage.getUserSkillsDir() = <configDir>/skills
'welcomeConfig.json',
'trustedFolders.json',
'skills',
]);

const DATA_ENTRIES = new Set([
Expand Down Expand Up @@ -78,7 +86,7 @@ const TMP_SKILLS_DIR = 'skills';

const MIGRATION_MARKER_FILE = '.migration-complete.json';

const MIGRATION_MARKER_VERSION = 1;
export const MIGRATION_MARKER_VERSION = 1;

const REPAIR_MARKER_FILE = '.profile-repair-complete.json';

Expand Down Expand Up @@ -309,8 +317,20 @@ function copyLegacyEntries(
visited: Set<string>,
errors: string[],
): number {
// Process entries in a deterministic, name-sorted order. Copy semantics are
// no-overwrite (COPYFILE_EXCL), so for a same-named collision the FIRST
// source to publish wins. Sorting guarantees the top-level `skills/` config
// entry is copied before the `tmp/` directory (whose `migrateTmpDir` routes
// `tmp/skills/` to the same `<config>/skills` destination), so an explicit
// precedence is defined: top-level `skills/` wins over `tmp/skills/`
// (#3081). Investigation: historically skills lived ONLY under
// `tmp/skills/` (a documented misplacement β€” skills are user configuration,
// not temporary state); no top-level legacy skills directory existed. The
// top-level `skills` CONFIG_ENTRIES entry is still correct because the
// canonical reader is `Storage.getUserSkillsDir()` = `<config>/skills`.
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
let filesCopied = 0;
for (const entry of entries) {
for (const entry of sorted) {
const category = categorizeEntry(entry.name);
if (category === 'exclude') {
continue;
Expand Down
Loading
Loading