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
4 changes: 2 additions & 2 deletions server/lib/README.md

Large diffs are not rendered by default.

66 changes: 48 additions & 18 deletions server/lib/cliChildEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@

import { withSpawnCwdEnv } from './spawnCwd.js';
import { buildOpencodeEnvVars } from './opencodeConfig.js';
import { getOpencodeLocalProviderNamespace, isClaudeCommand } from './providerModels.js';
import { isGatewayNamespace } from './providerGateways.js';
import {
localRuntimeNamespace,
isClaudeCommand,
parseOpencodeConfigContent,
opencodeConfigIsLocalOnly,
} from './providerModels.js';
import { isLocalInstanceEndpoint } from './localEndpoint.js';
import { agentGuardEnv } from './agentGuard/index.js';
import { buildSafeCliBaseEnv } from './processEnv.js';
import { isPublicReviewNoToolProfile, isPublicReviewRestrictedProfile } from './agentExecutionProfiles.js';
Expand All @@ -70,8 +75,7 @@ const CLAUDE_LOCAL_MAX_OUTPUT_TOKENS = '65536';
* `localRuntimeKind` makes.
*/
function isLocalBackedClaude(provider) {
const namespace = getOpencodeLocalProviderNamespace(provider);
return !!namespace && !isGatewayNamespace(namespace) && isClaudeCommand(provider?.command);
return !!localRuntimeNamespace(provider) && isClaudeCommand(provider?.command);
}

function claudeLocalEnvDefaults(provider) {
Expand Down Expand Up @@ -111,9 +115,11 @@ function claudeLocalEnvDefaults(provider) {
* per-call model — `provider.defaultModel` is always declared regardless.
* @param {object|null} [options.extra] - layered last, so it overrides every
* other layer including `provider.envVars` (TERM/COLORTERM for a PTY).
* @param {string|null} [options.safetyProfile] - a public-review execution
* profile, which hardens the OpenCode config (see `buildOpencodeEnvVars`).
* @returns {object} a fresh object holding only these layers
*/
export function composeProviderEnv({ before = null, provider = null, model = null, extra = null } = {}) {
export function composeProviderEnv({ before = null, provider = null, model = null, extra = null, safetyProfile = null } = {}) {
return {
...(before || {}),
...claudeLocalEnvDefaults(provider),
Expand All @@ -122,14 +128,17 @@ export function composeProviderEnv({ before = null, provider = null, model = nul
// local providers (an empty object for everyone else) so the injected
// namespaced `--model` isn't rejected as "not valid" — see #2190. It lands
// after provider.envVars to override the provider's STATIC
// OPENCODE_CONFIG_CONTENT, which it was built from.
...buildOpencodeEnvVars(provider, model),
// OPENCODE_CONFIG_CONTENT, which it was built from. `safetyProfile` also
// reaches it because OpenCode's tool posture lives in that config — see
// `hardenOpencodeConfigForNoTool`.
...buildOpencodeEnvVars(provider, model, { safetyProfile }),
...(extra || {}),
};
}

// Public contributor content is run through a no-tools local Claude wrapper.
// Keep only runtime essentials plus the local Anthropic-compatible endpoint;
// Public contributor content is run through a no-tools local harness — a Claude
// or an OpenCode wrapper pointed at a loopback daemon.
// Keep only runtime essentials plus the local model endpoint;
// in particular, never pass forge, cloud, SSH, auth, or arbitrary provider env
// vars into the child. This is a second boundary in addition to the CLI argv.
const PUBLIC_REVIEW_ENV_KEYS = new Set([
Expand All @@ -148,28 +157,49 @@ const PUBLIC_REVIEW_ENV_KEYS = new Set([
// disables the keychain — so without the token the CLI exits "Not logged in"
// before reading the prompt. Keep the credential only for a loopback base URL;
// against any other host it is a real cloud credential and stays stripped.
// `isLocalInstanceEndpoint` (localEndpoint.js) is the tree-wide answer to "is
// this endpoint on the machine PortOS runs on?" — the same predicate
// `localRuntimeForProvider` uses to decide a provider HAS a local daemon.
// Reused here rather than re-typed so a credential boundary cannot classify a
// host differently from the runtime resolver; it also counts the bind-all
// addresses (`0.0.0.0`, `::`) as local, which they are.
const LOCAL_ANTHROPIC_CREDENTIAL_KEYS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);

function localAnthropicCredentialEnv(env) {
let hostname;
try {
hostname = new URL(env?.ANTHROPIC_BASE_URL).hostname.toLowerCase();
} catch {
return {};
}
if (!LOOPBACK_HOSTNAMES.has(hostname) && !hostname.startsWith('127.')) return {};
if (!isLocalInstanceEndpoint(env?.ANTHROPIC_BASE_URL)) return {};
return Object.fromEntries(LOCAL_ANTHROPIC_CREDENTIAL_KEYS
.filter((key) => env[key] != null)
.map((key) => [key, env[key]]));
}

/**
* An OpenCode run carries its whole configuration — provider endpoint, declared
* models, and (under a `no-tool` profile) its entire tool posture — in
* `OPENCODE_CONFIG_CONTENT`, so stripping it does not harden the child, it just
* points it at the user's own `~/.config/opencode` instead. Keep it, on the same
* terms as the local Anthropic credential above: only when every endpoint it
* declares is loopback. A config naming a hosted gateway carries that gateway's
* API key, which is a real cloud credential and stays stripped — leaving an
* OpenCode wrapper front-ending a gateway ineligible for these stages, which is
* why `providerVendors.js` scopes the OpenCode recipe to local namespaces.
*/
function opencodeLocalConfigEnv(env) {
const raw = env?.OPENCODE_CONFIG_CONTENT;
// `requireDeclaration` marks this as the provenance-checking caller: a value
// declaring no endpoint is not a config PortOS built for an eligible provider,
// so it is dropped with every other inherited env var.
return opencodeConfigIsLocalOnly(parseOpencodeConfigContent(raw), { requireDeclaration: true })
? { OPENCODE_CONFIG_CONTENT: raw }
: {};
}

function allowlistEnv(env, keys) {
return {
...Object.fromEntries(Object.entries(env || {}).filter(([key, value]) => (
value != null && (keys.has(key) || key.startsWith('LC_'))
))),
...localAnthropicCredentialEnv(env),
...opencodeLocalConfigEnv(env),
};
}

Expand Down Expand Up @@ -235,7 +265,7 @@ export function buildCliChildEnv({
safetyProfile = null,
} = {}) {
const composed = withSpawnCwdEnv(
{ ...buildSafeCliBaseEnv(baseEnv, provider), ...composeProviderEnv({ before, provider, model, extra }) },
{ ...buildSafeCliBaseEnv(baseEnv, provider), ...composeProviderEnv({ before, provider, model, extra, safetyProfile }) },
cwd,
);

Expand Down
74 changes: 74 additions & 0 deletions server/lib/cliChildEnv.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { posixPath } from './testHelper.js';
import { buildCliChildEnv, buildPublicReviewCliEnv, composeProviderEnv } from './cliChildEnv.js';
import { PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, PUBLIC_REVIEW_EXECUTION_PROFILE } from './agentExecutionProfiles.js';
import { cliProviderAuthDescriptor } from './processEnv.js';
import { supportsPublicReviewProvider } from './providerVendors.js';
import { AGENT_GUARD_BIN } from './agentGuard/index.js';
import { collectServerSources, readServerSource } from './testHelper.js';
import { readFileSync } from 'node:fs';
Expand Down Expand Up @@ -178,6 +179,79 @@ describe('buildCliChildEnv — public-review profile', () => {
});
});

describe('buildCliChildEnv — public-review profile, OpenCode harness', () => {
// OpenCode is the natural way to drive a local Ollama model, and its whole
// tool posture lives in OPENCODE_CONFIG_CONTENT — stripping the variable does
// not harden the child, it points it back at the user's own
// ~/.config/opencode. It survives on the same loopback terms as the local
// Anthropic credential.
it('hardens the OpenCode config and carries it through the allowlist', () => {
const env = buildCliChildEnv({
baseEnv: { PATH: '/usr/bin', GH_TOKEN: 'ambient' },
provider: OLLAMA_OPENCODE,
model: 'qwen2.5:7b',
cwd: '/tmp/public-review',
safetyProfile: PUBLIC_REVIEW_EXECUTION_PROFILE,
});

// One posture marker is enough here — `opencodeConfig.test.js` owns the
// full matrix. What this test uniquely proves is that the hardened config
// survives the allowlist while the credentials beside it do not.
expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT).tools).toEqual({ '*': false });
expect(env).not.toHaveProperty('GH_TOKEN');
expect(env).not.toHaveProperty('API_KEY');
});

it('leaves the ordinary (non-public-review) OpenCode config tool-enabled', () => {
const env = buildCliChildEnv({ provider: OLLAMA_OPENCODE, model: 'qwen2.5:7b', cwd: '/tmp/work' });
const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT);
expect(config.permission).toBe('deny'); // the provider's stored value, untouched
expect(config.provider.ollama.models['qwen2.5:7b'].tool_call).toBe(true);
expect(config).not.toHaveProperty('tools');
});

// The load-bearing invariant: whatever the vendor row declares eligible for
// the gate must keep its hardened config through this allowlist. If the two
// sides ever disagree, the stage spawns with the config stripped — OpenCode
// then reads the user's own ~/.config/opencode, tools intact, while every
// signal still reports an enforced tool-free gate.
it('keeps the config for exactly the providers the vendor row makes eligible', () => {
const relocated = {
...OLLAMA_OPENCODE,
type: 'tui',
envVars: {
OPENCODE_CONFIG_CONTENT: JSON.stringify({
provider: { ollama: { options: { baseURL: 'http://192.0.2.10:11434/v1' } } },
}),
},
};
const eligible = { ...OLLAMA_OPENCODE, type: 'tui' };
for (const provider of [eligible, relocated]) {
const env = buildCliChildEnv({
provider,
model: 'qwen2.5:7b',
cwd: '/tmp/public-review',
safetyProfile: PUBLIC_REVIEW_EXECUTION_PROFILE,
});
expect(
Object.hasOwn(env, 'OPENCODE_CONFIG_CONTENT'),
provider === eligible ? 'eligible provider kept its config' : 'off-box provider was stripped',
).toBe(supportsPublicReviewProvider(provider));
}
});

it('strips a config declaring a non-loopback endpoint, key and all', () => {
const gatewayConfig = JSON.stringify({
provider: { openrouter: { options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: 'cloud-secret' } } },
});
expect(buildPublicReviewCliEnv({ PATH: '/usr/bin', OPENCODE_CONFIG_CONTENT: gatewayConfig }))
.not.toHaveProperty('OPENCODE_CONFIG_CONTENT');
// A config that no longer parses tells us nothing about the endpoint.
expect(buildPublicReviewCliEnv({ OPENCODE_CONFIG_CONTENT: '{not json' }))
.not.toHaveProperty('OPENCODE_CONFIG_CONTENT');
});
});

describe('buildCliChildEnv — public-review profile, cloud endpoint', () => {
it('strips the Anthropic credential when the base URL is not loopback', () => {
const env = buildPublicReviewCliEnv({
Expand Down
21 changes: 7 additions & 14 deletions server/lib/localProviderRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@
* report their working setup as broken.
*/

import { getOpencodeLocalProviderNamespace, isOpencodeCommand } from './providerModels.js';
import { localRuntimeNamespace, isOpencodeCommand, parseOpencodeConfigContent } from './providerModels.js';
import { opencodeLocalBaseUrl } from './opencodeConfig.js';
import { isGatewayNamespace } from './providerGateways.js';
import { PORTS } from './ports.js';
import { isLocalInstanceHost, isLocalInstanceEndpoint, localEndpointPort } from './localEndpoint.js';

Expand Down Expand Up @@ -266,16 +265,10 @@ function envBaseUrl(kind) {

/** The `baseURL` an OpenCode provider config declares for `namespace`, if any. */
function opencodeConfiguredBaseUrl(provider, namespace) {
const stored = provider?.envVars?.OPENCODE_CONFIG_CONTENT;
if (typeof stored !== 'string' || stored === '') return null;
let parsed = null;
try {
parsed = JSON.parse(stored);
} catch {
// A hand-edited config that no longer parses tells us nothing about the
// endpoint; fall through to the provider's own fields.
return null;
}
// A hand-edited config that no longer parses tells us nothing about the
// endpoint; `parseOpencodeConfigContent` answers null and we fall through to
// the provider's own fields.
const parsed = parseOpencodeConfigContent(provider?.envVars?.OPENCODE_CONFIG_CONTENT);
const baseUrl = parsed?.provider?.[namespace]?.options?.baseURL;
return typeof baseUrl === 'string' && baseUrl.trim() !== '' ? baseUrl : null;
}
Expand All @@ -296,8 +289,8 @@ export function localRuntimeKind(provider) {
if (!provider || typeof provider !== 'object') return null;
// Marker-based, NOT command-based: this also resolves `claude-ollama`, which
// carries `ollamaBacked` without being an OpenCode provider.
const namespace = getOpencodeLocalProviderNamespace(provider);
if (namespace && !isGatewayNamespace(namespace)) return namespace;
const namespace = localRuntimeNamespace(provider);
if (namespace) return namespace;
if (provider?.id === 'slotstream' || /slotstream/i.test(provider?.name || '')) return 'slotstream';
if (Number(localEndpointPort(provider?.endpoint)) === PORTS.SLOTSTREAM) return 'slotstream';
return localBackendForProvider(provider);
Expand Down
Loading