karajan-code 4.6.2 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/base-agent.js +20 -0
- package/src/checks/ai-surface.js +76 -0
- package/src/commands/check.js +6 -1
- package/src/harden/workflow-engine.js +11 -3
- package/src/harden/workflow-templates.js +67 -41
- package/src/roles/coder-role.js +10 -2
- package/src/utils/role-env.js +57 -0
package/package.json
CHANGED
package/src/agents/base-agent.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { defaultEnvironment } from "../infrastructure/environment.js";
|
|
14
|
+
import { buildAgentEnv } from "../utils/role-env.js";
|
|
14
15
|
|
|
15
16
|
const MODEL_NOT_SUPPORTED_PATTERNS = [
|
|
16
17
|
/model.{0,30}is not supported/i,
|
|
@@ -49,6 +50,25 @@ export class BaseAgent {
|
|
|
49
50
|
* @returns {Promise<CommandRunResult>}
|
|
50
51
|
*/
|
|
51
52
|
async runCommand(command, args = [], options = {}) {
|
|
53
|
+
// KJC-TSK-0693: the subprocess gets an env allowlist, never the user's
|
|
54
|
+
// whole environment. security.env_allowlist:false opts out.
|
|
55
|
+
if (this.config?.security?.env_allowlist !== false) {
|
|
56
|
+
// Solomon-arbitrated contract (KJC-TSK-0693): an explicit env WITH a
|
|
57
|
+
// PATH is a COMPLETE child env (agents build those from process.env and
|
|
58
|
+
// deliberately strip vars — cleanExecaOpts drops CLAUDECODE, qwen
|
|
59
|
+
// unsets gemini's trust var) and is filtered AS GIVEN. An env WITHOUT
|
|
60
|
+
// a PATH is a partial overlay and merges over the inherited env first,
|
|
61
|
+
// so an overlay caller can never cost the child PATH/HOME.
|
|
62
|
+
const complete = options.env && ("PATH" in options.env || "Path" in options.env);
|
|
63
|
+
const base = complete ? options.env : { ...process.env, ...(options.env || {}) };
|
|
64
|
+
options = {
|
|
65
|
+
...options,
|
|
66
|
+
env: buildAgentEnv(base, {
|
|
67
|
+
agent: this.name,
|
|
68
|
+
passthrough: this.config?.security?.env_passthrough || [],
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
52
72
|
return this.environment.runner.run(command, args, options);
|
|
53
73
|
}
|
|
54
74
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-surface — MCP inventory with drift detection (KJC-TSK-0694).
|
|
3
|
+
*
|
|
4
|
+
* Every new tool an agent gains is one more access. An inventory nobody
|
|
5
|
+
* reconciles is "the feeling of control without the control", so kj check
|
|
6
|
+
* flags what APPEARED since the last run and asks the user to own it.
|
|
7
|
+
* A nudge, never a gate. ~/.claude.json is scoped to its global block plus
|
|
8
|
+
* THIS project's entry — other projects' MCPs are not reachable here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const sources = (projectDir, home) => [
|
|
16
|
+
{ file: path.join(projectDir, ".mcp.json"), format: "json" },
|
|
17
|
+
{ file: path.join(home, ".claude.json"), format: "json" },
|
|
18
|
+
{ file: path.join(home, ".codex", "config.toml"), format: "toml" },
|
|
19
|
+
{ file: path.join(home, ".gemini", "settings.json"), format: "json" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function jsonServerNames(text, projectDir) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(text);
|
|
25
|
+
const names = new Set(Object.keys(parsed.mcpServers || {}));
|
|
26
|
+
for (const name of Object.keys(parsed.projects?.[projectDir]?.mcpServers || {})) names.add(name);
|
|
27
|
+
return names;
|
|
28
|
+
} catch {
|
|
29
|
+
return new Set();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tomlServerNames = (text) =>
|
|
34
|
+
new Set([...text.matchAll(/^\s*\[mcp_servers\.["']?([^\]"']+)["']?\]/gm)].map((m) => m[1]));
|
|
35
|
+
|
|
36
|
+
/** Sorted `name (config-file)` entries for every MCP reachable in this project. */
|
|
37
|
+
export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir() } = {}) {
|
|
38
|
+
const surface = new Set();
|
|
39
|
+
for (const { file, format } of sources(projectDir, home)) {
|
|
40
|
+
if (!existsSync(file)) continue;
|
|
41
|
+
let text;
|
|
42
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
43
|
+
const names = format === "toml" ? tomlServerNames(text) : jsonServerNames(text, projectDir);
|
|
44
|
+
for (const name of names) surface.add(`${name} (${path.basename(file)})`);
|
|
45
|
+
}
|
|
46
|
+
return [...surface].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Diff the current surface against the last-seen snapshot and persist the
|
|
51
|
+
* new state. First run records the baseline silently.
|
|
52
|
+
*/
|
|
53
|
+
export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), statePath = path.join(os.homedir(), ".karajan", "ai-surface.json") } = {}) {
|
|
54
|
+
const surface = collectAiSurface({ projectDir, home });
|
|
55
|
+
let state = {};
|
|
56
|
+
try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* first run or corrupt → baseline */ }
|
|
57
|
+
const prev = Array.isArray(state[projectDir]) ? state[projectDir] : null;
|
|
58
|
+
const added = prev ? surface.filter((e) => !prev.includes(e)) : [];
|
|
59
|
+
const removed = prev ? prev.filter((e) => !surface.includes(e)) : [];
|
|
60
|
+
state[projectDir] = surface;
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(path.dirname(statePath), { recursive: true });
|
|
63
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
64
|
+
} catch { /* read-only FS — inventory still reports, just cannot remember */ }
|
|
65
|
+
return { surface, added, removed, first: !prev };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One line for kj check output. */
|
|
69
|
+
export function formatAiSurface({ surface, added, removed, first }) {
|
|
70
|
+
const head = `ai surface: ${surface.length} MCP(s)`;
|
|
71
|
+
if (first) return `${head} — baseline recorded`;
|
|
72
|
+
const parts = [head];
|
|
73
|
+
if (added.length) parts.push(`NEW since last check: ${added.join(", ")} — approved by you?`);
|
|
74
|
+
if (removed.length) parts.push(`gone: ${removed.join(", ")}`);
|
|
75
|
+
return parts.join(" · ");
|
|
76
|
+
}
|
package/src/commands/check.js
CHANGED
|
@@ -6,21 +6,26 @@
|
|
|
6
6
|
|
|
7
7
|
import { checkHarden } from "../harden/check.js";
|
|
8
8
|
import { collectMethodStats, formatMethodStats } from "../checks/method.js";
|
|
9
|
+
import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
|
|
9
10
|
|
|
10
11
|
export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
|
|
11
12
|
const result = await checkHarden({ projectDir, profile });
|
|
12
13
|
// KJC-TSK-0689 (MG-D): method adherence is VISIBILITY, not a gate — it
|
|
13
14
|
// rides along in check output but never affects the exit code.
|
|
14
15
|
const method = await collectMethodStats({ projectDir }).catch(() => null);
|
|
16
|
+
// KJC-TSK-0694: same deal for the MCP inventory — a nudge, never a gate.
|
|
17
|
+
let aiSurface = null;
|
|
18
|
+
try { aiSurface = checkAiSurface({ projectDir }); } catch { /* inventory is best-effort */ }
|
|
15
19
|
|
|
16
20
|
if (json) {
|
|
17
|
-
logger.info?.(JSON.stringify({ ...result, method }));
|
|
21
|
+
logger.info?.(JSON.stringify({ ...result, method, aiSurface }));
|
|
18
22
|
return result.ok ? 0 : 1;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
logger.info?.(`kj check (${profile})`);
|
|
22
26
|
for (const c of result.checks) logger.info?.(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
|
|
23
27
|
if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
|
|
28
|
+
if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
|
|
24
29
|
if (!result.ok) logger.info?.("Harness drift detected — run `kj harden` to repair.");
|
|
25
30
|
else logger.info?.("Harness OK.");
|
|
26
31
|
return result.ok ? 0 : 1;
|
|
@@ -27,6 +27,13 @@ export function isPublishableNpm(projectDir) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Lockfile-detected package manager (KJC-BUG-0131): npm ci kills pnpm/yarn CI. */
|
|
31
|
+
export function detectPackageManager(projectDir) {
|
|
32
|
+
if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
33
|
+
if (existsSync(join(projectDir, "yarn.lock"))) return "yarn";
|
|
34
|
+
return "npm";
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
export function installWorkflows({
|
|
31
38
|
projectDir = process.cwd(),
|
|
32
39
|
language = null,
|
|
@@ -35,9 +42,10 @@ export function installWorkflows({
|
|
|
35
42
|
dryRun = false,
|
|
36
43
|
} = {}) {
|
|
37
44
|
const dir = join(projectDir, WORKFLOWS_DIR);
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
45
|
+
const pm = detectPackageManager(projectDir);
|
|
46
|
+
const quality = qualityWorkflowFor(language, pm);
|
|
47
|
+
const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir), pm });
|
|
48
|
+
const mut = mutation ? mutationWorkflowFor(language, pm) : null;
|
|
41
49
|
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : [])];
|
|
42
50
|
const results = [];
|
|
43
51
|
for (const wf of all) {
|
|
@@ -54,15 +54,37 @@ const header = (steps) =>
|
|
|
54
54
|
...steps,
|
|
55
55
|
].join("\n");
|
|
56
56
|
|
|
57
|
+
// Per-package-manager commands (KJC-BUG-0131, issue #1330): `npm ci` in a
|
|
58
|
+
// pnpm/yarn repo dies with EUSAGE (no package-lock.json). corepack ships with
|
|
59
|
+
// Node 22 and resolves the right pnpm/yarn from `packageManager` — no
|
|
60
|
+
// third-party action. pnpm passes flags AFTER the script name to the script
|
|
61
|
+
// itself, so --if-present must go BEFORE it; yarn has no --if-present at all,
|
|
62
|
+
// so lint runs through `npm run` (scripts don't touch the lockfile).
|
|
63
|
+
const PM_COMMANDS = {
|
|
64
|
+
npm: { setup: [], install: "npm ci", lint: "npm run -s lint --if-present", test: "npm test" },
|
|
65
|
+
pnpm: {
|
|
66
|
+
setup: [" - run: corepack enable"],
|
|
67
|
+
install: "pnpm install --frozen-lockfile",
|
|
68
|
+
lint: "pnpm run --if-present -s lint",
|
|
69
|
+
test: "pnpm test",
|
|
70
|
+
},
|
|
71
|
+
yarn: {
|
|
72
|
+
setup: [" - run: corepack enable"],
|
|
73
|
+
install: "yarn install --frozen-lockfile",
|
|
74
|
+
lint: "npm run -s lint --if-present",
|
|
75
|
+
test: "yarn test",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const nodeSetupSteps = (pm) => [
|
|
80
|
+
" - uses: actions/setup-node@v4",
|
|
81
|
+
" with:",
|
|
82
|
+
" node-version: 22",
|
|
83
|
+
...(PM_COMMANDS[pm] || PM_COMMANDS.npm).setup,
|
|
84
|
+
` - run: ${(PM_COMMANDS[pm] || PM_COMMANDS.npm).install}`,
|
|
85
|
+
];
|
|
86
|
+
|
|
57
87
|
const QUALITY_BY_LANGUAGE = {
|
|
58
|
-
javascript: header([
|
|
59
|
-
" - uses: actions/setup-node@v4",
|
|
60
|
-
" with:",
|
|
61
|
-
" node-version: 22",
|
|
62
|
-
" - run: npm ci",
|
|
63
|
-
" - run: npm run -s lint --if-present",
|
|
64
|
-
" - run: npm test",
|
|
65
|
-
]),
|
|
66
88
|
python: header([
|
|
67
89
|
" - uses: actions/setup-python@v5",
|
|
68
90
|
" with:",
|
|
@@ -87,11 +109,13 @@ const QUALITY_BY_LANGUAGE = {
|
|
|
87
109
|
" - run: vendor/bin/phpunit",
|
|
88
110
|
]),
|
|
89
111
|
};
|
|
90
|
-
QUALITY_BY_LANGUAGE.typescript = QUALITY_BY_LANGUAGE.javascript;
|
|
91
|
-
|
|
92
112
|
/** Stack-aware Quality workflow entry, or null for an unknown language. */
|
|
93
|
-
export function qualityWorkflowFor(language) {
|
|
94
|
-
const
|
|
113
|
+
export function qualityWorkflowFor(language, pm = "npm") {
|
|
114
|
+
const c = PM_COMMANDS[pm] || PM_COMMANDS.npm;
|
|
115
|
+
const body =
|
|
116
|
+
language === "javascript" || language === "typescript"
|
|
117
|
+
? header([...nodeSetupSteps(pm), ` - run: ${c.lint}`, ` - run: ${c.test}`])
|
|
118
|
+
: QUALITY_BY_LANGUAGE[language];
|
|
95
119
|
return body ? { file: "kj-quality.yml", blockId: "wf-quality", body } : null;
|
|
96
120
|
}
|
|
97
121
|
|
|
@@ -123,29 +147,29 @@ export const SHRINK_BUDGET_WORKFLOW = [
|
|
|
123
147
|
].join("\n");
|
|
124
148
|
|
|
125
149
|
// Packs the tarball and installs it clean — only for publishable npm packages.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
].join("\n");
|
|
150
|
+
// `npm pack`/`npm install <tarball>` need no lockfile, so only the dependency
|
|
151
|
+
// install step is package-manager-aware.
|
|
152
|
+
export const packSmokeWorkflow = (pm = "npm") =>
|
|
153
|
+
[
|
|
154
|
+
"name: Pack smoke",
|
|
155
|
+
"on:",
|
|
156
|
+
" pull_request:",
|
|
157
|
+
" branches: [main]",
|
|
158
|
+
"permissions:",
|
|
159
|
+
" contents: read",
|
|
160
|
+
"jobs:",
|
|
161
|
+
" pack-smoke:",
|
|
162
|
+
" runs-on: ubuntu-latest",
|
|
163
|
+
" steps:",
|
|
164
|
+
" - uses: actions/checkout@v4",
|
|
165
|
+
...nodeSetupSteps(pm),
|
|
166
|
+
" - name: Pack and install the tarball clean",
|
|
167
|
+
" run: |",
|
|
168
|
+
" tgz=$(npm pack --silent)",
|
|
169
|
+
" dir=$(mktemp -d)",
|
|
170
|
+
' npm install --prefix "$dir" "$PWD/$tgz" --no-audit --no-fund',
|
|
171
|
+
' echo "Tarball installs clean: $tgz"',
|
|
172
|
+
].join("\n");
|
|
149
173
|
|
|
150
174
|
// Opt-in nightly/manual mutation audit (KJC-TSK-0589). NEVER a PR gate: it runs
|
|
151
175
|
// on a weekly schedule + manual dispatch, and the mutate step is non-blocking
|
|
@@ -153,7 +177,7 @@ export const PACK_SMOKE_WORKFLOW = [
|
|
|
153
177
|
// yields a JSON report (stryker/mutmut/infection) get a workflow — no silent
|
|
154
178
|
// scaffold for tools that can't report yet.
|
|
155
179
|
const MUTATION_TOOLCHAIN = {
|
|
156
|
-
javascript:
|
|
180
|
+
javascript: "node", // resolved per package manager in mutationWorkflowFor
|
|
157
181
|
python: [
|
|
158
182
|
" - uses: actions/setup-python@v5",
|
|
159
183
|
" with:",
|
|
@@ -170,9 +194,11 @@ const MUTATION_TOOLCHAIN = {
|
|
|
170
194
|
MUTATION_TOOLCHAIN.typescript = MUTATION_TOOLCHAIN.javascript;
|
|
171
195
|
|
|
172
196
|
/** Opt-in mutation workflow entry, or null for a stack without a JSON report. */
|
|
173
|
-
export function mutationWorkflowFor(language) {
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
197
|
+
export function mutationWorkflowFor(language, pm = "npm") {
|
|
198
|
+
const entry = MUTATION_TOOLCHAIN[language];
|
|
199
|
+
if (!entry) return null;
|
|
200
|
+
const c = PM_COMMANDS[pm] || PM_COMMANDS.npm;
|
|
201
|
+
const toolchain = entry === "node" ? [...c.setup, ` - run: ${c.install}`] : entry;
|
|
176
202
|
const body = [
|
|
177
203
|
"name: Mutation (nightly)",
|
|
178
204
|
"on:",
|
|
@@ -200,13 +226,13 @@ export function mutationWorkflowFor(language) {
|
|
|
200
226
|
}
|
|
201
227
|
|
|
202
228
|
/** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
|
|
203
|
-
export function extraWorkflowsFor({ profile = "standard", publishable = false } = {}) {
|
|
229
|
+
export function extraWorkflowsFor({ profile = "standard", publishable = false, pm = "npm" } = {}) {
|
|
204
230
|
const extras = [];
|
|
205
231
|
if (profile === "strict") {
|
|
206
232
|
extras.push({ file: "kj-shrink-budget.yml", blockId: "wf-shrink", body: SHRINK_BUDGET_WORKFLOW });
|
|
207
233
|
}
|
|
208
234
|
if (publishable) {
|
|
209
|
-
extras.push({ file: "kj-pack-smoke.yml", blockId: "wf-pack-smoke", body:
|
|
235
|
+
extras.push({ file: "kj-pack-smoke.yml", blockId: "wf-pack-smoke", body: packSmokeWorkflow(pm) });
|
|
210
236
|
}
|
|
211
237
|
return extras;
|
|
212
238
|
}
|
package/src/roles/coder-role.js
CHANGED
|
@@ -17,9 +17,17 @@ export class CoderRole extends AgentRole {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
createAgentInstance(provider) {
|
|
20
|
+
// KJC-BUG-0129 (issues #1301/#1303): running INSIDE an agent does not
|
|
21
|
+
// make it "the host executor" — delegating sent the full instruction
|
|
22
|
+
// prompt through elicitInput as a hanging board "question". A CLI
|
|
23
|
+
// provider spawns as a subprocess ALWAYS; host delegation is explicit
|
|
24
|
+
// opt-in (roles.coder.host_delegation: true) for MCP-only setups.
|
|
20
25
|
if (this._askHost && isHostAgent(provider)) {
|
|
21
|
-
this.
|
|
22
|
-
|
|
26
|
+
if (this.config?.roles?.coder?.host_delegation === true) {
|
|
27
|
+
this.logger.info(`Host-as-coder: delegating to host AI (roles.coder.host_delegation, skipping ${provider} subprocess)`);
|
|
28
|
+
return new HostAgent(this.config, this.logger, { askHost: this._askHost });
|
|
29
|
+
}
|
|
30
|
+
this.logger.info(`Running inside ${provider} — spawning it as a subprocess anyway (set roles.coder.host_delegation: true to delegate to the host)`);
|
|
23
31
|
}
|
|
24
32
|
return this._createAgent(provider, this.config, this.logger);
|
|
25
33
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* role-env — env allowlist for agent subprocesses (KJC-TSK-0693).
|
|
3
|
+
*
|
|
4
|
+
* Agents inherit only what their function requires: system essentials, the
|
|
5
|
+
* spawned CLI's own auth/config vars, and KJ_* lane vars. Long-lived secrets
|
|
6
|
+
* that no agent needs (cloud keys, registry tokens, DB URLs) never reach the
|
|
7
|
+
* child, so a compromised agent or an injected prompt cannot exfiltrate them.
|
|
8
|
+
* Escapes: `security.env_passthrough` (exact names or trailing-* globs) and
|
|
9
|
+
* `security.env_allowlist: false` to disable filtering entirely.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const EXACT = new Set([
|
|
13
|
+
"PATH", "Path", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "COLORTERM", "LANG",
|
|
14
|
+
"TZ", "TMPDIR", "TMP", "TEMP", "EDITOR", "CI", "OLLAMA_HOST",
|
|
15
|
+
// Windows child-process essentials: executable resolution and profile dirs.
|
|
16
|
+
"SystemRoot", "SYSTEMROOT", "windir", "COMSPEC", "ComSpec", "PATHEXT",
|
|
17
|
+
"USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
|
|
18
|
+
// SSH agent socket: local-only handle, needed for git fetch/pull over SSH
|
|
19
|
+
// inside worktrees. Unusable off-machine, unlike a long-lived token.
|
|
20
|
+
"SSH_AUTH_SOCK", "SSH_AGENT_PID",
|
|
21
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
22
|
+
"http_proxy", "https_proxy", "no_proxy",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// Per-CLI auth/config families. GOOGLE_ covers GOOGLE_APPLICATION_CREDENTIALS
|
|
26
|
+
// (gemini via Vertex); npm_ covers npm_config_* that Node tooling reads.
|
|
27
|
+
const PREFIXES = [
|
|
28
|
+
"LC_", "XDG_", "KJ_", "NODE_", "npm_",
|
|
29
|
+
"ANTHROPIC_", "CLAUDE", "OPENAI_", "CODEX", "GEMINI", "GOOGLE_",
|
|
30
|
+
"QWEN", "DASHSCOPE_", "OPENCODE", "AIDER_", "OPENROUTER_",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Vars only ONE agent authenticates with — granting them globally would
|
|
34
|
+
// defeat the point (the coder does not need your GitHub token; copilot does).
|
|
35
|
+
const AGENT_EXTRAS = {
|
|
36
|
+
copilot: ["GITHUB_", "GH_"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const matches = (pattern, key) =>
|
|
40
|
+
pattern.endsWith("*") ? key.startsWith(pattern.slice(0, -1)) : key === pattern;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filter an environment down to what the given agent's subprocess needs.
|
|
44
|
+
* @param {Record<string,string|undefined>} base - env to filter
|
|
45
|
+
* @param {{agent?: string|null, passthrough?: string[]}} [opts]
|
|
46
|
+
* @returns {Record<string,string|undefined>}
|
|
47
|
+
*/
|
|
48
|
+
export function buildAgentEnv(base = process.env, { agent = null, passthrough = [] } = {}) {
|
|
49
|
+
const prefixes = [...PREFIXES, ...(AGENT_EXTRAS[agent] || [])];
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [key, value] of Object.entries(base)) {
|
|
52
|
+
if (EXACT.has(key) || prefixes.some((p) => key.startsWith(p)) || passthrough.some((p) => matches(p, key))) {
|
|
53
|
+
out[key] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|