@myagentroam/node 0.9.132 → 0.9.134

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.
@@ -9,6 +9,7 @@ const runtimeCredentialCapability = () => ({
9
9
  apiVersion: 1,
10
10
  websocket: true,
11
11
  fastMode: true,
12
+ namespacedTools: true,
12
13
  httpVersions: ['1.1', '2']
13
14
  });
14
15
  const workspaceDirectTransferCapability = () => ({
@@ -52,6 +53,7 @@ export function unavailableCapabilities() {
52
53
  openCode: { available: false },
53
54
  marAgent: { available: true, version: nodeAppVersion },
54
55
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
56
+ chatgptLogin: { apiVersion: 1, loopback: true },
55
57
  workspaceDirectTransfer: workspaceDirectTransferCapability(),
56
58
  terminalDirect: terminalDirectCapability()
57
59
  };
@@ -86,6 +88,7 @@ export function detectCapabilities(commands = {}) {
86
88
  },
87
89
  marAgent: { available: true, version: nodeAppVersion },
88
90
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
91
+ chatgptLogin: { apiVersion: 1, loopback: true },
89
92
  workspaceDirectTransfer: workspaceDirectTransferCapability(),
90
93
  terminalDirect: terminalDirectCapability()
91
94
  };
@@ -124,6 +127,7 @@ export async function detectCapabilitiesAsync(commands = {}) {
124
127
  },
125
128
  marAgent: { available: true, version: nodeAppVersion },
126
129
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
130
+ chatgptLogin: { apiVersion: 1, loopback: true },
127
131
  workspaceDirectTransfer: workspaceDirectTransferCapability(),
128
132
  terminalDirect: terminalDirectCapability()
129
133
  };
package/dist/connector.js CHANGED
@@ -99,6 +99,7 @@ import { safeErrorCode } from './util/safe-error.js';
99
99
  import { RuntimeCredentialControlService } from './service/runtime-credential-control-service.js';
100
100
  import { MarAgentRuntimeModelFactory } from './runner/mar-agent/runtime-model-factory.js';
101
101
  import { MarAgentModelTransportTestService } from './service/mar-agent-model-transport-test-service.js';
102
+ import { ChatgptLoginService } from './service/chatgpt-login-service.js';
102
103
  const HEARTBEAT_MS = 15_000;
103
104
  const CAPABILITY_REFRESH_MS = 60_000;
104
105
  const RETRY_MIN_MS = 500;
@@ -170,6 +171,11 @@ export class NodeConnector {
170
171
  marAgentModelTransportTests = new MarAgentModelTransportTestService({
171
172
  modelFactory: this.marAgentRuntimeModels
172
173
  });
174
+ chatgptLogins = new ChatgptLoginService(() => {
175
+ if (!this.config)
176
+ throw new Error('NODE_CONFIG_UNAVAILABLE');
177
+ return nodeDataDirectory(this.config, this.configPath);
178
+ });
173
179
  /** @internal Existing contract tests inject their socket through this boundary. */
174
180
  set socket(value) {
175
181
  this.controlChannel.replaceSocketForTesting(value);
@@ -1233,6 +1239,7 @@ export class NodeConnector {
1233
1239
  onClose: (code, reason) => {
1234
1240
  this.directTransferService?.close();
1235
1241
  this.runtimeCredentials.disconnected();
1242
+ this.chatgptLogins.close();
1236
1243
  this.scheduledRunAuthorization.disconnected();
1237
1244
  this.mailNotificationControl.disconnected();
1238
1245
  this.mailNotificationMcpServer.revokeAll();
@@ -1453,6 +1460,7 @@ export class NodeConnector {
1453
1460
  this.operationRouter.registerAll(this.sessionCommandService.operations());
1454
1461
  this.operationRouter.registerAll(this.workspaceQueueWorkbenchService.operations());
1455
1462
  this.operationRouter.registerAll(this.marAgentModelTransportTests.operations());
1463
+ this.operationRouter.registerAll(this.chatgptLogins.operations());
1456
1464
  this.operationRouter.registerAll(new ConversationHostingEnablementService({
1457
1465
  config: () => this.config,
1458
1466
  setConfig: (config) => (this.config = config),
@@ -2009,6 +2017,7 @@ export class NodeConnector {
2009
2017
  const terminalDrain = this.terminalManager.shutdown();
2010
2018
  this.terminalChannel.stop();
2011
2019
  this.runtimeCredentials.close();
2020
+ this.chatgptLogins.close();
2012
2021
  this.scheduledSessions.stop();
2013
2022
  this.scheduledRunAuthorization.close();
2014
2023
  this.mailNotificationControl.close();
@@ -433,6 +433,7 @@ async function readTranscript(path, runner, byteLimit, page, targetLines = Numbe
433
433
  const toolIndexes = new Map();
434
434
  const metaRecordIds = new Set();
435
435
  const claudeCompactCommandIds = new Set();
436
+ let codexTurnId;
436
437
  forEachJsonlRecord(content, (value) => {
437
438
  if (derivedTranscriptRecord(runner, value))
438
439
  derivedSession = true;
@@ -444,6 +445,13 @@ async function readTranscript(path, runner, byteLimit, page, targetLines = Numbe
444
445
  claudeCompactCommandIds.add(value['uuid']);
445
446
  });
446
447
  forEachJsonlRecord(truncated ? window.pageContent : content, (value) => {
448
+ if (runner === 'codex' && value['type'] === 'event_msg' && isRecord(value['payload'])) {
449
+ const payload = value['payload'];
450
+ if (payload['type'] === 'task_started')
451
+ codexTurnId = typeof payload['turn_id'] === 'string' ? payload['turn_id'] : undefined;
452
+ else if (payload['type'] === 'task_complete')
453
+ codexTurnId = undefined;
454
+ }
447
455
  const recordId = typeof value['uuid'] === 'string' ? value['uuid'] : undefined;
448
456
  const parentId = typeof value['parentUuid'] === 'string' ? value['parentUuid'] : undefined;
449
457
  if (value['isMeta'] === true && recordId !== undefined)
@@ -469,7 +477,16 @@ async function readTranscript(path, runner, byteLimit, page, targetLines = Numbe
469
477
  if (generatedImage !== undefined)
470
478
  items.push(generatedImage);
471
479
  for (const parsedItem of transcriptItems(runner, value)) {
472
- const item = recordId === undefined ? parsedItem : { ...parsedItem, nativeId: recordId };
480
+ const item = {
481
+ ...parsedItem,
482
+ ...(recordId === undefined ? {} : { nativeId: recordId }),
483
+ ...(runner === 'codex' &&
484
+ parsedItem.kind === 'message' &&
485
+ parsedItem.role === 'USER' &&
486
+ codexTurnId !== undefined
487
+ ? { nativeTurnId: codexTurnId }
488
+ : {})
489
+ };
473
490
  if (item.kind === 'tool_result') {
474
491
  const toolIndex = toolIndexes.get(item.toolUseId);
475
492
  if (toolIndex !== undefined)
@@ -0,0 +1,47 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /** Environment for processes started by this Node, not for the Node service itself. */
5
+ export function nodeExecutionEnvironment(provided = {}, inherited = process.env, options = {}) {
6
+ const environment = Object.fromEntries(Object.entries(inherited).filter((entry) => typeof entry[1] === 'string'));
7
+ Object.assign(environment, provided);
8
+ environment['MAR_NODE_CONTEXT'] = '1';
9
+ const platform = options.platform ?? process.platform;
10
+ const packageRoot = options.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const cliBins = [join(packageRoot, 'node_modules', '.bin')];
12
+ const nodeModules = resolve(packageRoot, '..', '..');
13
+ if (basename(packageRoot) === 'node' &&
14
+ basename(dirname(packageRoot)) === '@myagentroam' &&
15
+ basename(nodeModules) === 'node_modules') {
16
+ // npm prefix installs and pnpm's virtual store place shims beside scoped packages.
17
+ cliBins.push(join(nodeModules, '.bin'));
18
+ const prefix = dirname(nodeModules);
19
+ if (platform === 'win32')
20
+ cliBins.push(prefix);
21
+ else if (basename(prefix) === 'lib')
22
+ cliBins.push(resolve(prefix, '..', 'bin'));
23
+ }
24
+ const cliBin = cliBins.find((bin) => existsSync(join(bin, platform === 'win32' ? 'marnodes.cmd' : 'marnodes')));
25
+ if (cliBin === undefined)
26
+ return environment;
27
+ const separator = platform === 'win32' ? ';' : ':';
28
+ const existingKeys = Object.keys(environment).filter((key) => key.toLowerCase() === 'path');
29
+ const key = platform === 'win32'
30
+ ? (Object.keys(provided).find((name) => name.toLowerCase() === 'path') ??
31
+ existingKeys[0] ??
32
+ 'Path')
33
+ : 'PATH';
34
+ const current = environment[key] ?? environment[existingKeys[0] ?? key] ?? '';
35
+ if (platform === 'win32')
36
+ for (const other of existingKeys)
37
+ if (other !== key)
38
+ delete environment[other];
39
+ const samePath = (entry) => platform === 'win32'
40
+ ? entry.replaceAll('/', '\\').toLowerCase() === cliBin.replaceAll('/', '\\').toLowerCase()
41
+ : entry === cliBin;
42
+ environment[key] = [
43
+ cliBin,
44
+ ...current.split(separator).filter((entry) => entry && !samePath(entry))
45
+ ].join(separator);
46
+ return environment;
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { mcpRuntimeDescriptorSchema } from '@myagentroam/protocol';
2
+ import { nodeExecutionEnvironment } from '../node-execution-environment.js';
2
3
  /**
3
4
  * Stable lifecycle boundary for every Node Runner implementation.
4
5
  *
@@ -175,6 +176,7 @@ function resolvedMcpServers(raw, secrets) {
175
176
  : { MAR_MGR_USER_TOKEN: secrets['MAR_MGR_USER_TOKEN'] }),
176
177
  MAR_MGR_CONTEXT_REQUIRED: '1'
177
178
  };
179
+ const mcpEnvironment = (provided) => nodeExecutionEnvironment({ ...provided, ...marmgrEnvironment }, { PATH: process.env['PATH'], Path: process.env['Path'] });
178
180
  const result = [];
179
181
  for (const value of raw) {
180
182
  if (value === null || typeof value !== 'object' || Array.isArray(value))
@@ -197,24 +199,21 @@ function resolvedMcpServers(raw, secrets) {
197
199
  transport: 'STDIO',
198
200
  command: record['command'],
199
201
  args: record['args'],
200
- environment: record['__marGoal'] === true
202
+ environment: mcpEnvironment(record['__marGoal'] === true
201
203
  ? {
202
204
  MAR_GOAL_MCP_ENDPOINT: record['endpoint'],
203
- MAR_GOAL_MCP_TOKEN: secrets[record['secretName']],
204
- ...marmgrEnvironment
205
+ MAR_GOAL_MCP_TOKEN: secrets[record['secretName']]
205
206
  }
206
207
  : record['__marScheduled'] === true
207
208
  ? {
208
209
  MAR_SCHEDULE_MCP_ENDPOINT: record['endpoint'],
209
210
  MAR_SCHEDULE_MCP_TOKEN: secrets[record['secretName']],
210
- MAR_SCHEDULE_MCP_READ_ONLY: record['readOnly'] === true ? '1' : '0',
211
- ...marmgrEnvironment
211
+ MAR_SCHEDULE_MCP_READ_ONLY: record['readOnly'] === true ? '1' : '0'
212
212
  }
213
213
  : {
214
214
  MAR_MAIL_MCP_ENDPOINT: record['endpoint'],
215
- MAR_MAIL_MCP_TOKEN: secrets[record['secretName']],
216
- ...marmgrEnvironment
217
- }
215
+ MAR_MAIL_MCP_TOKEN: secrets[record['secretName']]
216
+ })
218
217
  });
219
218
  continue;
220
219
  }
@@ -251,11 +250,10 @@ function resolvedMcpServers(raw, secrets) {
251
250
  ? configuration['arguments'].filter((item) => typeof item === 'string')
252
251
  : [])
253
252
  ],
254
- environment: {
253
+ environment: mcpEnvironment({
255
254
  ...runtimeEnvironment,
256
- ...configuredEnvironment,
257
- ...marmgrEnvironment
258
- }
255
+ ...configuredEnvironment
256
+ })
259
257
  });
260
258
  }
261
259
  else {
@@ -1,5 +1,6 @@
1
1
  import { createEnvelope } from '@myagentroam/protocol';
2
2
  import { ClaudeChannelGate } from '../../claude-channel.js';
3
+ import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
3
4
  import { boundedConversationItemId, claudePlanText, compactRunnerText, extractClaudeText, extractClaudeThinkingSummaries, extractClaudeToolResults, extractClaudeToolUses } from '../codex/conversation-parser.js';
4
5
  import { normalizeClaudeTool } from './tool-normalizer.js';
5
6
  import { claudeContentBlocks } from '../../util/node-operation-parsers.js';
@@ -170,7 +171,10 @@ export class ClaudeManagedRunController {
170
171
  ...(typeof payload.model === 'string' ? { model: payload.model } : {}),
171
172
  ...(typeof payload.effort === 'string' ? { effort: payload.effort } : {}),
172
173
  ...(typeof payload.access === 'string' ? { access: payload.access } : {}),
173
- environment: { ...secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' },
174
+ environment: nodeExecutionEnvironment({
175
+ ...secretEnvironment,
176
+ MAR_MGR_CONTEXT_REQUIRED: '1'
177
+ }, { PATH: process.env['PATH'], Path: process.env['Path'] }),
174
178
  ...(typeof payload.personalInstructions === 'string'
175
179
  ? { personalInstructions: payload.personalInstructions }
176
180
  : {}),
@@ -1,3 +1,4 @@
1
+ import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
1
2
  import { extractId } from '../../util/runner-native-session-parsers.js';
2
3
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
3
4
  import { safeErrorCode } from '../../util/safe-error.js';
@@ -65,7 +66,7 @@ export class CodexManagedRunController {
65
66
  throw new Error('RUNNER_COMMAND_UNAVAILABLE');
66
67
  if (command.id === 'compact') {
67
68
  const runId = crypto.randomUUID();
68
- this.runner.prepareEnvironment(context.secretEnvironment, runId);
69
+ this.runner.prepareEnvironment(nodeExecutionEnvironment({ ...context.secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }), runId);
69
70
  try {
70
71
  await this.runner.start(runId);
71
72
  return await this.compact(session, threadId, runId, context.initiatedByUserId);
@@ -385,7 +386,7 @@ export class CodexManagedRunController {
385
386
  return;
386
387
  }
387
388
  try {
388
- this.runner.prepareEnvironment({ ...secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' }, runId);
389
+ this.runner.prepareEnvironment(nodeExecutionEnvironment({ ...secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }), runId);
389
390
  }
390
391
  catch (error) {
391
392
  this.host.emit(runId, 'run.rejected', { code: safeErrorCode(error, 'SECRET_ENVIRONMENT_INVALID') }, 'FAILED');
@@ -2,6 +2,7 @@ import { computerUseHostSkill } from './computer-use-skill.js';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import { access, constants } from 'node:fs/promises';
5
+ import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
5
6
  import { BUNDLED_RIPGREP_VERSION, bundledRipgrepPath, prependBundledRipgrepToPath } from '../../ripgrep-binary.js';
6
7
  import { LocalAgentHost, createMarAgent } from '@myagentroam/agent';
7
8
  import { marAgentReasoningEffortSchema, marAgentRuntimeModelConfigurationSchema } from '@myagentroam/protocol';
@@ -686,7 +687,10 @@ export function allowsUntrustedWebFetchTls(environment) {
686
687
  return environment['MAR_AGENT_WEB_FETCH_ALLOW_UNTRUSTED_TLS'] !== '0';
687
688
  }
688
689
  async function resolveMarAgentHostRuntimeConfiguration(secretEnvironment = {}) {
689
- const environment = { ...process.env, ...secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' };
690
+ const environment = nodeExecutionEnvironment({
691
+ ...secretEnvironment,
692
+ MAR_MGR_CONTEXT_REQUIRED: '1'
693
+ });
690
694
  const ripgrep = await availableBundledRipgrep();
691
695
  return {
692
696
  environment: ripgrep === undefined ? environment : prependBundledRipgrepToPath(environment, ripgrep),
@@ -22,6 +22,7 @@ class PendingCredentialResolution {
22
22
  }
23
23
  }
24
24
  const CREDENTIAL_REFRESH_WINDOW_MS = 60_000;
25
+ const CREDENTIAL_REFRESH_RETRY_MS = 30_000;
25
26
  export class MarAgentRuntimeModelFactory {
26
27
  control;
27
28
  now;
@@ -73,6 +74,7 @@ export class MarAgentRuntimeModelFactory {
73
74
  ...(configuration.responsesEncoding === undefined
74
75
  ? {}
75
76
  : { responsesEncoding: configuration.responsesEncoding }),
77
+ responsesToolNamespace: configuration.responsesToolNamespace,
76
78
  responsesPreviousResponseId: configuration.responsesPreviousResponseId,
77
79
  enabled: configuration.enabled
78
80
  };
@@ -229,12 +231,12 @@ class ServerRuntimeCredentialProvider {
229
231
  if (this.state.pending !== pending)
230
232
  return;
231
233
  delete this.state.pending;
232
- scheduleRefresh(this.state, this.now);
234
+ scheduleRefresh(this.state, this.now, CREDENTIAL_REFRESH_RETRY_MS);
233
235
  }, () => {
234
236
  if (this.state.pending !== pending)
235
237
  return;
236
238
  delete this.state.pending;
237
- scheduleRefresh(this.state, this.now, false);
239
+ scheduleRefresh(this.state, this.now, CREDENTIAL_REFRESH_RETRY_MS);
238
240
  });
239
241
  return pending;
240
242
  }
@@ -296,20 +298,23 @@ function sameRuntimeCredentialTicket(first, second) {
296
298
  first.allowedModelIds.length === second.allowedModelIds.length &&
297
299
  first.allowedModelIds.every((modelId, index) => modelId === second.allowedModelIds[index]));
298
300
  }
299
- function scheduleRefresh(state, now, includeDue = true) {
301
+ function scheduleRefresh(state, now, minimumDelayMs = 0) {
300
302
  clearRefreshTimer(state);
301
303
  if (state.released || state.pending !== undefined || state.providers.size === 0)
302
304
  return;
305
+ const timestamp = now();
306
+ if (state.ticket.expiresAt <= timestamp)
307
+ return;
303
308
  const expiresAt = Math.min(state.ticket.expiresAt, state.credential?.expiresAt ?? Number.POSITIVE_INFINITY);
304
- const delay = expiresAt - now() - CREDENTIAL_REFRESH_WINDOW_MS;
305
- if (!includeDue && delay <= 0)
309
+ const delay = Math.max(minimumDelayMs, expiresAt - timestamp - CREDENTIAL_REFRESH_WINDOW_MS, 0);
310
+ if (timestamp + delay > state.ticket.expiresAt)
306
311
  return;
307
312
  const timer = setTimeout(() => {
308
313
  if (state.refreshTimer === timer)
309
314
  delete state.refreshTimer;
310
315
  const provider = state.providers.values().next().value;
311
316
  provider?.refreshAutomatically();
312
- }, Math.max(0, delay));
317
+ }, delay);
313
318
  timer.unref();
314
319
  state.refreshTimer = timer;
315
320
  }
@@ -221,24 +221,36 @@ export class MarAgentRunner extends AbstractRunner {
221
221
  let catalog;
222
222
  try {
223
223
  catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
224
- const listed = await catalog.list({ limit: 200 });
224
+ const activeCatalog = catalog;
225
+ const listed = await activeCatalog.list({ limit: 200 });
225
226
  const workspaces = workspace ? [workspace] : context.workspaces;
226
- return listed.sessions.flatMap((session) => {
227
+ return (await Promise.all(listed.sessions.map(async (session) => {
227
228
  const target = workspaces.find((candidate) => resolve(candidate.path) === resolve(session.workspace));
228
229
  if (!target)
229
230
  return [];
231
+ const executionConfiguration = session.modelId === undefined
232
+ ? await activeCatalog.executionConfiguration(session.id)
233
+ : session;
230
234
  return [
231
- context.createProjection({
232
- workspace: target,
233
- runner: this.name,
234
- externalSessionId: session.id,
235
- cwd: session.workspace,
236
- title: session.title,
237
- titleOrigin: 'NATIVE',
238
- lastActivityAt: Date.parse(session.lastActivityAt)
239
- })
235
+ {
236
+ ...context.createProjection({
237
+ workspace: target,
238
+ runner: this.name,
239
+ externalSessionId: session.id,
240
+ cwd: session.workspace,
241
+ title: session.title,
242
+ titleOrigin: 'NATIVE',
243
+ lastActivityAt: Date.parse(session.lastActivityAt)
244
+ }),
245
+ ...(executionConfiguration?.modelId === undefined
246
+ ? {}
247
+ : { model: executionConfiguration.modelId }),
248
+ ...(executionConfiguration?.reasoningEffort === undefined
249
+ ? {}
250
+ : { effort: executionConfiguration.reasoningEffort })
251
+ }
240
252
  ];
241
- });
253
+ }))).flat();
242
254
  }
243
255
  catch {
244
256
  return [];
@@ -1,3 +1,4 @@
1
+ import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
1
2
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
2
3
  import { runnerErrorProjection } from '../../util/runner-error.js';
3
4
  import { boundedConversationItemId, compactRunnerText } from '../codex/conversation-parser.js';
@@ -49,7 +50,7 @@ export class OpenCodeManagedRunController {
49
50
  this.host.hasActiveSessionLease(session.id))
50
51
  throw new Error('RUNNER_COMMAND_UNAVAILABLE');
51
52
  const runId = crypto.randomUUID();
52
- const client = this.runner.managedClient(environment);
53
+ const client = this.runner.managedClient(nodeExecutionEnvironment({ ...environment, MAR_MGR_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }));
53
54
  this.host.adopt({
54
55
  runId,
55
56
  sessionId: session.id,
@@ -108,10 +109,10 @@ export class OpenCodeManagedRunController {
108
109
  let mcp;
109
110
  let attachments;
110
111
  try {
111
- environment = {
112
+ environment = nodeExecutionEnvironment({
112
113
  ...parseSecretEnvironment(payload.secretEnvironment),
113
114
  MAR_MGR_CONTEXT_REQUIRED: '1'
114
- };
115
+ }, { PATH: process.env['PATH'], Path: process.env['Path'] });
115
116
  mcpSecretEnvironment = parseSecretEnvironment(payload.mcpSecretEnvironment);
116
117
  mcp = this.runner.mcpConfiguration(payload.mcpInstallations, {
117
118
  ...environment,
@@ -0,0 +1,345 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { requestHttp } from '@myagentroam/agent/host/network';
6
+ import { CHATGPT_ISSUER, CHATGPT_RESOURCE, CHATGPT_TOKEN_ENDPOINT, CHATGPT_JWKS_ENDPOINT, CHATGPT_REQUESTED_SCOPE, chatgptLoginStartSchema, chatgptTokenResponseSchema, verifyChatgptIdentity } from '@myagentroam/protocol';
7
+ const TTL_MS = 10 * 60_000;
8
+ /** Temporary, loopback-only OAuth provider. Only the Server may consume token results. */
9
+ export class ChatgptLoginService {
10
+ dataDirectory;
11
+ request;
12
+ attempts = new Map();
13
+ starting = new Map();
14
+ hostIdFlight;
15
+ constructor(dataDirectory, request = (url, init, proxy, signal) => requestHttp(url, { request: init, httpVersion: '1.1', ...(proxy ? { socks5Url: proxy } : {}) }, signal ?? AbortSignal.timeout(30_000), 30_000)) {
16
+ this.dataDirectory = dataDirectory;
17
+ this.request = request;
18
+ }
19
+ operations() {
20
+ return {
21
+ 'chatgpt.login.start': (raw) => this.start(raw),
22
+ 'chatgpt.login.status': (raw) => this.status(raw),
23
+ 'chatgpt.login.ack': (raw) => this.remove(raw),
24
+ 'chatgpt.login.cancel': (raw) => this.remove(raw)
25
+ };
26
+ }
27
+ async start(raw) {
28
+ const { data, userId, session } = this.context(raw);
29
+ const parsed = chatgptLoginStartSchema.safeParse(data);
30
+ if (!parsed.success)
31
+ throw new Error('CHATGPT_LOGIN_INPUT_INVALID');
32
+ const input = parsed.data;
33
+ if (this.attempts.has(input.attemptId))
34
+ return this.status(raw);
35
+ const existing = this.starting.get(input.attemptId);
36
+ if (existing) {
37
+ if (existing.userId !== userId || existing.session !== session)
38
+ throw new Error('CHATGPT_LOGIN_FORBIDDEN');
39
+ await existing.flight;
40
+ return this.status(raw);
41
+ }
42
+ if (this.attempts.size + this.starting.size >= 64)
43
+ throw new Error('CHATGPT_LOGIN_LIMIT_REACHED');
44
+ if (input.socks5Url !== undefined && new URL(input.socks5Url).protocol !== 'socks5:')
45
+ throw new Error('CHATGPT_LOGIN_INPUT_INVALID');
46
+ const pending = { userId, session, cancelled: false };
47
+ this.starting.set(input.attemptId, pending);
48
+ pending.flight = this.openAttempt(input, pending);
49
+ try {
50
+ await pending.flight;
51
+ return this.status(raw);
52
+ }
53
+ finally {
54
+ if (this.starting.get(input.attemptId) === pending)
55
+ this.starting.delete(input.attemptId);
56
+ }
57
+ }
58
+ async openAttempt(input, pending) {
59
+ const hostId = await this.hostId();
60
+ if (pending.cancelled)
61
+ throw new Error('CHATGPT_LOGIN_CANCELLED');
62
+ const server = createServer((req, res) => {
63
+ void this.handle(input.attemptId, req, res).catch(() => {
64
+ if (!res.headersSent)
65
+ res.writeHead(500);
66
+ res.end('Login failed.');
67
+ });
68
+ });
69
+ server.maxHeadersCount = 32;
70
+ server.headersTimeout = 10_000;
71
+ server.requestTimeout = 10_000;
72
+ await new Promise((resolve, reject) => {
73
+ server.once('error', reject);
74
+ server.listen(0, '127.0.0.1', () => {
75
+ server.removeListener('error', reject);
76
+ resolve();
77
+ });
78
+ });
79
+ const address = server.address();
80
+ if (pending.cancelled) {
81
+ server.close();
82
+ server.closeAllConnections();
83
+ throw new Error('CHATGPT_LOGIN_CANCELLED');
84
+ }
85
+ if (address === null || typeof address === 'string') {
86
+ server.close();
87
+ throw new Error('CHATGPT_LISTENER_FAILED');
88
+ }
89
+ const attempt = {
90
+ id: input.attemptId,
91
+ userId: pending.userId,
92
+ session: pending.session,
93
+ nonce: input.nonce,
94
+ state: randomBytes(32).toString('base64url'),
95
+ entry: randomBytes(32).toString('base64url'),
96
+ verifier: randomBytes(48).toString('base64url'),
97
+ clientId: input.clientId,
98
+ subject: input.subject,
99
+ proxy: input.socks5Url,
100
+ hostId,
101
+ server,
102
+ expiresAt: Date.now() + TTL_MS,
103
+ redirectUri: `http://127.0.0.1:${address.port}/auth/callback`,
104
+ abort: new AbortController(),
105
+ started: false,
106
+ exchanging: false,
107
+ timer: setTimeout(() => this.clear(input.attemptId), TTL_MS)
108
+ };
109
+ attempt.timer.unref();
110
+ this.attempts.set(attempt.id, attempt);
111
+ }
112
+ status(raw) {
113
+ const attempt = this.owned(raw);
114
+ if (attempt.result !== undefined)
115
+ return { status: 'SUCCEEDED', result: attempt.result };
116
+ if (attempt.error !== undefined)
117
+ return { status: 'FAILED', code: attempt.error };
118
+ return {
119
+ status: 'PENDING',
120
+ verificationUri: attempt.redirectUri.replace('/auth/callback', `/auth/start?ticket=${attempt.entry}`),
121
+ expiresAt: attempt.expiresAt
122
+ };
123
+ }
124
+ async remove(raw) {
125
+ const { data, userId, session } = this.context(raw);
126
+ const id = data['attemptId'];
127
+ const pending = typeof id === 'string' ? this.starting.get(id) : undefined;
128
+ if (pending) {
129
+ if (pending.userId !== userId || pending.session !== session)
130
+ throw new Error('CHATGPT_LOGIN_FORBIDDEN');
131
+ pending.cancelled = true;
132
+ await pending.flight?.catch(() => undefined);
133
+ return { cleared: true };
134
+ }
135
+ const attempt = this.owned(raw);
136
+ this.clear(attempt.id);
137
+ return { cleared: true };
138
+ }
139
+ close() {
140
+ for (const pending of this.starting.values())
141
+ pending.cancelled = true;
142
+ this.starting.clear();
143
+ for (const id of this.attempts.keys())
144
+ this.clear(id);
145
+ }
146
+ clear(id) {
147
+ const attempt = this.attempts.get(id);
148
+ if (!attempt)
149
+ return;
150
+ this.attempts.delete(id);
151
+ clearTimeout(attempt.timer);
152
+ attempt.abort.abort();
153
+ attempt.server.close();
154
+ attempt.server.closeAllConnections();
155
+ attempt.result = undefined;
156
+ attempt.verifier = '';
157
+ attempt.entry = '';
158
+ }
159
+ context(raw) {
160
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
161
+ throw new Error('CHATGPT_LOGIN_INPUT_INVALID');
162
+ const data = { ...raw };
163
+ const userId = data['__requestUserId'], session = data['__workspaceWatchSessionHash'];
164
+ delete data['__requestUserId'];
165
+ delete data['__workspaceWatchSessionHash'];
166
+ if (!Number.isSafeInteger(userId) ||
167
+ userId < 1 ||
168
+ typeof session !== 'string' ||
169
+ session.length < 1)
170
+ throw new Error('CHATGPT_LOGIN_FORBIDDEN');
171
+ return { data, userId: userId, session };
172
+ }
173
+ owned(raw) {
174
+ const { data, userId, session } = this.context(raw);
175
+ const id = data['attemptId'];
176
+ const attempt = typeof id === 'string' ? this.attempts.get(id) : undefined;
177
+ if (!attempt ||
178
+ attempt.userId !== userId ||
179
+ attempt.session !== session ||
180
+ Date.now() >= attempt.expiresAt)
181
+ throw new Error('CHATGPT_LOGIN_NOT_FOUND');
182
+ return attempt;
183
+ }
184
+ hostId() {
185
+ this.hostIdFlight ??= this.loadHostId().catch((error) => {
186
+ this.hostIdFlight = undefined;
187
+ throw error;
188
+ });
189
+ return this.hostIdFlight;
190
+ }
191
+ async loadHostId() {
192
+ const directory = this.dataDirectory(), path = join(directory, 'chatgpt-host-id');
193
+ await mkdir(directory, { recursive: true, mode: 0o700 });
194
+ try {
195
+ await writeFile(path, `urn:uuid:${randomUUID()}`, { flag: 'wx', mode: 0o600 });
196
+ }
197
+ catch (error) {
198
+ if (error.code !== 'EEXIST')
199
+ throw new Error('CHATGPT_HOST_ID_FAILED');
200
+ }
201
+ const id = await readFile(path, 'utf8');
202
+ if (!/^urn:uuid:[0-9a-f-]{36}$/u.test(id))
203
+ throw new Error('CHATGPT_HOST_ID_INVALID');
204
+ return id;
205
+ }
206
+ async json(url, init, attempt) {
207
+ const response = await this.request(url, { ...init, redirect: 'error' }, attempt.proxy, AbortSignal.any([attempt.abort.signal, AbortSignal.timeout(30_000)]));
208
+ if (!response.ok) {
209
+ await response.body?.cancel();
210
+ throw new Error('CHATGPT_TOKEN_EXCHANGE_FAILED');
211
+ }
212
+ const reader = response.body?.getReader();
213
+ if (!reader)
214
+ throw new Error('CHATGPT_TOKEN_RESPONSE_INVALID');
215
+ let length = 0;
216
+ const chunks = [];
217
+ try {
218
+ for (;;) {
219
+ const { done, value } = await reader.read();
220
+ if (done)
221
+ break;
222
+ length += value.length;
223
+ if (length > 524_288)
224
+ throw new Error('CHATGPT_TOKEN_RESPONSE_INVALID');
225
+ chunks.push(value);
226
+ }
227
+ const bytes = new Uint8Array(length);
228
+ let offset = 0;
229
+ for (const chunk of chunks) {
230
+ bytes.set(chunk, offset);
231
+ offset += chunk.length;
232
+ }
233
+ return JSON.parse(new TextDecoder().decode(bytes));
234
+ }
235
+ finally {
236
+ await reader.cancel().catch(() => undefined);
237
+ }
238
+ }
239
+ async handle(id, req, res) {
240
+ res.setHeader('cache-control', 'no-store');
241
+ res.setHeader('referrer-policy', 'no-referrer');
242
+ res.setHeader('content-security-policy', "default-src 'none'; frame-ancestors 'none'");
243
+ const attempt = this.attempts.get(id);
244
+ if (!attempt ||
245
+ req.method !== 'GET' ||
246
+ !req.url ||
247
+ req.url.length > 16_384 ||
248
+ req.headers.host !== new URL(attempt.redirectUri).host ||
249
+ Date.now() >= attempt.expiresAt) {
250
+ res.writeHead(400);
251
+ res.end('Invalid login request.');
252
+ return;
253
+ }
254
+ const url = new URL(req.url, attempt.redirectUri);
255
+ if (url.pathname === '/auth/start' &&
256
+ !attempt.started &&
257
+ url.searchParams.getAll('ticket').length === 1 &&
258
+ url.searchParams.get('ticket') === attempt.entry) {
259
+ attempt.started = true;
260
+ attempt.entry = '';
261
+ const authorize = new URL(`${CHATGPT_ISSUER}/api/accounts/authorize`);
262
+ authorize.search = new URLSearchParams({
263
+ response_type: 'code',
264
+ client_id: attempt.clientId ?? 'dynamic_agent_client',
265
+ ext_agent_host_id: attempt.hostId,
266
+ scope: CHATGPT_REQUESTED_SCOPE,
267
+ resource: CHATGPT_RESOURCE,
268
+ redirect_uri: attempt.redirectUri,
269
+ state: attempt.state,
270
+ nonce: attempt.nonce,
271
+ code_challenge_method: 'S256',
272
+ code_challenge: createHash('sha256').update(attempt.verifier).digest('base64url'),
273
+ ...(attempt.clientId ? {} : { agent_name_hint: 'MyAgentRoam' })
274
+ }).toString();
275
+ res.writeHead(302, { location: authorize.href });
276
+ res.end();
277
+ return;
278
+ }
279
+ if (url.pathname !== '/auth/callback' ||
280
+ !attempt.started ||
281
+ attempt.exchanging ||
282
+ attempt.result ||
283
+ attempt.error ||
284
+ url.searchParams.getAll('state').length !== 1 ||
285
+ url.searchParams.get('state') !== attempt.state) {
286
+ res.writeHead(400);
287
+ res.end('Invalid login callback.');
288
+ return;
289
+ }
290
+ attempt.exchanging = true;
291
+ try {
292
+ if (url.searchParams.has('error'))
293
+ throw new Error('CHATGPT_AUTHORIZATION_DENIED');
294
+ const callbackClient = url.searchParams.get('client_id');
295
+ const clientId = callbackClient ?? attempt.clientId;
296
+ const code = url.searchParams.get('code');
297
+ if (!clientId ||
298
+ clientId === 'dynamic_agent_client' ||
299
+ clientId.length > 512 ||
300
+ !code ||
301
+ code.length > 8192 ||
302
+ url.searchParams.getAll('code').length !== 1 ||
303
+ url.searchParams.getAll('client_id').length > 1 ||
304
+ (attempt.clientId && clientId !== attempt.clientId))
305
+ throw new Error('CHATGPT_CALLBACK_INVALID');
306
+ const raw = await this.json(CHATGPT_TOKEN_ENDPOINT, {
307
+ method: 'POST',
308
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
309
+ body: new URLSearchParams({
310
+ grant_type: 'authorization_code',
311
+ client_id: clientId,
312
+ code,
313
+ code_verifier: attempt.verifier,
314
+ redirect_uri: attempt.redirectUri,
315
+ resource: CHATGPT_RESOURCE
316
+ }).toString()
317
+ }, attempt);
318
+ const parsed = chatgptTokenResponseSchema.safeParse(raw);
319
+ if (!parsed.success || !parsed.data.id_token)
320
+ throw new Error('CHATGPT_TOKEN_RESPONSE_INVALID');
321
+ const jwks = await this.json(CHATGPT_JWKS_ENDPOINT, { method: 'GET' }, attempt);
322
+ await verifyChatgptIdentity(parsed.data.id_token, jwks, {
323
+ clientId,
324
+ nonce: attempt.nonce,
325
+ subject: attempt.subject
326
+ });
327
+ if (this.attempts.get(id) !== attempt || attempt.abort.signal.aborted)
328
+ throw new Error('CHATGPT_LOGIN_CANCELLED');
329
+ attempt.result = { attemptId: id, clientId, hostId: attempt.hostId, tokens: parsed.data };
330
+ res.end('Login completed. Return to MyAgentRoam to save the credential.');
331
+ }
332
+ catch (error) {
333
+ attempt.error =
334
+ error instanceof Error && /^CHATGPT_[A-Z0-9_]+$/u.test(error.message)
335
+ ? error.message
336
+ : 'CHATGPT_LOGIN_FAILED';
337
+ res.writeHead(400);
338
+ res.end('Login failed. Return to MyAgentRoam to retry.');
339
+ }
340
+ finally {
341
+ attempt.verifier = '';
342
+ attempt.server.close();
343
+ }
344
+ }
345
+ }
@@ -369,20 +369,22 @@ export class ConversationHistoryService {
369
369
  }
370
370
  async readManagedRuntime(session, input, cursor, snapshotSequence, readAt) {
371
371
  const page = await this.readManagedActive(session, input, cursor, snapshotSequence, readAt);
372
+ const codexActive = session.runner === 'codex' && this.options.hasManagedActiveRun(session);
372
373
  if (input.cursor !== undefined ||
373
374
  session.externalSessionId === null ||
374
- this.options.hasManagedActiveRun(session) ||
375
- !this.hasManagedRuntimeHistory(session) ||
375
+ (!codexActive &&
376
+ (this.options.hasManagedActiveRun(session) || !this.hasManagedRuntimeHistory(session))) ||
376
377
  !page.turns.some((turn) => turn.rewind === undefined &&
377
378
  terminalConversationTurn(turn.status) &&
378
379
  conversationClientMessageId(turn) !== null))
379
380
  return page;
380
381
  // A completed managed Turn keeps its live items and Run identity until Runtime cleanup.
381
- // Only the SDK's matched native Turn may supply a destructive history boundary.
382
- const official = await this.readOfficial(session, {
383
- limit: Math.min(500, Math.max(30, page.turns.length + 1))
384
- }).catch(() => undefined);
385
- if (official === undefined)
382
+ // Only its matched native Turn may supply a Rewind boundary.
383
+ const historyInput = { limit: Math.min(500, Math.max(30, page.turns.length + 1)) };
384
+ const authoritative = codexActive
385
+ ? (await this.readRunnerHistory(session, historyInput).catch(() => undefined))?.page
386
+ : await this.readOfficial(session, historyInput).catch(() => undefined);
387
+ if (authoritative === undefined)
386
388
  return page;
387
389
  return {
388
390
  ...page,
@@ -392,7 +394,7 @@ export class ConversationHistoryService {
392
394
  const messageId = conversationClientMessageId(turn);
393
395
  if (messageId === null)
394
396
  return turn;
395
- const native = official.turns.find((candidate) => candidate.rewind !== undefined &&
397
+ const native = authoritative.turns.find((candidate) => candidate.rewind !== undefined &&
396
398
  candidate.userItemId !== null &&
397
399
  conversationClientMessageId(candidate) === messageId);
398
400
  return native?.rewind === undefined ? turn : { ...turn, rewind: native.rewind };
package/dist/terminal.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawn } from 'node-pty';
7
7
  import { terminalDataFrameSchema } from '@myagentroam/protocol';
8
+ import { nodeExecutionEnvironment } from './node-execution-environment.js';
8
9
  import { terminateProcessTree } from './process-tree.js';
9
10
  const MAX_FRAME_CHARACTERS = 65_536;
10
11
  const TERMINAL_COMMAND_MAX_BYTES = 64 * 1024;
@@ -138,7 +139,7 @@ export class TerminalManager {
138
139
  cols,
139
140
  rows,
140
141
  cwd: typeof input.cwd === 'string' ? input.cwd : this.cwd,
141
- env: { ...process.env, ...input.environment }
142
+ env: nodeExecutionEnvironment(input.environment, process.env, { platform: this.platform })
142
143
  };
143
144
  const commandArgs = command === undefined
144
145
  ? undefined
@@ -182,6 +182,11 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
182
182
  const startedAt = items[0]?.startedAt ?? null;
183
183
  const completedAt = items.at(-1)?.completedAt ?? startedAt;
184
184
  const after = lastNativeItemId(entries);
185
+ const nativeIndex = start + relativeIndex;
186
+ const turnBoundaryAfter = nativeUser?.kind === 'message' ? nativeUser.nativeTurnId : undefined;
187
+ const previousNativeUser = nativeTurns[nativeIndex - 1]?.find(({ entry }) => entry.kind === 'message' && entry.role === 'USER')?.entry;
188
+ const turnBoundaryBefore = previousNativeUser?.kind === 'message' ? previousNativeUser.nativeTurnId : undefined;
189
+ const firstNativeTurn = nativeIndex === 0 && history.truncated !== true;
185
190
  return {
186
191
  id: turnId,
187
192
  sessionId: session.id,
@@ -199,6 +204,10 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
199
204
  after
200
205
  }
201
206
  }),
207
+ ...(turnBoundaryAfter === undefined ||
208
+ (!firstNativeTurn && turnBoundaryBefore === undefined)
209
+ ? {}
210
+ : { rewind: { before: turnBoundaryBefore ?? null, after: turnBoundaryAfter } }),
202
211
  items,
203
212
  startedAt,
204
213
  completedAt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.132",
3
+ "version": "0.9.134",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -34,10 +34,10 @@
34
34
  "npm": "11.12.1",
35
35
  "ws": "^8.21.3",
36
36
  "zod": "4.4.3",
37
- "@myagentroam/marmgr": "0.9.115",
38
- "@myagentroam/agent": "0.9.132",
39
- "@myagentroam/protocol": "0.9.132",
40
- "@myagentroam/code-navigation": "0.9.129"
37
+ "@myagentroam/marmgr": "0.9.117",
38
+ "@myagentroam/agent": "0.9.134",
39
+ "@myagentroam/code-navigation": "0.9.129",
40
+ "@myagentroam/protocol": "0.9.134"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/ws": "^8.18.1"