agentlas 0.7.0 → 0.9.2

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +161 -18
  3. package/bin/agentlas.cjs +8 -8
  4. package/engine/agentlas-core-harness.cjs +212 -0
  5. package/engine/agentlas-desktop-loadout.cjs +527 -0
  6. package/engine/agentlas-doctor.cjs +1 -1
  7. package/engine/agentlas-experience-exchange.cjs +835 -85
  8. package/engine/agentlas-experience-intake.cjs +444 -0
  9. package/engine/agentlas-experience-mcp.cjs +580 -18
  10. package/engine/agentlas-i18n.cjs +10 -10
  11. package/engine/agentlas-input.cjs +5 -4
  12. package/engine/agentlas-mcp-env.cjs +219 -0
  13. package/engine/agentlas-mcp-wrapper.cjs +51 -0
  14. package/engine/agentlas-memory-governance.cjs +1029 -0
  15. package/engine/agentlas-native-host.cjs +129 -39
  16. package/engine/agentlas-parity.cjs +339 -154
  17. package/engine/agentlas-repl.cjs +306 -31
  18. package/engine/agentlas-workforce.cjs +2991 -0
  19. package/engine/agentlas-workload-routing.cjs +523 -0
  20. package/engine/agentlas.cjs +1619 -234
  21. package/engine/bootstrap-schema.sql +1 -1
  22. package/engine/experience-taxonomy-v1.json +49 -0
  23. package/package.json +8 -4
  24. package/scripts/gen-bootstrap-schema.sh +0 -23
  25. package/test/bootstrap-race.cjs +0 -47
  26. package/test/capture-runtime-guard.cjs +0 -122
  27. package/test/cloud-asset-restore.cjs +0 -423
  28. package/test/cloud-cas-client.cjs +0 -333
  29. package/test/cloud-owner-restore.cjs +0 -183
  30. package/test/cloud-runtime-paths.cjs +0 -40
  31. package/test/cloud-save-publish.cjs +0 -487
  32. package/test/credential-env-regression.cjs +0 -52
  33. package/test/engine-hardening-regression.cjs +0 -74
  34. package/test/experience-exchange-contract.cjs +0 -569
  35. package/test/experience-mcp-contract.cjs +0 -391
  36. package/test/fixtures/portable-experience-bundle-v1-golden.json +0 -124
  37. package/test/login-loopback-security.cjs +0 -115
  38. package/test/mcp-config-isolation.cjs +0 -36
  39. package/test/permission-mapping.cjs +0 -180
  40. package/test/route-regression.cjs +0 -357
  41. package/test/run-api-regression.cjs +0 -322
  42. package/test/runtime-env-protection.cjs +0 -89
  43. package/test/semver-precedence.cjs +0 -39
  44. package/test/smoke.sh +0 -93
  45. package/test/sqlite-driver-probe.cjs +0 -22
  46. package/test/terminal-ui-regression.cjs +0 -477
  47. package/test/timeout-regression.cjs +0 -218
  48. package/test/tool-workspace-boundary.cjs +0 -165
  49. package/test/update-safety.cjs +0 -376
@@ -19,6 +19,7 @@ const os = require("node:os");
19
19
  const path = require("node:path");
20
20
  const permissions = require("./agentlas-permissions.cjs");
21
21
  const i18n = require("./agentlas-i18n.cjs");
22
+ const { wrapStdioServer } = require("./agentlas-mcp-env.cjs");
22
23
 
23
24
  function uiText(ui, key, ...args) {
24
25
  return ui && typeof ui.t === "function" ? ui.t(key, ...args) : i18n.t("en", key, ...args);
@@ -152,27 +153,45 @@ function prepareCodexRuntimeEnv(env = process.env) {
152
153
  return base;
153
154
  }
154
155
 
155
- function runtimeEnvForKind(kind, env = process.env) {
156
- return kind === "codex" ? prepareCodexRuntimeEnv(env) : env;
156
+ function runtimeEnvForKind(kind, env = process.env, options = {}) {
157
+ if (kind === "codex") return prepareCodexRuntimeEnv(env);
158
+ if (kind === "gemini") return prepareGeminiRuntimeEnv(env, options);
159
+ return env;
157
160
  }
158
161
 
159
162
  // MCP 서버 이름 → TOML/JSON 안전 키 (하이픈/공백 → _).
160
163
  function mcpKey(s) {
161
- return String((s && (s.name || s.id)) || "mcp").toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "mcp";
164
+ return String((s && (s.catalog_id || s.id || s.name)) || "mcp").toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "mcp";
162
165
  }
163
- function mcpStdioArgs(s) {
164
- try { return JSON.parse((s && s.args_json) || "[]"); } catch { return []; }
166
+ function selectedMcpServers(servers, options = {}) {
167
+ const selected = [];
168
+ for (const server of servers || []) {
169
+ if (!server || server.enabled === 0 || server.transport !== "stdio" || !server.command) continue;
170
+ selected.push(server);
171
+ }
172
+ return selected;
173
+ }
174
+ function wrappedMcpServerMap(servers, options = {}) {
175
+ const result = {};
176
+ for (const server of selectedMcpServers(servers, options)) {
177
+ const wrapped = wrapStdioServer(server, { dataDir: userDataDir(options.env || process.env) });
178
+ const baseKey = mcpKey(server);
179
+ let key = baseKey;
180
+ if (Object.prototype.hasOwnProperty.call(result, key)) {
181
+ const identity = String(server.catalog_id || server.id || server.name || server.command);
182
+ key = `${baseKey}_${crypto.createHash("sha256").update(identity, "utf8").digest("hex").slice(0, 8)}`;
183
+ }
184
+ result[key] = { command: wrapped.command, args: wrapped.args };
185
+ }
186
+ return result;
165
187
  }
166
- // Full-access turns only: claude --mcp-config with Playwright + enabled DB stdio servers.
167
- function cliMcpConfigPath(servers) {
168
- const dir = path.join(userDataDir(), "mcp");
188
+ // Full-access turns only: claude --mcp-config with the exact host-authorized
189
+ // stdio servers. Empty means empty; there is no legacy or provider seed.
190
+ function cliMcpConfigPath(servers, options = {}) {
191
+ const dir = path.join(userDataDir(options.env || process.env), "mcp");
169
192
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
170
193
  try { fs.chmodSync(dir, 0o700); } catch { /* Windows/best-effort */ }
171
- const mcpServers = { playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] } };
172
- for (const s of servers || []) {
173
- if (!s || s.enabled === 0 || s.transport !== "stdio" || !s.command) continue;
174
- mcpServers[mcpKey(s)] = { command: s.command, args: mcpStdioArgs(s) };
175
- }
194
+ const mcpServers = wrappedMcpServerMap(servers, options);
176
195
  const body = JSON.stringify({ mcpServers }, null, 2);
177
196
  // 서로 다른 동시 실행이 하나의 agentlas-cli-mcp.json을 덮어쓰지 않도록 내용 주소 파일을 쓴다.
178
197
  const digest = crypto.createHash("sha256").update(body).digest("hex").slice(0, 20);
@@ -191,17 +210,13 @@ function cliMcpConfigPath(servers) {
191
210
  try { fs.chmodSync(file, 0o600); } catch { /* Windows/best-effort */ }
192
211
  return { file, names: Object.keys(mcpServers) };
193
212
  }
194
- // Full-access turns only: codex -c mcp_servers.* with Playwright + enabled DB stdio servers.
195
- function codexMcpArgs(servers) {
196
- const out = [
197
- "-c", 'mcp_servers.playwright.command="npx"',
198
- "-c", 'mcp_servers.playwright.args=["-y","@playwright/mcp@latest"]',
199
- ];
200
- for (const s of servers || []) {
201
- if (!s || s.enabled === 0 || s.transport !== "stdio" || !s.command) continue;
202
- const k = mcpKey(s);
203
- out.push("-c", `mcp_servers.${k}.command=${JSON.stringify(s.command)}`);
204
- out.push("-c", `mcp_servers.${k}.args=${JSON.stringify(mcpStdioArgs(s))}`);
213
+ // Full-access turns only: codex -c mcp_servers.* with the same exact Build
214
+ // allowlist semantics as Claude.
215
+ function codexMcpArgs(servers, options = {}) {
216
+ const out = [];
217
+ for (const [key, server] of Object.entries(wrappedMcpServerMap(servers, options))) {
218
+ out.push("-c", `mcp_servers.${key}.command=${JSON.stringify(server.command)}`);
219
+ out.push("-c", `mcp_servers.${key}.args=${JSON.stringify(server.args)}`);
205
220
  }
206
221
  return out;
207
222
  }
@@ -276,7 +291,7 @@ function claudePermissionArgs(permission) {
276
291
  return ["--permission-mode", "plan"];
277
292
  }
278
293
 
279
- function claudeArgs({ prompt, systemPrompt, permission, session, model, effort, mcpServers }) {
294
+ function claudeArgs({ prompt, systemPrompt, permission, session, model, effort, mcpServers, mcpAllowlistMode, env }) {
280
295
  const level = permissions.normalize(permission);
281
296
  const perm = claudePermissionArgs(level);
282
297
  // /effort → Claude Code는 think 키워드로 reasoning 예산을 올린다(전용 CLI 플래그 없음).
@@ -294,8 +309,9 @@ function claudeArgs({ prompt, systemPrompt, permission, session, model, effort,
294
309
  // MCP tools can mutate state outside the workspace sandbox. Until the desktop schema
295
310
  // carries a trustworthy readOnlyHint per tool, only explicit full access may inject them.
296
311
  if (level === "full") {
297
- const mcpCfg = cliMcpConfigPath(mcpServers);
298
- args.push("--strict-mcp-config", "--mcp-config", mcpCfg.file, "--allowedTools", mcpCfg.names.map((n) => "mcp__" + n).join(","));
312
+ const mcpCfg = cliMcpConfigPath(mcpServers, { exactAllowlist: mcpAllowlistMode === "exact", env });
313
+ args.push("--strict-mcp-config", "--mcp-config", mcpCfg.file);
314
+ if (mcpCfg.names.length) args.push("--allowedTools", mcpCfg.names.map((n) => "mcp__" + n).join(","));
299
315
  } else {
300
316
  args.push(...claudeMcpIsolationArgs());
301
317
  }
@@ -433,13 +449,14 @@ function codexPermissionArgs(permission) {
433
449
  return ["--sandbox", level === "write" ? "workspace-write" : "read-only", "-c", 'approval_policy="never"'];
434
450
  }
435
451
 
436
- function codexArgs({ prompt, systemPrompt, permission, session, cwd, model, effort, mcpServers }) {
452
+ function codexArgs({ prompt, systemPrompt, permission, session, cwd, model, effort, mcpServers, mcpAllowlistMode, env }) {
437
453
  const level = permissions.normalize(permission);
438
454
  const sandbox = codexPermissionArgs(level);
439
- const mcp = level === "full" ? codexMcpArgs(mcpServers) : [];
455
+ const mcp = level === "full" ? codexMcpArgs(mcpServers, { exactAllowlist: mcpAllowlistMode === "exact", env }) : [];
440
456
  const mdl = model ? ["-m", model] : []; // /model parity
441
- // /effort parity codex reasoning effort (low|medium|high). max는 high로 매핑.
442
- const eff = effort ? ["-c", `model_reasoning_effort="${effort === "max" ? "high" : effort}"`] : [];
457
+ // Current Codex model inventory advertises max directly; preserve the user's
458
+ // explicit pin instead of silently weakening it to high.
459
+ const eff = effort ? ["-c", `model_reasoning_effort="${effort}"`] : [];
443
460
  const full = systemPrompt && !(session && session.id) ? `[SYSTEM]\n${systemPrompt}\n\n${prompt}` : prompt;
444
461
  // -C/--sandbox/--skip-git-repo-check 는 `codex exec` 옵션이라 `resume <id>` 토큰 *앞에* 와야 한다.
445
462
  // (codex-cli 0.133: resume 뒤에 두면 `unexpected argument` 로 거부 → 멀티턴 전부 실패. 실측 검증됨.)
@@ -582,22 +599,84 @@ function truncateLines(s, n) {
582
599
 
583
600
  // ── gemini (stream-json 구조화 렌더 — claude/codex와 동일 파리티) ──
584
601
  // gemini-cli는 -o stream-json 으로 init/message(delta)/tool_use/tool_result/result 이벤트를 낸다(실측).
602
+ function geminiSystemSettingsSourcePath(env = process.env) {
603
+ if (env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) return path.resolve(env.GEMINI_CLI_SYSTEM_SETTINGS_PATH);
604
+ if (process.platform === "darwin") return "/Library/Application Support/GeminiCli/settings.json";
605
+ if (process.platform === "win32") return "C:\\ProgramData\\gemini-cli\\settings.json";
606
+ return "/etc/gemini-cli/settings.json";
607
+ }
608
+
609
+ function geminiSystemDefaultsSourcePath(env = process.env) {
610
+ if (env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH) return path.resolve(env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH);
611
+ return path.join(path.dirname(geminiSystemSettingsSourcePath(env)), "system-defaults.json");
612
+ }
613
+
614
+ function geminiMcpIsolationReadiness(env = process.env) {
615
+ const managed = path.resolve(userDataDir(env), "mcp");
616
+ for (const source of [geminiSystemSettingsSourcePath(env), geminiSystemDefaultsSourcePath(env)]) {
617
+ let stat;
618
+ try { stat = fs.lstatSync(source); }
619
+ catch (error) {
620
+ if (error && error.code === "ENOENT") continue;
621
+ return { ready: false, reason: "system-settings-unreadable" };
622
+ }
623
+ const relative = path.relative(managed, path.resolve(source));
624
+ const insideManaged = relative && !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`);
625
+ if (insideManaged && stat.isFile() && !stat.isSymbolicLink()) continue;
626
+ // Replacing a real organization system policy/defaults file would weaken
627
+ // policy. A Gemini Build therefore degrades to empty-MCP on this host.
628
+ return { ready: false, reason: "system-settings-conflict" };
629
+ }
630
+ return { ready: true, reason: "no-system-settings-conflict" };
631
+ }
632
+
633
+ function prepareGeminiRuntimeEnv(env = process.env, options = {}) {
634
+ const base = { ...env };
635
+ const exactAllowlist = options.mcpAllowlistMode === "exact";
636
+ const mcpServers = wrappedMcpServerMap(options.mcpServers, { exactAllowlist, env: base });
637
+ if (!Object.keys(mcpServers).length) return base;
638
+ const readiness = geminiMcpIsolationReadiness(base);
639
+ if (!readiness.ready) {
640
+ const error = new Error("Gemini MCP isolation is unavailable because host system settings must be preserved");
641
+ error.code = "AGENTLAS_GEMINI_MCP_ISOLATION_UNAVAILABLE";
642
+ throw error;
643
+ }
644
+ const dir = path.join(userDataDir(base), "mcp");
645
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
646
+ try { fs.chmodSync(dir, 0o700); } catch { /* Windows/best-effort */ }
647
+ const names = Object.keys(mcpServers);
648
+ const body = JSON.stringify({ mcpServers, mcp: { allowed: names } }, null, 2);
649
+ const digest = crypto.createHash("sha256").update(body).digest("hex").slice(0, 20);
650
+ const file = path.join(dir, `agentlas-gemini-mcp-${digest}.json`);
651
+ let current = null;
652
+ try { current = fs.readFileSync(file, "utf8"); } catch { /* first write */ }
653
+ if (current !== body) writeManagedFile(file, body);
654
+ base.GEMINI_CLI_SYSTEM_SETTINGS_PATH = file;
655
+ return base;
656
+ }
657
+
585
658
  function geminiPermissionArgs(permission) {
586
659
  const level = permissions.normalize(permission);
587
660
  const approvalMode = level === "full" ? "yolo" : level === "write" ? "auto_edit" : "plan";
588
661
  return ["--approval-mode", approvalMode];
589
662
  }
590
663
 
591
- function geminiArgs({ prompt, systemPrompt, permission, model }) {
664
+ function geminiArgs({ prompt, systemPrompt, permission, model, mcpServers, mcpAllowlistMode, env }) {
592
665
  const level = permissions.normalize(permission);
593
666
  // Gemini CLI 0.50 exposes three matching modes: plan, auto_edit, and yolo.
594
667
  const approval = geminiPermissionArgs(level);
595
668
  const mdl = model ? ["-m", model] : []; // /model parity
669
+ const allowedMcpNames = level === "full"
670
+ ? Object.keys(wrappedMcpServerMap(mcpServers, { exactAllowlist: mcpAllowlistMode === "exact", env }))
671
+ : [];
672
+ const exactMcp = level === "full"
673
+ ? ["--allowed-mcp-server-names", allowedMcpNames.join(",") || `__agentlas_no_mcp_${crypto.randomUUID()}__`]
674
+ : [];
596
675
  return [
597
676
  "--output-format", "stream-json",
598
677
  "--skip-trust", // 헤드리스: 이 세션 동안 워크스페이스 신뢰 (untrusted dir exit 55 방지)
599
678
  ...approval,
600
- ...(level === "full" ? [] : geminiMcpIsolationArgs()),
679
+ ...(level === "full" ? exactMcp : geminiMcpIsolationArgs()),
601
680
  ...mdl,
602
681
  "--prompt", systemPrompt ? `[SYSTEM]\n${systemPrompt}\n\n${prompt}` : prompt,
603
682
  ];
@@ -691,6 +770,15 @@ function handleGeminiLine(line, st, ui) {
691
770
  function runNativeTurn(req) {
692
771
  const { kind, bin, ui } = req;
693
772
  const cwd = req.cwd;
773
+ let launchReq = req;
774
+ if (
775
+ kind === "gemini" && permissions.normalize(req.permission) === "full" &&
776
+ selectedMcpServers(req.mcpServers, { exactAllowlist: req.mcpAllowlistMode === "exact" }).length &&
777
+ !geminiMcpIsolationReadiness(req.env || process.env).ready
778
+ ) {
779
+ ui.warn("Gemini system policy settings are present; MCP attachment was isolated to empty mode for this turn.");
780
+ launchReq = { ...req, mcpServers: [], mcpAllowlistMode: "exact" };
781
+ }
694
782
  const st = {
695
783
  text: "",
696
784
  finalText: "",
@@ -710,13 +798,13 @@ function runNativeTurn(req) {
710
798
  let plainStream = false;
711
799
  try {
712
800
  if (kind === "claude-code") {
713
- args = claudeArgs(req);
801
+ args = claudeArgs(launchReq);
714
802
  lineHandler = (l) => handleClaudeLine(l, st, ui);
715
803
  } else if (kind === "codex") {
716
- args = codexArgs({ ...req, cwd });
804
+ args = codexArgs({ ...launchReq, cwd });
717
805
  lineHandler = (l) => handleCodexLine(l, st, ui);
718
806
  } else if (kind === "gemini") {
719
- args = geminiArgs(req);
807
+ args = geminiArgs(launchReq);
720
808
  lineHandler = (l) => handleGeminiLine(l, st, ui);
721
809
  } else {
722
810
  return Promise.resolve({ text: "", session: st.session, error: `unknown runtime: ${kind}` });
@@ -733,9 +821,9 @@ function runNativeTurn(req) {
733
821
  let child;
734
822
  try {
735
823
  const spawnImpl = req.spawn || spawn;
736
- const childEnv = req.prepareRuntimeEnv === false
737
- ? (req.env || process.env)
738
- : runtimeEnvForKind(kind, req.env || process.env);
824
+ const childEnv = launchReq.prepareRuntimeEnv === false
825
+ ? (launchReq.env || process.env)
826
+ : runtimeEnvForKind(kind, launchReq.env || process.env, launchReq);
739
827
  child = spawnImpl(bin, args, {
740
828
  cwd,
741
829
  stdio: ["ignore", "pipe", "pipe"],
@@ -902,6 +990,8 @@ module.exports = {
902
990
  claudeMcpIsolationArgs,
903
991
  geminiMcpIsolationArgs,
904
992
  prepareCodexRuntimeEnv,
993
+ prepareGeminiRuntimeEnv,
994
+ geminiMcpIsolationReadiness,
905
995
  runtimeEnvForKind,
906
996
  cliMcpConfigPath,
907
997
  codexMcpArgs,