Skip to content
This repository was archived by the owner on Oct 5, 2026. It is now read-only.

Commit ecf9b18

Browse files
authored
fix(runtime): isolate CLI configuration by conversation (#26)
1 parent daab9b2 commit ecf9b18

13 files changed

Lines changed: 373 additions & 29 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
npm ci --ignore-scripts --prefix image/claude-agent-acp
3535
node image/claude-agent-acp/patch-adapter.mjs image/claude-agent-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js
3636
node --check image/claude-agent-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js
37-
node --test test/claude-turn-completion-smoke.mjs test/claude-session-state-smoke.mjs
37+
node --test test/claude-turn-completion-smoke.mjs test/claude-session-state-smoke.mjs test/claude-session-home-smoke.mjs
3838
3939
image:
4040
name: Runtime image (${{ matrix.provider }})

‎.github/workflows/update-claude-sdk.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
npm ci --ignore-scripts --prefix image/claude-agent-acp
4343
node image/claude-agent-acp/patch-adapter.mjs image/claude-agent-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js
4444
node --check image/claude-agent-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js
45-
node --test test/claude-turn-completion-smoke.mjs test/claude-session-state-smoke.mjs
45+
node --test test/claude-turn-completion-smoke.mjs test/claude-session-state-smoke.mjs test/claude-session-home-smoke.mjs
4646
- name: Set up Buildx
4747
if: steps.update.outputs.changed == 'true'
4848
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,24 @@ filesystem (an agent working for one team can read and change files another team
286286
agent wrote), one provider sign-in and account, and the same installed tools. Connect
287287
it only to teams that may share them.
288288

289+
Nuphos conversations have separate CLI homes under
290+
`~/.nuphos/session-homes/<sha256-conversation-id>`. Both adapters give shell tools
291+
that conversation's `HOME`, XDG directories, and explicit config paths for gcloud,
292+
gh, AWS, Kubernetes, Azure, Docker, Git, npm and GPG. The home persists across
293+
new/load/resume and token refresh; existing runtime-global credentials are never
294+
copied into it. Re-run credential setup inside the conversation after upgrading.
295+
The agent provider's own login remains runtime-owned so this does not require a
296+
new Claude/Codex login. Desktop bundles use the same adapter behavior.
297+
298+
This prevents accidental configuration collisions, not access by another process
299+
running as the same OS user. Shared workspaces, installed tools, provider settings
300+
and OS credential stores remain shared. Skills should respect the session paths;
301+
in particular, GitHub installation tokens must be stored in the session's gh
302+
config directly, not through `gh auth login` (even `--insecure-storage` can touch a shared OS keychain). Changing runtime-global
303+
settings is possible but strongly discouraged unless the user understands the
304+
impact on other sessions and explicitly requests that scope. Use separate runtimes
305+
when a filesystem security boundary is required.
306+
289307
Everything else a Nuphos-provisioned pod has always had is already in the image,
290308
so the agent reaches Nuphos' own tools as soon as it is connected — the container
291309
only has to be able to resolve and reach the backend they are served from. The

‎image/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ RUN chmod -R a+rX /opt/nuphos-runtime \
4545

4646
COPY --chmod=755 sync-skills.sh /usr/local/bin/nuphos-sync-skills
4747
COPY --chmod=755 seed-codex-auth.sh /usr/local/bin/nuphos-seed-codex-auth
48-
COPY runtime-defaults.mjs mcp-bridge-config.mjs /opt/
48+
COPY runtime-defaults.mjs mcp-bridge-config.mjs session-home.mjs /opt/
4949
# Sourced via BASH_ENV, so it is read, never executed.
5050
COPY --chmod=644 runtime-guard.sh /opt/nuphos-runtime/runtime-guard.sh
5151
COPY codex-login.mjs claude-login.mjs mcp-http-bridge.mjs panel-job.mjs /opt/nuphos-runtime/

‎image/claude-agent-acp/patch-adapter.mjs‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,35 @@ if (
1414
throw new Error('Claude ACP bundle changed; review the Nuphos adapter patches.')
1515
const mcpAnchor = ' for (const server of params.mcpServers) {'
1616
if (source.split(mcpAnchor).length !== 2) throw new Error('Expected one Claude MCP server handoff.')
17-
const patched = patchClaudeSessionState(patchClaudeTurnCompletion(source)).replace(
17+
let patched = patchClaudeSessionState(patchClaudeTurnCompletion(source)).replace(
1818
mcpAnchor,
1919
' for (const server of nuphosBridgeMcpServers(params.mcpServers)) {',
2020
)
21+
const envStart =
22+
' const env = {\n ...process.env,\n ...userProvidedOptions?.env,'
23+
const envEnd = ' CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",\n };'
24+
if (patched.split(envStart).length !== 2 || patched.split(envEnd).length !== 2)
25+
throw new Error('Expected one Claude SDK session environment handoff.')
26+
patched = patched
27+
.replace(envStart, envStart.replace('const env = {', 'const env = nuphosClaudeSessionEnv({'))
28+
.replace(
29+
envEnd,
30+
envEnd.replace(
31+
'};',
32+
`}, userProvidedOptions?.env);
33+
// Settings env overrides the child environment in Claude Code. Pin the
34+
// session paths at the programmatic tier too, just like provider routing.
35+
if (env.NUPHOS_SESSION_HOME) {
36+
const sessionSettings = typeof settings === "string"
37+
? JSON.parse(await fs.readFile(path.resolve(params.cwd, settings), "utf8"))
38+
: settings;
39+
settings = { ...sessionSettings, env: {
40+
...sessionSettings?.env,
41+
...nuphosSessionHomeEnv(userProvidedOptions?.env),
42+
} };
43+
}`,
44+
),
45+
)
46+
const sessionHome = readFileSync(new URL('../session-home.mjs', import.meta.url), 'utf8')
2147
const helper = readFileSync(new URL('../mcp-bridge-config.mjs', import.meta.url), 'utf8')
22-
writeFileSync(target, patchRuntimeDefaults(`${helper}\n${patched}`))
48+
writeFileSync(target, patchRuntimeDefaults(`${sessionHome}\n${helper}\n${patched}`))

‎image/codex-acp/patch-adapter.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { fileURLToPath } from 'node:url'
88
import { patchRuntimeDefaults } from '../runtime-defaults.mjs'
99

1010
export function patchAdapter(source, helper) {
11+
const sessionHome = readFileSync(new URL('../session-home.mjs', import.meta.url), 'utf8')
12+
helper = helper.replace("import { nuphosSessionHomeEnv } from '../session-home.mjs'", '')
1113
const digest = createHash('sha256').update(source).digest('hex')
1214
if (digest !== '7534a0ad3cc4c9affd0b2da5007fa53ea0f1d6fcd71b2c5ef202e2056a976a97')
1315
throw new Error(
@@ -54,7 +56,10 @@ export function patchAdapter(source, helper) {
5456
'var FAST_MODE_DESCRIPTION = "1.5x speed, increased usage";',
5557
'var FAST_MODE_DESCRIPTION = "Faster responses, increased usage";',
5658
)
57-
.replace('// src/index.ts', `${helper}\n${steering}\n${mcpBridge}\n// src/index.ts`),
59+
.replace(
60+
'// src/index.ts',
61+
`${sessionHome}\n${helper}\n${steering}\n${mcpBridge}\n// src/index.ts`,
62+
),
5863
)
5964
}
6065

‎image/codex-acp/session-config.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { nuphosSessionHomeEnv } from '../session-home.mjs'
2+
13
// Applied to thread/start and thread/resume on every ACP new/load/resume.
24
// Never change process.env: concurrent conversations have different principals.
35
export function nuphosCodexSessionConfig(config, meta, processEnv = process.env) {
@@ -48,7 +50,10 @@ export function nuphosCodexSessionConfig(config, meta, processEnv = process.env)
4850
...config,
4951
developer_instructions:
5052
typeof context.developerInstructions === 'string' ? context.developerInstructions : '',
51-
shell_environment_policy: { inherit: 'none', set: { ...baseline, ...env } },
53+
shell_environment_policy: {
54+
inherit: 'none',
55+
set: { ...baseline, ...env, ...nuphosSessionHomeEnv(env, processEnv) },
56+
},
5257
mcp_servers: Object.fromEntries(
5358
Object.entries(config.mcp_servers ?? {}).map(([name, server]) => [
5459
name,

‎image/session-home.mjs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { createHash as nuphosHomeHash } from 'node:crypto'
2+
import { mkdirSync as nuphosMkdir, lstatSync as nuphosLstat } from 'node:fs'
3+
import { homedir as nuphosHostHome } from 'node:os'
4+
import { join as nuphosJoin, delimiter as nuphosPathDelimiter } from 'node:path'
5+
6+
// Configuration isolation, not a filesystem security boundary: sessions still
7+
// run as the same OS user. Never mutate process.env or copy ambient credentials.
8+
// Use the conversation ID, not the ACP process/turn ID, so load/resume and token
9+
// refresh keep the same home. Hash it so metadata cannot supply a filesystem path.
10+
export function nuphosSessionHomeEnv(sessionEnv = {}, runtimeEnv = process.env) {
11+
const id = sessionEnv.NUPHOS_SESSION_ID
12+
if (typeof id !== 'string' || !id.trim()) {
13+
if (sessionEnv.NUPHOS_TOKEN || sessionEnv.NUPHOS_TEAM_ID)
14+
throw new Error('Nuphos sessions require NUPHOS_SESSION_ID for CLI configuration isolation')
15+
return {} // Ordinary ACP clients without a Nuphos conversation keep their defaults.
16+
}
17+
const runtimeHome = runtimeEnv.HOME || runtimeEnv.USERPROFILE || nuphosHostHome()
18+
const root = nuphosJoin(runtimeHome, '.nuphos', 'session-homes')
19+
const home = nuphosJoin(root, nuphosHomeHash('sha256').update(id).digest('hex'))
20+
for (const dir of [root, home]) {
21+
nuphosMkdir(dir, { recursive: true, mode: 0o700 })
22+
if (!nuphosLstat(dir).isDirectory()) throw new Error('Session home must be a directory')
23+
}
24+
const config = nuphosJoin(home, '.config')
25+
return {
26+
HOME: home,
27+
USERPROFILE: home,
28+
APPDATA: nuphosJoin(home, 'AppData', 'Roaming'),
29+
LOCALAPPDATA: nuphosJoin(home, 'AppData', 'Local'),
30+
XDG_CONFIG_HOME: config,
31+
XDG_CACHE_HOME: nuphosJoin(home, '.cache'),
32+
XDG_DATA_HOME: nuphosJoin(home, '.local', 'share'),
33+
XDG_STATE_HOME: nuphosJoin(home, '.local', 'state'),
34+
CLOUDSDK_CONFIG: nuphosJoin(config, 'gcloud'),
35+
GH_CONFIG_DIR: nuphosJoin(config, 'gh'),
36+
AWS_SHARED_CREDENTIALS_FILE: nuphosJoin(home, '.aws', 'credentials'),
37+
AWS_CONFIG_FILE: nuphosJoin(home, '.aws', 'config'),
38+
KUBECONFIG: nuphosJoin(home, '.kube', 'config'),
39+
AZURE_CONFIG_DIR: nuphosJoin(home, '.azure'),
40+
DOCKER_CONFIG: nuphosJoin(home, '.docker'),
41+
GIT_CONFIG_GLOBAL: nuphosJoin(home, '.gitconfig'),
42+
GIT_CONFIG_NOSYSTEM: '1',
43+
NPM_CONFIG_USERCONFIG: nuphosJoin(home, '.npmrc'),
44+
GNUPGHOME: nuphosJoin(home, '.gnupg'),
45+
PATH: [nuphosJoin(home, '.local', 'bin'), runtimeEnv.PATH]
46+
.filter(Boolean)
47+
.join(nuphosPathDelimiter),
48+
NUPHOS_SESSION_HOME: home,
49+
}
50+
}
51+
52+
// Claude's own login/session store belongs to the runtime. Pin its location
53+
// before giving the SDK child (and its shell tools) the conversation's HOME.
54+
export function nuphosClaudeSessionEnv(env, sessionEnv, runtimeEnv = process.env) {
55+
const isolated = nuphosSessionHomeEnv(sessionEnv, runtimeEnv)
56+
if (!isolated.HOME) return env
57+
return {
58+
...env,
59+
CLAUDE_CONFIG_DIR:
60+
runtimeEnv.CLAUDE_CONFIG_DIR ||
61+
nuphosJoin(runtimeEnv.HOME || runtimeEnv.USERPROFILE || nuphosHostHome(), '.claude'),
62+
...isolated,
63+
}
64+
}

‎test/claude-session-home-smoke.mjs‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Exercise the pinned, patched adapter through new/load/resume up to its real
2+
// SDK query handoff. Stop immediately before query() so no model or login is used.
3+
import assert from 'node:assert/strict'
4+
import { execFileSync } from 'node:child_process'
5+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
6+
import { tmpdir } from 'node:os'
7+
import { join } from 'node:path'
8+
import test from 'node:test'
9+
10+
test('Claude new/load/resume isolate SDK and settings env while retaining runtime login', (t) => {
11+
const target = new URL(
12+
'../image/claude-agent-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js',
13+
import.meta.url,
14+
)
15+
const instrumented = new URL(`./nuphos-home-smoke-${process.pid}.js`, target)
16+
const source = readFileSync(target, 'utf8')
17+
const anchor = ' const q = query({'
18+
assert.equal(source.split(anchor).length, 2)
19+
writeFileSync(
20+
instrumented,
21+
source.replace(
22+
anchor,
23+
` throw Object.assign(new Error('captured-sdk-options'), { capturedOptions: options });\n${anchor}`,
24+
),
25+
)
26+
const home = mkdtempSync(join(tmpdir(), 'nuphos-claude-home-smoke-'))
27+
t.after(() => {
28+
rmSync(instrumented)
29+
rmSync(home, { recursive: true, force: true })
30+
})
31+
const result = execFileSync(
32+
process.execPath,
33+
[
34+
'--input-type=module',
35+
'-e',
36+
`
37+
import { ClaudeAcpAgent } from ${JSON.stringify(instrumented.href)};
38+
const agent = new ClaudeAcpAgent({ sessionUpdate: async () => {} }, { log() {}, error() {} });
39+
const captures = [];
40+
for (const [method, id] of [['newSession', 'a'], ['newSession', 'b'], ['loadSession', 'a'], ['resumeSession', 'a']]) {
41+
try {
42+
await agent[method]({ cwd: process.env.HOME, sessionId: 'native-session', mcpServers: [],
43+
_meta: { claudeCode: { options: {
44+
env: { NUPHOS_SESSION_ID: id, NUPHOS_TOKEN: 'synthetic' },
45+
settings: { env: { HOME: '/ambient-override', GH_CONFIG_DIR: '/shared-gh' } },
46+
} } } });
47+
throw new Error('SDK handoff not reached');
48+
} catch (error) {
49+
if (!error.capturedOptions) throw error;
50+
const { env, settings } = error.capturedOptions;
51+
captures.push({ home: env.HOME, gh: env.GH_CONFIG_DIR, login: env.CLAUDE_CONFIG_DIR,
52+
settingsHome: settings.env.HOME, settingsGh: settings.env.GH_CONFIG_DIR });
53+
}
54+
}
55+
console.log(JSON.stringify(captures));
56+
process.exit(0);
57+
`,
58+
],
59+
{
60+
env: { PATH: process.env.PATH, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude') },
61+
encoding: 'utf8',
62+
timeout: 30000,
63+
},
64+
)
65+
const [a, b, loaded, resumed] = JSON.parse(result.trim())
66+
assert.notEqual(a.home, home)
67+
assert.notEqual(a.home, b.home)
68+
assert.deepEqual(loaded, a)
69+
assert.deepEqual(resumed, a)
70+
for (const capture of [a, b]) {
71+
assert.equal(capture.settingsHome, capture.home)
72+
assert.equal(capture.settingsGh, capture.gh)
73+
assert.equal(capture.login, join(home, '.claude'))
74+
}
75+
})

‎test/codex-acp-smoke.mjs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ const source = process.argv[2]
1515
if (!source)
1616
throw new Error('Usage: node test/codex-acp-smoke.mjs <unpatched codex-acp dist/index.js>')
1717
const fromAdapter = createRequire(resolve(source))
18-
const fromRuntime = createRequire(
19-
new URL('../image/codex-acp/package.json', import.meta.url),
20-
)
18+
const fromRuntime = createRequire(new URL('../image/codex-acp/package.json', import.meta.url))
2119

2220
assert.equal(
2321
fromAdapter.resolve('@openai/codex/bin/codex.js'),
@@ -95,7 +93,7 @@ try {
9593
_meta: {
9694
'ai.nuphos/codex': {
9795
developerInstructions: `context ${actor}`,
98-
env: { NUPHOS_TOKEN: actor },
96+
env: { NUPHOS_TOKEN: actor, NUPHOS_SESSION_ID: 'conversation-smoke' },
9997
},
10098
},
10199
})
@@ -132,6 +130,11 @@ try {
132130
const start = calls.find((c) => c.method === 'thread/start')
133131
const resume = calls.find((c) => c.method === 'thread/resume')
134132

133+
assert.notEqual(start.params.config.shell_environment_policy.set.HOME, base)
134+
assert.equal(
135+
start.params.config.shell_environment_policy.set.HOME,
136+
resume.params.config.shell_environment_policy.set.HOME,
137+
)
135138
assert.equal(start.params.config.developer_instructions, 'context actor-a')
136139
assert.equal(start.params.config.shell_environment_policy.set.NUPHOS_TOKEN, 'actor-a')
137140
assert.equal(resume.params.config.developer_instructions, 'context actor-b')

0 commit comments

Comments
 (0)