amalgm 0.1.75 → 0.1.76

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 (45) hide show
  1. package/package.json +1 -1
  2. package/runtime/scripts/amalgm-mcp/agent-config/importers/claude.js +52 -0
  3. package/runtime/scripts/amalgm-mcp/agent-config/importers/codex.js +36 -0
  4. package/runtime/scripts/amalgm-mcp/agent-config/importers/common.js +115 -0
  5. package/runtime/scripts/amalgm-mcp/agent-config/importers/index.js +14 -0
  6. package/runtime/scripts/amalgm-mcp/agent-config/importers/opencode.js +59 -0
  7. package/runtime/scripts/amalgm-mcp/agent-config/renderers/hooks.js +55 -0
  8. package/runtime/scripts/amalgm-mcp/agent-config/renderers/instructions.js +54 -0
  9. package/runtime/scripts/amalgm-mcp/agent-config/rest.js +168 -0
  10. package/runtime/scripts/amalgm-mcp/agent-config/schema.js +248 -0
  11. package/runtime/scripts/amalgm-mcp/agent-config/store.js +123 -0
  12. package/runtime/scripts/amalgm-mcp/agents/hooks.js +2 -0
  13. package/runtime/scripts/amalgm-mcp/agents/rest.js +45 -2
  14. package/runtime/scripts/amalgm-mcp/agents/talk.js +20 -4
  15. package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +52 -9
  16. package/runtime/scripts/amalgm-mcp/automations/tools.js +3 -3
  17. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +3 -3
  18. package/runtime/scripts/amalgm-mcp/lib/chat-payloads.js +101 -0
  19. package/runtime/scripts/amalgm-mcp/server/core-tools.js +10 -10
  20. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  21. package/runtime/scripts/amalgm-mcp/server/routes/agents.js +13 -0
  22. package/runtime/scripts/amalgm-mcp/server/routes/chat-payloads.js +37 -0
  23. package/runtime/scripts/amalgm-mcp/state/db.js +11 -0
  24. package/runtime/scripts/amalgm-mcp/state/snapshot.js +13 -3
  25. package/runtime/scripts/amalgm-mcp/tests/agent-config.test.js +80 -0
  26. package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +17 -5
  27. package/runtime/scripts/amalgm-mcp/tests/chat-payloads.test.js +71 -0
  28. package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +9 -9
  29. package/runtime/scripts/amalgm-mcp/tests/mcp-surface.test.js +29 -0
  30. package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +21 -16
  31. package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +9 -7
  32. package/runtime/scripts/amalgm-mcp/toolbox/selection.js +21 -1
  33. package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +16 -1
  34. package/runtime/scripts/amalgm-mcp/workflows/compiler.js +1 -1
  35. package/runtime/scripts/amalgm-mcp/workflows/runner.js +8 -2
  36. package/runtime/scripts/chat-core/adapters/claude.js +2 -2
  37. package/runtime/scripts/chat-core/adapters/codex.js +82 -12
  38. package/runtime/scripts/chat-core/auth.js +13 -4
  39. package/runtime/scripts/chat-core/contract.js +18 -0
  40. package/runtime/scripts/chat-core/tests/auth.test.js +27 -2
  41. package/runtime/scripts/chat-core/tests/native-config.test.js +65 -12
  42. package/runtime/scripts/chat-core/tooling/native-config.js +73 -0
  43. package/runtime/scripts/chat-core/tooling/runtime-home.js +2 -2
  44. package/runtime/scripts/chat-core/tooling/system-prompt.js +3 -1
  45. package/runtime/scripts/local-gateway.js +1 -0
@@ -160,16 +160,18 @@ test('toolbox service lists compact registry views', () => {
160
160
  test('toolbox service overlays first-party system catalog entries', () => {
161
161
  const service = createToolboxService(createRepository());
162
162
  const catalog = service.readCatalog();
163
- const browser = service.getTool('amalgm.browser', { includeActions: false });
164
- const navigate = service.getAction('amalgm.browser.browser_navigate');
163
+ const browser = service.getTool('browser', { includeActions: false });
164
+ const navigate = service.getAction('browser.browser_navigate');
165
165
 
166
166
  assert.equal(catalog.tools.amalgm, undefined);
167
167
  assert.equal(catalog.toolActions['amalgm.notify_user'], undefined);
168
- assert.equal(catalog.tools['amalgm.browser'].origin, 'system');
169
- assert.equal(browser.tool.name, 'Amalgm Browser');
168
+ assert.equal(catalog.tools['amalgm.browser'], undefined);
169
+ assert.equal(catalog.tools.browser.origin, 'system');
170
+ assert.equal(catalog.tools.browser.owner, 'amalgm');
171
+ assert.equal(browser.tool.name, 'Browser');
170
172
  assert.equal(browser.actions, undefined);
171
- assert.equal(navigate.action.sourceAction.mcpToolName, 'toolbox__amalgm_browser_browser_navigate');
172
- assert.equal(service.listTools({ origin: 'system' }).tools.some((tool) => tool.id === 'amalgm.browser'), true);
173
+ assert.equal(navigate.action.sourceAction.mcpToolName, 'toolbox__browser_browser_navigate');
174
+ assert.equal(service.listTools({ origin: 'system' }).tools.some((tool) => tool.id === 'browser'), true);
173
175
  });
174
176
 
175
177
  test('toolbox service resolves only runnable CLI/API actions', () => {
@@ -196,7 +198,7 @@ test('toolbox service derives only external MCP ids from selected tools', () =>
196
198
  assert.deepEqual(
197
199
  service.externalMcpToolIdsForSelection([
198
200
  'remote.mcp',
199
- 'amalgm.browser',
201
+ 'browser',
200
202
  'api.weather',
201
203
  'missing.tool',
202
204
  ]),
@@ -1,5 +1,13 @@
1
1
  'use strict';
2
2
 
3
+ const FIRST_PARTY_TOOL_IDS = new Set([
4
+ 'automations',
5
+ 'notifications',
6
+ 'agents',
7
+ 'apps',
8
+ 'browser',
9
+ ]);
10
+
3
11
  function selectedToolIdsForContext(context) {
4
12
  const tools = context?.sessionMetadata?.tools;
5
13
  if (!tools || tools.mode !== 'selected') return null;
@@ -11,9 +19,21 @@ function selectedToolIdsForContext(context) {
11
19
  return new Set(selected.map((value) => String(value || '').trim()).filter(Boolean));
12
20
  }
13
21
 
22
+ function selectedHas(selectedToolIds, id) {
23
+ if (!id) return false;
24
+ if (selectedToolIds.has(id)) return true;
25
+ const value = String(id || '').trim();
26
+ const toolId = value.split('.')[0];
27
+ if (FIRST_PARTY_TOOL_IDS.has(toolId) && selectedToolIds.has(`amalgm.${value}`)) return true;
28
+ if (value === 'notifications.notify_user' && selectedToolIds.has('amalgm.notify_user')) return true;
29
+ if (value === 'agents.talk_to_agent' && selectedToolIds.has('amalgm.talk_to_agent')) return true;
30
+ if (FIRST_PARTY_TOOL_IDS.has(value) && selectedToolIds.has(`amalgm.${value}`)) return true;
31
+ return false;
32
+ }
33
+
14
34
  function selectedContainsToolOrAction(selectedToolIds, toolId, actionId) {
15
35
  if (!selectedToolIds) return true;
16
- return selectedToolIds.has(toolId) || selectedToolIds.has(actionId);
36
+ return selectedHas(selectedToolIds, toolId) || selectedHas(selectedToolIds, actionId);
17
37
  }
18
38
 
19
39
  module.exports = {
@@ -12,6 +12,13 @@ const { PORT } = require('../config');
12
12
  const { toolboxMcpToolName } = require('./names');
13
13
 
14
14
  const SYSTEM_TIMESTAMP = '1970-01-01T00:00:00.000Z';
15
+ const LEGACY_FIRST_PARTY_TOOL_IDS = new Set([
16
+ 'amalgm.automations',
17
+ 'amalgm.notifications',
18
+ 'amalgm.agents',
19
+ 'amalgm.apps',
20
+ 'amalgm.browser',
21
+ ]);
15
22
 
16
23
  function coreToolGroups() {
17
24
  return require('../server/core-tools').CORE_TOOL_GROUPS || [];
@@ -83,8 +90,16 @@ function persistedCatalogWithoutLegacyAggregate(toolbox) {
83
90
  const tools = { ...(toolbox?.tools || {}) };
84
91
  const toolActions = { ...(toolbox?.toolActions || {}) };
85
92
  delete tools.amalgm;
93
+ for (const toolId of LEGACY_FIRST_PARTY_TOOL_IDS) delete tools[toolId];
86
94
  for (const [actionId, action] of Object.entries(toolActions)) {
87
- if (action?.toolId === 'amalgm') delete toolActions[actionId];
95
+ const legacyGroupId = actionId.split('.').slice(0, 2).join('.');
96
+ if (
97
+ action?.toolId === 'amalgm'
98
+ || LEGACY_FIRST_PARTY_TOOL_IDS.has(action?.toolId)
99
+ || LEGACY_FIRST_PARTY_TOOL_IDS.has(legacyGroupId)
100
+ ) {
101
+ delete toolActions[actionId];
102
+ }
88
103
  }
89
104
  return { tools, toolActions };
90
105
  }
@@ -136,7 +136,7 @@ function makeBuilders() {
136
136
  const args = isObject(promptOrArgs)
137
137
  ? { agent, ...promptOrArgs }
138
138
  : { agent, prompt: String(promptOrArgs || '') };
139
- return builders.tool(name, 'amalgm.agents.talk_to_agent', args, options);
139
+ return builders.tool(name, 'agents.talk_to_agent', args, options);
140
140
  };
141
141
 
142
142
  builders.workflow = (definition) => definition;
@@ -10,7 +10,12 @@ const { recordEventRun } = require('../events/store');
10
10
  const { compileWorkflowText } = require('./compiler');
11
11
  const { loadWorkflows } = require('./store');
12
12
  const { callToolboxMcpTool, toolboxMcpToolName } = require('../toolbox/runner');
13
- const { resolveFirstPartyToolAction, resolveToolboxAction, suggestWorkflowToolActions } = require('../automations/tool-actions');
13
+ const {
14
+ resolveFirstPartyToolAction,
15
+ resolveToolboxAction,
16
+ suggestWorkflowToolActions,
17
+ workflowActionKeys,
18
+ } = require('../automations/tool-actions');
14
19
 
15
20
  const DEFAULT_CELL_TIMEOUT_MS = 120_000;
16
21
  const DEFAULT_CODE_TIMEOUT_MS = 15_000;
@@ -281,7 +286,8 @@ function mcpResultToOutput(result, actionKey) {
281
286
  async function runToolCell(cell, args, allowlist) {
282
287
  const actionKey = `${cell.toolId}.${cell.actionName}`;
283
288
  const allowedActions = Array.isArray(allowlist.actions) ? allowlist.actions : [];
284
- if (allowedActions.length > 0 && !allowedActions.includes(actionKey)) {
289
+ const allowedKeys = new Set(workflowActionKeys(cell.toolId, cell.actionName));
290
+ if (allowedActions.length > 0 && !allowedActions.some((allowed) => allowedKeys.has(allowed))) {
285
291
  throw new Error(`Workflow is not allowed to call ${actionKey}.`);
286
292
  }
287
293
 
@@ -7,10 +7,10 @@ const { normalizeClaudeMessage, usageRecordsFromClaudeResult, usageFromClaude }
7
7
  const { recordNativeEvent } = require('../recorder');
8
8
  const { toClaudeMcpServers } = require('../tooling/mcp-bundle');
9
9
  const { bundledClaudeBinary } = require('../tooling/native-binaries');
10
- const { claudeNativeHookSettings } = require('../tooling/native-config');
11
10
  const { importPackage } = require('../tooling/package-import');
12
11
  const { prepareHarnessRuntime } = require('../tooling/runtime-home');
13
12
  const { composeSystemPrompt } = require('../tooling/system-prompt');
13
+ const { toClaudeSettings } = require('../../amalgm-mcp/agent-config/renderers/hooks');
14
14
 
15
15
  function redactSecrets(text) {
16
16
  return String(text || '')
@@ -36,7 +36,7 @@ class ClaudeAdapter {
36
36
  const systemPrompt = composeSystemPrompt(contract);
37
37
  const pathToClaudeCodeExecutable = process.env.CLAUDE_CODE_BINARY || bundledClaudeBinary();
38
38
  const settings = {
39
- ...(contract.authMethod === 'provider_auth' ? (claudeNativeHookSettings({ cwd: contract.cwd }) || {}) : {}),
39
+ ...toClaudeSettings(contract.agentConfig),
40
40
  ...(contract.fastMode ? { fastMode: true, fastModePerSessionOptIn: true } : {}),
41
41
  };
42
42
  return {
@@ -10,9 +10,10 @@ const { codexErrorMessage, normalizeCodexNotification } = require('../normalizer
10
10
  const { recordNativeEvent } = require('../recorder');
11
11
  const { relayedMcpServers, toCodexMcpToml } = require('../tooling/mcp-bundle');
12
12
  const { bundledCodexBinary, bundledCodexPathDirs, executableExists, findOnPath } = require('../tooling/native-binaries');
13
- const { syncCodexNativeConfig } = require('../tooling/native-config');
13
+ const { syncCodexProviderAuth } = require('../tooling/native-config');
14
14
  const { prepareHarnessRuntime } = require('../tooling/runtime-home');
15
15
  const { composeSystemPrompt } = require('../tooling/system-prompt');
16
+ const { hasHooks, toCodexHooksJson, toHookMap } = require('../../amalgm-mcp/agent-config/renderers/hooks');
16
17
 
17
18
  class JsonLineRpc {
18
19
  constructor({ binary, cwd, env }) {
@@ -202,6 +203,53 @@ function readTextFile(file) {
202
203
  }
203
204
  }
204
205
 
206
+ function ensureCodexHomeAlias(home) {
207
+ if (!home) return null;
208
+ const alias = path.join(home, '.codex');
209
+ try {
210
+ const stat = fs.lstatSync(alias);
211
+ return stat.isDirectory() || stat.isSymbolicLink() ? alias : null;
212
+ } catch {}
213
+ try {
214
+ fs.symlinkSync('.', alias, 'dir');
215
+ return alias;
216
+ } catch {
217
+ try {
218
+ fs.mkdirSync(alias, { recursive: true });
219
+ return alias;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+ }
225
+
226
+ function codexConfigDirs(home) {
227
+ fs.mkdirSync(home, { recursive: true });
228
+ const dirs = [home];
229
+ const alias = ensureCodexHomeAlias(home);
230
+ if (alias) dirs.push(alias);
231
+ const seen = new Set();
232
+ return dirs.filter((dir) => {
233
+ let key;
234
+ try {
235
+ key = fs.realpathSync(dir);
236
+ } catch {
237
+ key = path.resolve(dir);
238
+ }
239
+ if (seen.has(key)) return false;
240
+ seen.add(key);
241
+ return true;
242
+ });
243
+ }
244
+
245
+ function writeCodexHomeFile(home, filename, contents, mode = 0o600) {
246
+ for (const dir of codexConfigDirs(home)) {
247
+ const target = path.join(dir, filename);
248
+ fs.writeFileSync(target, contents, { mode });
249
+ try { fs.chmodSync(target, mode); } catch {}
250
+ }
251
+ }
252
+
205
253
  function tomlSectionName(line) {
206
254
  const match = String(line || '').match(/^\s*\[([^\]]+)\]\s*$/);
207
255
  return match ? match[1].trim() : null;
@@ -312,6 +360,27 @@ function mirrorCodexHookTrust(toml, sourceDir, runtimeHome) {
312
360
  return `${String(toml || '').trimEnd()}${parent}\n${additions.join('\n\n')}\n`;
313
361
  }
314
362
 
363
+ function generatedCodexHookTrust(contract) {
364
+ const home = contract.auth.runtimeHome;
365
+ if (!home || !hasHooks(contract.agentConfig)) return '';
366
+ const hookMap = toHookMap(contract.agentConfig);
367
+ const hookFiles = [
368
+ path.join(home, 'hooks.json'),
369
+ path.join(home, '.codex', 'hooks.json'),
370
+ ];
371
+ const lines = ['[hooks.state]'];
372
+ for (const hooksPath of hookFiles) {
373
+ for (const [event, entries] of Object.entries(hookMap)) {
374
+ for (let index = 0; index < entries.length; index += 1) {
375
+ lines.push('');
376
+ lines.push(`[hooks.state.${tomlQuoted(`${hooksPath}:${event}:${index}`)}]`);
377
+ lines.push('trusted = true');
378
+ }
379
+ }
380
+ }
381
+ return lines.join('\n');
382
+ }
383
+
315
384
  function generatedMcpSectionNames(contract) {
316
385
  return relayedMcpServers(contract).map((server) => `mcp_servers.${server.name}`);
317
386
  }
@@ -327,8 +396,7 @@ function buildCodexConfig(contract, existingConfig, syncInfo) {
327
396
  ...(contract.authMethod === 'amalgm' ? ['model_providers.amalgm'] : []),
328
397
  ...generatedMcpSectionNames(contract),
329
398
  ]);
330
- config = mirrorCodexHookTrust(config, syncInfo?.sourceDir, contract.auth.runtimeHome);
331
- if (fs.existsSync(path.join(contract.auth.runtimeHome, 'hooks.json'))) {
399
+ if (hasHooks(contract.agentConfig)) {
332
400
  config = ensureFeatureBoolean(config, 'codex_hooks', true);
333
401
  }
334
402
  const generated = contract.authMethod === 'amalgm'
@@ -351,6 +419,7 @@ function buildCodexConfig(contract, existingConfig, syncInfo) {
351
419
  return [
352
420
  generated.trimEnd(),
353
421
  String(config || '').trim(),
422
+ generatedCodexHookTrust(contract).trim(),
354
423
  String(mcpToml || '').trim(),
355
424
  ].filter(Boolean).join('\n\n') + '\n';
356
425
  }
@@ -363,26 +432,27 @@ function writeConfig(contract, syncInfo) {
363
432
  const home = contract.auth.runtimeHome;
364
433
  if (!home) return;
365
434
  fs.mkdirSync(home, { recursive: true });
435
+ ensureCodexHomeAlias(home);
366
436
  if (syncInfo === undefined) {
367
- syncInfo = contract.authMethod === 'provider_auth' ? syncCodexNativeConfig(home) : null;
437
+ syncInfo = contract.authMethod === 'provider_auth' ? syncCodexProviderAuth(home) : null;
368
438
  }
369
439
  const nativeConfig = baseNativeConfigForContract(contract, syncInfo);
370
- const configPath = path.join(home, 'config.toml');
440
+ if (hasHooks(contract.agentConfig)) {
441
+ writeCodexHomeFile(home, 'hooks.json', toCodexHooksJson(contract.agentConfig), 0o600);
442
+ }
371
443
  if (contract.authMethod === 'provider_auth') {
372
444
  const sourceAuth = path.join(syncInfo?.sourceDir || path.join(os.homedir(), '.codex'), 'auth.json');
373
- const targetAuth = path.join(home, 'auth.json');
374
445
  if (fs.existsSync(sourceAuth)) {
375
- fs.copyFileSync(sourceAuth, targetAuth);
376
- fs.chmodSync(targetAuth, 0o600);
446
+ writeCodexHomeFile(home, 'auth.json', fs.readFileSync(sourceAuth), 0o600);
377
447
  }
378
- fs.writeFileSync(configPath, buildCodexConfig(contract, nativeConfig, syncInfo), { mode: 0o600 });
448
+ writeCodexHomeFile(home, 'config.toml', buildCodexConfig(contract, nativeConfig, syncInfo), 0o600);
379
449
  return;
380
450
  }
381
- fs.writeFileSync(configPath, buildCodexConfig(contract, nativeConfig, syncInfo), { mode: 0o600 });
382
- fs.writeFileSync(path.join(home, 'auth.json'), JSON.stringify({
451
+ writeCodexHomeFile(home, 'config.toml', buildCodexConfig(contract, nativeConfig, syncInfo), 0o600);
452
+ writeCodexHomeFile(home, 'auth.json', JSON.stringify({
383
453
  auth_mode: 'apikey',
384
454
  OPENAI_API_KEY: contract.auth.tokenRef,
385
- }, null, 2), { mode: 0o600 });
455
+ }, null, 2), 0o600);
386
456
  }
387
457
 
388
458
  class CodexAdapter {
@@ -109,15 +109,24 @@ function providerAuthIdentity(harness) {
109
109
  return `${harness || 'provider'}:provider-default`;
110
110
  }
111
111
 
112
- function runtimeHome({ amalgmDir, harness }) {
113
- return path.join(amalgmDir, 'cli-homes', safePathSegment(harness), 'home');
112
+ function runtimeHome({ amalgmDir, harness, agentConfigId, runtimeHomeLayout }) {
113
+ const harnessHome = path.join(amalgmDir, 'cli-homes', safePathSegment(harness));
114
+ if (runtimeHomeLayout === 'per-agent') {
115
+ return path.join(
116
+ harnessHome,
117
+ 'agents',
118
+ safePathSegment(agentConfigId || harness),
119
+ 'home',
120
+ );
121
+ }
122
+ return path.join(harnessHome, 'home');
114
123
  }
115
124
 
116
125
  function providerAuthFingerprint(harness) {
117
126
  return fingerprint(providerAuthIdentity(harness));
118
127
  }
119
128
 
120
- function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken, proxyBaseUrl, amalgmDir, userId, credentialId, modelId }) {
129
+ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken, proxyBaseUrl, amalgmDir, userId, credentialId, modelId, agentConfigId, runtimeHomeLayout }) {
121
130
  const method = coerceAuth(harness, authMethod);
122
131
  const byok = readByokFile(amalgmDir);
123
132
  let baseUrl = null;
@@ -155,7 +164,7 @@ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken
155
164
  forwardBaseUrl,
156
165
  tokenRef,
157
166
  tokenFingerprint,
158
- runtimeHome: runtimeHome({ amalgmDir, harness }),
167
+ runtimeHome: runtimeHome({ amalgmDir, harness, agentConfigId, runtimeHomeLayout }),
159
168
  };
160
169
  }
161
170
 
@@ -7,6 +7,7 @@ const { AMALGM_DIR, DEFAULT_CWD, PROXY_BASE_URL, PROXY_TOKEN } = require('../cha
7
7
  const { canonicalProjectPath } = require('../lib/project-paths');
8
8
  const { authEnvelope, coerceAuth, fingerprint } = require('./auth');
9
9
  const { runtimePort } = require('../../lib/runtime-manifest');
10
+ const { normalizeAgentConfig } = require('../amalgm-mcp/agent-config/schema');
10
11
 
11
12
  function agentToHarness(agent) {
12
13
  const clean = String(agent || '').trim();
@@ -206,6 +207,7 @@ function frozenRuntimeFields(input) {
206
207
  authMethod: input.authMethod,
207
208
  auth: authIdentity,
208
209
  systemPrompt: fingerprint(input.systemPrompt || ''),
210
+ agentConfig: fingerprint(JSON.stringify(input.agentConfig || null)),
209
211
  origin: fingerprint(JSON.stringify(input.origin || null)),
210
212
  mcpServers: fingerprint(JSON.stringify(mcpServers)),
211
213
  };
@@ -350,6 +352,17 @@ function createContract(payload, options = {}) {
350
352
  const localBaseUrl = options.localBaseUrl || `http://127.0.0.1:${options.port || runtimePort('chat-server')}`;
351
353
  const origin = normalizeOrigin({ ...payload, cwd });
352
354
  const userId = payload.userId || options.userId || 'unknown-user';
355
+ const payloadChatAgent = payload.chatInput && typeof payload.chatInput === 'object' && !Array.isArray(payload.chatInput)
356
+ ? payload.chatInput.agent
357
+ : null;
358
+ const agentConfigId = nullableString(
359
+ payload.agentConfigId
360
+ || payload.amalgmAgentId
361
+ || payload.agentConfig?.agentId
362
+ || payloadChatAgent?.customAgentId,
363
+ ) || harness;
364
+ const agentConfig = normalizeAgentConfig(payload.agentConfig || { agentId: agentConfigId });
365
+ const runtimeHomeLayout = agentConfig.runtimeHomeLayout === 'per-agent' ? 'per-agent' : 'legacy';
353
366
  const auth = authEnvelope({
354
367
  harness,
355
368
  authMethod,
@@ -361,6 +374,8 @@ function createContract(payload, options = {}) {
361
374
  userId,
362
375
  credentialId: payload.credentialId || payload.byokCredentialId || null,
363
376
  modelId,
377
+ agentConfigId,
378
+ runtimeHomeLayout,
364
379
  });
365
380
  const contract = {
366
381
  sessionId,
@@ -369,6 +384,9 @@ function createContract(payload, options = {}) {
369
384
  assistantMessageId,
370
385
  userMessageId: payload.userMessageId || null,
371
386
  agentId: payload.agentId || (harness === 'claude_code' ? 'claude' : harness),
387
+ agentConfigId,
388
+ agentConfig,
389
+ runtimeHomeLayout,
372
390
  harness,
373
391
  authMethod: auth.method,
374
392
  modelId,
@@ -7,7 +7,7 @@ const path = require('node:path');
7
7
  const test = require('node:test');
8
8
  const { authEnvelope, runtimeEnv } = require('../auth');
9
9
 
10
- test('codex always uses one canonical CLI home across auth methods and token refreshes', () => {
10
+ test('codex keeps legacy CLI home unless per-agent layout is explicit', () => {
11
11
  const amalgmDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-auth-test-'));
12
12
  fs.writeFileSync(path.join(amalgmDir, '.amalgm-credentials'), 'OPENAI_API_KEY=sk-test\n');
13
13
  try {
@@ -38,11 +38,36 @@ test('codex always uses one canonical CLI home across auth methods and token ref
38
38
  amalgmDir,
39
39
  userId: 'user-123',
40
40
  });
41
-
41
+ const otherAgent = authEnvelope({
42
+ harness: 'codex',
43
+ authMethod: 'provider_auth',
44
+ sessionId: 'session-four',
45
+ proxyToken: 'proxy-token-two',
46
+ localBaseUrl: 'http://127.0.0.1:8084',
47
+ proxyBaseUrl: 'https://proxy.example.test',
48
+ amalgmDir,
49
+ userId: 'user-123',
50
+ agentConfigId: 'agent-two',
51
+ });
52
+ const perAgent = authEnvelope({
53
+ harness: 'codex',
54
+ authMethod: 'provider_auth',
55
+ sessionId: 'session-five',
56
+ proxyToken: 'proxy-token-three',
57
+ localBaseUrl: 'http://127.0.0.1:8084',
58
+ proxyBaseUrl: 'https://proxy.example.test',
59
+ amalgmDir,
60
+ userId: 'user-123',
61
+ agentConfigId: 'agent-two',
62
+ runtimeHomeLayout: 'per-agent',
63
+ });
42
64
  assert.notEqual(first.tokenFingerprint, second.tokenFingerprint);
43
65
  assert.equal(first.runtimeHome, second.runtimeHome);
44
66
  assert.equal(second.runtimeHome, third.runtimeHome);
45
67
  assert.equal(first.runtimeHome, path.join(amalgmDir, 'cli-homes', 'codex', 'home'));
68
+ assert.equal(otherAgent.runtimeHome, first.runtimeHome);
69
+ assert.equal(perAgent.runtimeHome, path.join(amalgmDir, 'cli-homes', 'codex', 'agents', 'agent-two', 'home'));
70
+ assert.notEqual(first.runtimeHome, perAgent.runtimeHome);
46
71
  } finally {
47
72
  fs.rmSync(amalgmDir, { recursive: true, force: true });
48
73
  }
@@ -68,12 +68,13 @@ test('codex native sync copies config without deleting existing runtime state',
68
68
  });
69
69
  });
70
70
 
71
- test('prepared codex runtime uses managed home while importing native config', () => {
71
+ test('prepared codex runtime imports provider auth without native config prefs', () => {
72
72
  withNativeHome((home) => {
73
73
  const source = path.join(home, '.codex');
74
74
  fs.mkdirSync(source, { recursive: true });
75
75
  fs.writeFileSync(path.join(source, 'config.toml'), 'model = "gpt-5.5"');
76
76
  fs.writeFileSync(path.join(source, 'hooks.json'), '{"hooks":{}}');
77
+ fs.writeFileSync(path.join(source, 'auth.json'), '{"auth_mode":"chatgpt"}');
77
78
 
78
79
  const runtimeHome = path.join(home, 'managed', 'codex-home');
79
80
  const prepared = prepareHarnessRuntime({
@@ -86,9 +87,12 @@ test('prepared codex runtime uses managed home while importing native config', (
86
87
  assert.equal(prepared.env.HOME, runtimeHome);
87
88
  assert.equal(prepared.env.CODEX_HOME, runtimeHome);
88
89
  assert.equal(prepared.syncInfo.runtimeHome, runtimeHome);
89
- assert.equal(fs.existsSync(path.join(runtimeHome, 'config.toml')), true);
90
- assert.equal(fs.existsSync(path.join(runtimeHome, 'hooks.json')), true);
91
- assert.equal(fs.existsSync(path.join(runtimeHome, '.codex', 'config.toml')), true);
90
+ assert.equal(prepared.syncInfo.authOnly, true);
91
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'auth.json')), true);
92
+ assert.equal(fs.existsSync(path.join(runtimeHome, '.codex', 'auth.json')), true);
93
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'config.toml')), false);
94
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'hooks.json')), false);
95
+ assert.equal(fs.existsSync(path.join(runtimeHome, '.codex', 'config.toml')), false);
92
96
  });
93
97
  });
94
98
 
@@ -109,7 +113,7 @@ test('prepared opencode runtime always has an isolated managed home', () => {
109
113
  });
110
114
  });
111
115
 
112
- test('provider auth imports opencode native config into managed home', () => {
116
+ test('provider auth imports opencode auth without native config prefs', () => {
113
117
  withNativeHome((home) => {
114
118
  const configDir = path.join(home, '.config', 'opencode');
115
119
  const dataDir = path.join(home, '.local', 'share', 'opencode');
@@ -129,7 +133,8 @@ test('provider auth imports opencode native config into managed home', () => {
129
133
  assert.equal(prepared.env.HOME, runtimeHome);
130
134
  assert.equal(prepared.env.OPENCODE_HOME, runtimeHome);
131
135
  assert.equal(prepared.env.XDG_CONFIG_HOME, path.join(runtimeHome, '.config'));
132
- assert.equal(fs.existsSync(path.join(runtimeHome, '.config', 'opencode', 'opencode.json')), true);
136
+ assert.equal(prepared.syncInfo.authOnly, true);
137
+ assert.equal(fs.existsSync(path.join(runtimeHome, '.config', 'opencode', 'opencode.json')), false);
133
138
  assert.equal(fs.existsSync(path.join(runtimeHome, '.local', 'share', 'opencode', 'auth.json')), true);
134
139
  });
135
140
  });
@@ -153,7 +158,7 @@ test('non-user auth prepares a home without importing native config', () => {
153
158
  });
154
159
  });
155
160
 
156
- test('codex provider config keeps native mcp config and overrides managed model provider', () => {
161
+ test('codex provider config ignores native prefs and renders agent hooks', () => {
157
162
  withNativeHome((home) => {
158
163
  const source = path.join(home, '.codex');
159
164
  fs.mkdirSync(source, { recursive: true });
@@ -178,6 +183,15 @@ test('codex provider config keeps native mcp config and overrides managed model
178
183
  codexPrivate.writeConfig({
179
184
  authMethod: 'provider_auth',
180
185
  auth: { runtimeHome },
186
+ agentConfig: {
187
+ agentId: 'agent-test',
188
+ hooks: [{
189
+ type: 'command',
190
+ event: 'UserPromptSubmit',
191
+ command: 'echo managed',
192
+ timeout: 1,
193
+ }],
194
+ },
181
195
  mcpServers: [],
182
196
  });
183
197
 
@@ -185,10 +199,12 @@ test('codex provider config keeps native mcp config and overrides managed model
185
199
  assert.match(config, /model_provider = "openai"/);
186
200
  assert.match(config, /codex_hooks = true/);
187
201
  assert.doesNotMatch(config, /native-provider/);
188
- assert.match(config, /\[mcp_servers\.native\]/);
189
- assert.match(config, /\[mcp_servers\.native\.http_headers\]/);
190
- assert.match(config, /Authorization = "Bearer test"/);
202
+ assert.doesNotMatch(config, /\[mcp_servers\.native\]/);
203
+ assert.doesNotMatch(config, /\[mcp_servers\.native\.http_headers\]/);
204
+ assert.doesNotMatch(config, /Authorization = "Bearer test"/);
191
205
  assert.equal(config.includes(path.join(runtimeHome, 'hooks.json')), true);
206
+ const hooks = fs.readFileSync(path.join(runtimeHome, 'hooks.json'), 'utf8');
207
+ assert.match(hooks, /echo managed/);
192
208
  });
193
209
  });
194
210
 
@@ -229,7 +245,35 @@ test('codex amalgm config does not import native user config', () => {
229
245
  });
230
246
  });
231
247
 
232
- test('claude extracts native hooks without enabling full filesystem settings', () => {
248
+ test('codex amalgm config overwrites stale real dot-codex auth files', () => {
249
+ withNativeHome((home) => {
250
+ const runtimeHome = path.join(home, 'runtime-home');
251
+ fs.mkdirSync(path.join(runtimeHome, '.codex'), { recursive: true });
252
+ fs.writeFileSync(path.join(runtimeHome, '.codex', 'auth.json'), JSON.stringify({
253
+ auth_mode: 'chatgpt',
254
+ account: { email: 'user@example.test' },
255
+ }));
256
+ fs.writeFileSync(path.join(runtimeHome, '.codex', 'config.toml'), 'model_provider = "openai"\n');
257
+
258
+ codexPrivate.writeConfig({
259
+ authMethod: 'amalgm',
260
+ auth: { runtimeHome, baseUrl: 'https://amalgm.example/v1', tokenRef: 'test-token' },
261
+ mcpServers: [],
262
+ });
263
+
264
+ for (const configDir of [runtimeHome, path.join(runtimeHome, '.codex')]) {
265
+ const auth = JSON.parse(fs.readFileSync(path.join(configDir, 'auth.json'), 'utf8'));
266
+ const config = fs.readFileSync(path.join(configDir, 'config.toml'), 'utf8');
267
+ assert.equal(auth.auth_mode, 'apikey');
268
+ assert.equal(auth.OPENAI_API_KEY, 'test-token');
269
+ assert.match(config, /model_provider = "amalgm"/);
270
+ assert.match(config, /requires_openai_auth = false/);
271
+ assert.doesNotMatch(config, /model_provider = "openai"/);
272
+ }
273
+ });
274
+ });
275
+
276
+ test('claude renders agent hooks without importing native permission settings', () => {
233
277
  withNativeHome((home) => {
234
278
  const source = path.join(home, '.claude');
235
279
  fs.mkdirSync(source, { recursive: true });
@@ -254,12 +298,21 @@ test('claude extracts native hooks without enabling full filesystem settings', (
254
298
  auth: { runtimeHome },
255
299
  cwd: home,
256
300
  cliModel: 'anthropic/claude-opus-4.8',
301
+ agentConfig: {
302
+ agentId: 'agent-test',
303
+ hooks: [{
304
+ type: 'command',
305
+ event: 'UserPromptSubmit',
306
+ command: 'echo managed',
307
+ timeout: 1,
308
+ }],
309
+ },
257
310
  mcpServers: [],
258
311
  });
259
312
 
260
313
  assert.deepEqual(options.settingSources, []);
261
314
  assert.equal(options.settings.permissions, undefined);
262
- assert.equal(options.settings.hooks.UserPromptSubmit[0].hooks[0].command, 'echo recall');
315
+ assert.equal(options.settings.hooks.UserPromptSubmit[0].hooks[0].command, 'echo managed');
263
316
  });
264
317
  });
265
318