doer-agent 0.9.5 → 0.9.7

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.
@@ -65,7 +65,25 @@ function normalizeCodexAppRpcRequest(args) {
65
65
  const timeoutMs = typeof args.request.timeoutMs === "number" && Number.isFinite(args.request.timeoutMs)
66
66
  ? Math.min(180_000, Math.max(1_000, Math.trunc(args.request.timeoutMs)))
67
67
  : undefined;
68
- if (!requestId || !requestAgentId || requestAgentId !== args.agentId || actionRaw !== "request" || !method) {
68
+ if (!requestId || !requestAgentId || requestAgentId !== args.agentId) {
69
+ throw new Error("invalid codex app rpc request");
70
+ }
71
+ if (actionRaw === "mcp-oauth-callback") {
72
+ const path = typeof args.request.path === "string" ? args.request.path.trim() : "";
73
+ const search = typeof args.request.search === "string" ? args.request.search.trim() : "";
74
+ if (!path.startsWith("/") || search.length > 16_384) {
75
+ throw new Error("invalid MCP OAuth callback request");
76
+ }
77
+ return { requestId, action: "mcp-oauth-callback", path, search };
78
+ }
79
+ if (actionRaw === "mcp-oauth-logout") {
80
+ const name = typeof args.request.name === "string" ? args.request.name.trim() : "";
81
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
82
+ throw new Error("invalid MCP OAuth logout request");
83
+ }
84
+ return { requestId, action: "mcp-oauth-logout", name };
85
+ }
86
+ if (actionRaw !== "request" || !method) {
69
87
  throw new Error("invalid codex app rpc request");
70
88
  }
71
89
  return {
@@ -82,7 +100,11 @@ async function handleCodexAppRpcMessage(args) {
82
100
  const payload = JSON.parse(codexAppRpcCodec.decode(args.msg.data));
83
101
  const request = normalizeCodexAppRpcRequest({ request: payload, agentId: args.agentId });
84
102
  requestId = request.requestId;
85
- const result = applyCodexAppRpcOmitRules(request.method, await args.manager.request(request.method, request.params, request.timeoutMs));
103
+ const result = request.action === "request"
104
+ ? applyCodexAppRpcOmitRules(request.method, await args.manager.request(request.method, request.params, request.timeoutMs))
105
+ : request.action === "mcp-oauth-callback"
106
+ ? await args.manager.relayMcpOauthCallback(request.path, request.search)
107
+ : await args.manager.logoutMcpServer(request.name);
86
108
  args.msg.respond(codexAppRpcCodec.encode(JSON.stringify({
87
109
  requestId,
88
110
  ok: true,
@@ -41,6 +41,9 @@ function buildRemoteMcpServerConfigArgs(args) {
41
41
  "--config",
42
42
  `${prefix}.enabled=${args.enabled === false ? "false" : "true"}`,
43
43
  ];
44
+ if (args.auth) {
45
+ configArgs.push("--config", `${prefix}.auth=${toTomlStringLiteral(args.auth)}`);
46
+ }
44
47
  if (args.bearerTokenEnvVar?.trim()) {
45
48
  configArgs.push("--config", `${prefix}.bearer_token_env_var=${toTomlStringLiteral(args.bearerTokenEnvVar.trim())}`);
46
49
  }
@@ -50,6 +53,12 @@ function buildRemoteMcpServerConfigArgs(args) {
50
53
  if (Object.keys(args.envHttpHeaders ?? {}).length > 0) {
51
54
  configArgs.push("--config", `${prefix}.env_http_headers=${toTomlStringMap(args.envHttpHeaders ?? {})}`);
52
55
  }
56
+ if ((args.scopes ?? []).length > 0) {
57
+ configArgs.push("--config", `${prefix}.scopes=${toTomlStringArray(args.scopes ?? [])}`);
58
+ }
59
+ if (args.oauthResource?.trim()) {
60
+ configArgs.push("--config", `${prefix}.oauth_resource=${toTomlStringLiteral(args.oauthResource.trim())}`);
61
+ }
53
62
  return configArgs;
54
63
  }
55
64
  function hasDirectCodexBinary() {
@@ -157,7 +166,10 @@ export function buildCustomMcpConfigArgs(servers) {
157
166
  configArgs.push(...buildRemoteMcpServerConfigArgs({
158
167
  serverName,
159
168
  url: server.url,
169
+ auth: server.auth,
160
170
  bearerTokenEnvVar: server.bearerTokenEnvVar,
171
+ scopes: server.scopes,
172
+ oauthResource: server.oauthResource,
161
173
  httpHeaders: Object.fromEntries(server.httpHeaders.map((header) => [header.key, header.value])),
162
174
  envHttpHeaders: Object.fromEntries(server.envHttpHeaders.map((header) => [header.key, header.value])),
163
175
  enabled: true,
@@ -9,7 +9,10 @@ test("buildCustomMcpConfigArgs builds streamable HTTP MCP overrides", () => {
9
9
  args: [],
10
10
  env: [],
11
11
  url: "https://example.com/mcp",
12
+ auth: "oauth",
12
13
  bearerTokenEnvVar: "MCP_TOKEN",
14
+ scopes: ["read:docs"],
15
+ oauthResource: "https://example.com/",
13
16
  httpHeaders: [{ key: "X-Region", value: "seoul" }],
14
17
  envHttpHeaders: [{ key: "X-API-Key", value: "MCP_API_KEY" }],
15
18
  enabled: true,
@@ -17,9 +20,12 @@ test("buildCustomMcpConfigArgs builds streamable HTTP MCP overrides", () => {
17
20
  assert.deepEqual(args, [
18
21
  "--config", 'mcp_servers.remote_docs.url="https://example.com/mcp"',
19
22
  "--config", "mcp_servers.remote_docs.enabled=true",
23
+ "--config", 'mcp_servers.remote_docs.auth="oauth"',
20
24
  "--config", 'mcp_servers.remote_docs.bearer_token_env_var="MCP_TOKEN"',
21
25
  "--config", 'mcp_servers.remote_docs.http_headers={ "X-Region" = "seoul" }',
22
26
  "--config", 'mcp_servers.remote_docs.env_http_headers={ "X-API-Key" = "MCP_API_KEY" }',
27
+ "--config", 'mcp_servers.remote_docs.scopes=["read:docs"]',
28
+ "--config", 'mcp_servers.remote_docs.oauth_resource="https://example.com/"',
23
29
  ]);
24
30
  });
25
31
  test("buildCustomMcpConfigArgs keeps stdio MCP overrides", () => {
@@ -30,7 +36,10 @@ test("buildCustomMcpConfigArgs keeps stdio MCP overrides", () => {
30
36
  args: ["-y", "local-mcp"],
31
37
  env: [{ key: "LOCAL_TOKEN", value: "secret" }],
32
38
  url: "",
39
+ auth: "oauth",
33
40
  bearerTokenEnvVar: "",
41
+ scopes: [],
42
+ oauthResource: "",
34
43
  httpHeaders: [],
35
44
  envHttpHeaders: [],
36
45
  enabled: true,
@@ -237,7 +237,10 @@ function normalizeAgentMcpServer(value) {
237
237
  args: normalizeStringArray(raw.args),
238
238
  env,
239
239
  url,
240
+ auth: raw.auth === "chatgpt" ? "chatgpt" : "oauth",
240
241
  bearerTokenEnvVar: typeof raw.bearerTokenEnvVar === "string" ? raw.bearerTokenEnvVar.trim() : "",
242
+ scopes: normalizeStringArray(raw.scopes),
243
+ oauthResource: typeof raw.oauthResource === "string" ? raw.oauthResource.trim() : "",
241
244
  httpHeaders: normalizeMcpHeaderEntries(raw.httpHeaders),
242
245
  envHttpHeaders: normalizeMcpHeaderEntries(raw.envHttpHeaders),
243
246
  enabled: raw.enabled !== false,
@@ -423,7 +426,10 @@ export async function toAgentSettingsPublic(args) {
423
426
  value: variable.value,
424
427
  })),
425
428
  url: server.url,
429
+ auth: server.auth,
426
430
  bearerTokenEnvVar: server.bearerTokenEnvVar,
431
+ scopes: [...server.scopes],
432
+ oauthResource: server.oauthResource,
427
433
  httpHeaders: server.httpHeaders.map((header) => ({ ...header })),
428
434
  envHttpHeaders: server.envHttpHeaders.map((header) => ({ ...header })),
429
435
  enabled: server.enabled,
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { StringCodec } from "nats";
5
5
  import { runCodexTextTask } from "./codex-text-task.js";
@@ -9,6 +9,8 @@ const MAX_THREADS_PER_REBUILD = 2_000;
9
9
  const MAX_TAGS = 12;
10
10
  const MIN_TAGS = 2;
11
11
  const OTHER_TAG_ID = "other";
12
+ const MAX_REPOSITORIES = 200;
13
+ const MAX_RELATED_REPOS = 3;
12
14
  const DEFAULT_TAGS = [
13
15
  { id: "deployment", label: "Deployment", description: "Deploy, release, publish, production rollout, or app-store delivery.", color: "#8b5cf6" },
14
16
  { id: "bug", label: "Bug · diagnosis", description: "Debugging, errors, failures, regressions, diagnosis, or fixes.", color: "#ef4444" },
@@ -55,6 +57,70 @@ function tagsFilePath(workspaceRoot, threadId) {
55
57
  function configFilePath(workspaceRoot) {
56
58
  return path.join(workspaceRoot, ".doer-agent", "thread-tags", "config.json");
57
59
  }
60
+ async function isGitRepository(directoryPath) {
61
+ try {
62
+ const gitEntry = await stat(path.join(directoryPath, ".git"));
63
+ return gitEntry.isDirectory() || gitEntry.isFile();
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ function repositoryCandidate(workspaceRoot, absolutePath) {
70
+ const relativePath = path.relative(workspaceRoot, absolutePath).split(path.sep).join("/") || ".";
71
+ return {
72
+ id: relativePath,
73
+ label: path.basename(absolutePath),
74
+ relativePath,
75
+ absolutePath,
76
+ };
77
+ }
78
+ export async function discoverWorkspaceRepositories(workspaceRoot) {
79
+ const resolvedRoot = path.resolve(workspaceRoot);
80
+ const candidates = [];
81
+ if (await isGitRepository(resolvedRoot)) {
82
+ candidates.push(repositoryCandidate(resolvedRoot, resolvedRoot));
83
+ }
84
+ const entries = await readdir(resolvedRoot, { withFileTypes: true }).catch(() => []);
85
+ for (const entry of entries) {
86
+ if (candidates.length >= MAX_REPOSITORIES ||
87
+ !entry.isDirectory() ||
88
+ entry.name.startsWith(".")) {
89
+ continue;
90
+ }
91
+ const absolutePath = path.join(resolvedRoot, entry.name);
92
+ if (await isGitRepository(absolutePath)) {
93
+ candidates.push(repositoryCandidate(resolvedRoot, absolutePath));
94
+ }
95
+ }
96
+ return candidates.sort((left, right) => (left.label.localeCompare(right.label) || left.id.localeCompare(right.id)));
97
+ }
98
+ function repositoryFingerprint(repositories) {
99
+ return crypto.createHash("sha256").update(JSON.stringify(repositories.map((repository) => ({
100
+ id: repository.id,
101
+ label: repository.label,
102
+ relativePath: repository.relativePath,
103
+ })))).digest("hex");
104
+ }
105
+ function pathIsWithin(candidatePath, targetPath) {
106
+ const relative = path.relative(candidatePath, targetPath);
107
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
108
+ }
109
+ export function relatedReposFromCwd(cwd, repositories) {
110
+ if (!cwd) {
111
+ return [];
112
+ }
113
+ const resolvedCwd = path.resolve(cwd);
114
+ const repository = repositories
115
+ .filter((candidate) => pathIsWithin(candidate.absolutePath, resolvedCwd))
116
+ .sort((left, right) => right.absolutePath.length - left.absolutePath.length)[0];
117
+ return repository ? [{
118
+ id: repository.id,
119
+ label: repository.label,
120
+ confidence: 1,
121
+ source: "cwd",
122
+ }] : [];
123
+ }
58
124
  function normalizeInputs(value, limit) {
59
125
  const rows = Array.isArray(value) ? value.slice(0, limit) : [];
60
126
  const seen = new Set();
@@ -191,9 +257,10 @@ function jsonObjectFromText(text) {
191
257
  }
192
258
  return payload;
193
259
  }
194
- export function parseThreadTagClassificationResponse(text, requestedThreadIds, allowedTagIds = new Set(DEFAULT_TAGS.map((tag) => tag.id))) {
260
+ export function parseThreadTagClassificationResponse(text, requestedThreadIds, allowedTagIds = new Set(DEFAULT_TAGS.map((tag) => tag.id)), repositories = []) {
195
261
  const payload = jsonObjectFromText(text);
196
262
  const assignments = Array.isArray(payload.assignments) ? payload.assignments : [];
263
+ const repositoriesById = new Map(repositories.map((repository) => [repository.id, repository]));
197
264
  const seen = new Set();
198
265
  const results = [];
199
266
  for (const assignmentValue of assignments) {
@@ -207,11 +274,30 @@ export function parseThreadTagClassificationResponse(text, requestedThreadIds, a
207
274
  continue;
208
275
  }
209
276
  seen.add(threadId);
277
+ const seenRepositoryIds = new Set();
278
+ const relatedRepos = (Array.isArray(assignment?.relatedRepos) ? assignment.relatedRepos : [])
279
+ .flatMap((value) => {
280
+ const row = recordValue(value);
281
+ const id = stringValue(row?.id) || stringValue(value);
282
+ const repository = repositoriesById.get(id);
283
+ if (!repository || seenRepositoryIds.has(id)) {
284
+ return [];
285
+ }
286
+ seenRepositoryIds.add(id);
287
+ return [{
288
+ id: repository.id,
289
+ label: repository.label,
290
+ confidence: boundedConfidence(row?.confidence),
291
+ source: "ai",
292
+ }];
293
+ })
294
+ .slice(0, MAX_RELATED_REPOS);
210
295
  results.push({
211
296
  threadId,
212
297
  activityTag,
213
298
  confidence: boundedConfidence(assignment?.confidence),
214
299
  source: "ai",
300
+ relatedRepos,
215
301
  });
216
302
  }
217
303
  return results;
@@ -219,41 +305,61 @@ export function parseThreadTagClassificationResponse(text, requestedThreadIds, a
219
305
  export function parseSuggestedThreadTags(text) {
220
306
  return normalizeTagDefinitions(jsonObjectFromText(text).tags);
221
307
  }
222
- async function readStoredTags(workspaceRoot, input, config) {
308
+ async function readStoredTags(workspaceRoot, input, config, repositories) {
223
309
  try {
224
310
  const row = recordValue(JSON.parse(await readFile(tagsFilePath(workspaceRoot, input.threadId), "utf8")));
225
311
  const activityTag = sanitizeTagId(stringValue(row?.activityTag));
226
312
  const allowedTagIds = new Set(config.tags.map((tag) => tag.id));
227
- const isLegacyDefaultCache = row?.version === 1 && config.source === "default";
228
- const isCurrentCache = row?.version === 2 && stringValue(row.configFingerprint) === config.fingerprint;
229
- if ((!isLegacyDefaultCache && !isCurrentCache) ||
313
+ const isCurrentCache = row?.version === 3 &&
314
+ stringValue(row.configFingerprint) === config.fingerprint &&
315
+ stringValue(row.repositoryFingerprint) === repositoryFingerprint(repositories);
316
+ if (!isCurrentCache ||
230
317
  stringValue(row?.threadId) !== input.threadId ||
231
318
  stringValue(row?.contentFingerprint) !== contentFingerprint(input) ||
232
319
  !allowedTagIds.has(activityTag)) {
233
320
  return null;
234
321
  }
322
+ const repositoriesById = new Map(repositories.map((repository) => [repository.id, repository]));
323
+ const relatedRepos = (Array.isArray(row?.relatedRepos) ? row.relatedRepos : [])
324
+ .flatMap((value) => {
325
+ const stored = recordValue(value);
326
+ const repository = repositoriesById.get(stringValue(stored?.id));
327
+ if (!repository) {
328
+ return [];
329
+ }
330
+ return [{
331
+ id: repository.id,
332
+ label: repository.label,
333
+ confidence: boundedConfidence(stored?.confidence),
334
+ source: stored?.source === "cwd" ? "cwd" : "ai",
335
+ }];
336
+ })
337
+ .slice(0, MAX_RELATED_REPOS);
235
338
  return {
236
339
  threadId: input.threadId,
237
340
  activityTag,
238
341
  confidence: boundedConfidence(row?.confidence),
239
342
  source: "ai",
343
+ relatedRepos,
240
344
  };
241
345
  }
242
346
  catch {
243
347
  return null;
244
348
  }
245
349
  }
246
- async function writeStoredTags(workspaceRoot, input, classification, config) {
350
+ async function writeStoredTags(workspaceRoot, input, classification, config, repositories) {
247
351
  const filePath = tagsFilePath(workspaceRoot, input.threadId);
248
352
  await mkdir(path.dirname(filePath), { recursive: true });
249
353
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
250
354
  const payload = {
251
- version: 2,
355
+ version: 3,
252
356
  threadId: input.threadId,
253
357
  contentFingerprint: contentFingerprint(input),
254
358
  configFingerprint: config.fingerprint,
359
+ repositoryFingerprint: repositoryFingerprint(repositories),
255
360
  activityTag: classification.activityTag,
256
361
  confidence: classification.confidence,
362
+ relatedRepos: classification.relatedRepos,
257
363
  source: "ai",
258
364
  updatedAt: new Date().toISOString(),
259
365
  };
@@ -263,15 +369,23 @@ async function writeStoredTags(workspaceRoot, input, classification, config) {
263
369
  function configuredTagsPrompt(tags) {
264
370
  return tags.map((tag) => `- ${tag.id} (${tag.label}): ${tag.description}`);
265
371
  }
266
- function classifierPrompt(inputs, tags) {
372
+ function classifierPrompt(inputs, tags, repositories) {
267
373
  return [
268
374
  "You classify Codex threads inside Doer.",
269
375
  "Return only one valid JSON object. Do not use Markdown fences or add explanations.",
270
376
  "Classify every input thread into exactly one activityTag from the configured list.",
271
377
  "Configured activityTag values:",
272
378
  ...configuredTagsPrompt(tags),
273
- "Use label and preview as the main evidence. Use cwd only as supporting project context.",
274
- `Output shape: {"assignments":[{"threadId":"...","activityTag":"${tags[0]?.id ?? OTHER_TAG_ID}","confidence":0.9}]}`,
379
+ "Also select zero to three relatedRepos for each thread from the repository candidates below.",
380
+ "A related repository is a code repository that the thread discusses, inspects, modifies, tests, deploys, or otherwise directly works on.",
381
+ "Do not invent repository ids and do not select a repository merely because it shares the agent workspace.",
382
+ `Repository candidates: ${JSON.stringify(repositories.map((repository) => ({
383
+ id: repository.id,
384
+ label: repository.label,
385
+ relativePath: repository.relativePath,
386
+ })))}`,
387
+ "Use label and preview as the main evidence. Use cwd as strong supporting repository evidence.",
388
+ `Output shape: {"assignments":[{"threadId":"...","activityTag":"${tags[0]?.id ?? OTHER_TAG_ID}","confidence":0.9,"relatedRepos":[{"id":"repository-id","confidence":0.9}]}]}`,
275
389
  "",
276
390
  JSON.stringify({ threads: inputs }),
277
391
  ].join("\n");
@@ -284,7 +398,7 @@ function suggestionPrompt(inputs, candidateTags) {
284
398
  "Every tag needs: a stable lowercase kebab-case id, a concise label, a one-sentence classification description, and a distinct #RRGGBB color.",
285
399
  "Write labels and descriptions in the predominant language of the supplied threads. Use Korean when the threads are predominantly Korean.",
286
400
  `Always include "${OTHER_TAG_ID}" as the final fallback tag.`,
287
- "Avoid tags for project paths or running status; those are managed separately.",
401
+ "Avoid tags for repositories or running status; those are managed separately.",
288
402
  "Prefer work-intent categories that will remain useful for future threads.",
289
403
  'Output shape: {"tags":[{"id":"feature","label":"Feature","description":"Implementation and product changes.","color":"#22c55e"}]}',
290
404
  candidateTags?.length
@@ -299,12 +413,30 @@ function suggestionPrompt(inputs, candidateTags) {
299
413
  })}` : "",
300
414
  ].filter(Boolean).join("\n");
301
415
  }
416
+ function withCwdRelatedRepos(classification, input, repositories) {
417
+ const merged = new Map();
418
+ for (const relatedRepo of classification.relatedRepos) {
419
+ merged.set(relatedRepo.id, relatedRepo);
420
+ }
421
+ for (const relatedRepo of relatedReposFromCwd(input.cwd, repositories)) {
422
+ merged.set(relatedRepo.id, relatedRepo);
423
+ }
424
+ return {
425
+ ...classification,
426
+ relatedRepos: [...merged.values()]
427
+ .sort((left, right) => (Number(right.source === "cwd") - Number(left.source === "cwd")
428
+ || right.confidence - left.confidence
429
+ || left.label.localeCompare(right.label)))
430
+ .slice(0, MAX_RELATED_REPOS),
431
+ };
432
+ }
302
433
  async function classifyThreads(args) {
303
434
  const config = await readConfig(args.workspaceRoot);
435
+ const repositories = await discoverWorkspaceRepositories(args.workspaceRoot);
304
436
  const classifications = new Map();
305
437
  const staleInputs = [];
306
438
  for (const input of args.inputs) {
307
- const stored = args.force ? null : await readStoredTags(args.workspaceRoot, input, config);
439
+ const stored = args.force ? null : await readStoredTags(args.workspaceRoot, input, config, repositories);
308
440
  if (stored) {
309
441
  classifications.set(input.threadId, stored);
310
442
  }
@@ -317,17 +449,18 @@ async function classifyThreads(args) {
317
449
  try {
318
450
  const resultText = await runCodexTextTask({
319
451
  manager: args.manager,
320
- prompt: classifierPrompt(staleInputs, config.tags),
452
+ prompt: classifierPrompt(staleInputs, config.tags, repositories),
321
453
  });
322
- const resolved = parseThreadTagClassificationResponse(resultText, new Set(staleInputs.map((input) => input.threadId)), new Set(config.tags.map((tag) => tag.id)));
454
+ const resolved = parseThreadTagClassificationResponse(resultText, new Set(staleInputs.map((input) => input.threadId)), new Set(config.tags.map((tag) => tag.id)), repositories);
323
455
  const inputsById = new Map(staleInputs.map((input) => [input.threadId, input]));
324
456
  await Promise.all(resolved.map(async (classification) => {
325
457
  const input = inputsById.get(classification.threadId);
326
458
  if (!input) {
327
459
  return;
328
460
  }
329
- await writeStoredTags(args.workspaceRoot, input, classification, config);
330
- classifications.set(classification.threadId, classification);
461
+ const completedClassification = withCwdRelatedRepos(classification, input, repositories);
462
+ await writeStoredTags(args.workspaceRoot, input, completedClassification, config, repositories);
463
+ classifications.set(classification.threadId, completedClassification);
331
464
  }));
332
465
  if (resolved.length !== staleInputs.length) {
333
466
  classificationError = `AI classified ${resolved.length} of ${staleInputs.length} threads`;
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import test from "node:test";
3
- import { parseSuggestedThreadTags, parseThreadTagClassificationResponse, } from "./agent-thread-tags-rpc.js";
6
+ import { discoverWorkspaceRepositories, parseSuggestedThreadTags, parseThreadTagClassificationResponse, relatedReposFromCwd, } from "./agent-thread-tags-rpc.js";
4
7
  test("parses only requested valid thread tag assignments", () => {
5
8
  const result = parseThreadTagClassificationResponse(JSON.stringify({
6
9
  assignments: [
@@ -14,6 +17,7 @@ test("parses only requested valid thread tag assignments", () => {
14
17
  activityTag: "feature",
15
18
  confidence: 0.91,
16
19
  source: "ai",
20
+ relatedRepos: [],
17
21
  }]);
18
22
  });
19
23
  test("accepts fenced JSON and clamps confidence", () => {
@@ -24,6 +28,58 @@ test("accepts classifications from a custom configured range", () => {
24
28
  const result = parseThreadTagClassificationResponse("{\"assignments\":[{\"threadId\":\"a\",\"activityTag\":\"customer-request\",\"confidence\":0.8}]}", new Set(["a"]), new Set(["customer-request", "other"]));
25
29
  assert.equal(result[0]?.activityTag, "customer-request");
26
30
  });
31
+ test("accepts only configured related repository ids", () => {
32
+ const repositories = [{
33
+ id: "doer",
34
+ label: "doer",
35
+ relativePath: "doer",
36
+ absolutePath: "/workspace/doer",
37
+ }];
38
+ const result = parseThreadTagClassificationResponse(JSON.stringify({
39
+ assignments: [{
40
+ threadId: "a",
41
+ activityTag: "feature",
42
+ confidence: 0.9,
43
+ relatedRepos: [
44
+ { id: "doer", confidence: 0.82 },
45
+ { id: "invented", confidence: 1 },
46
+ ],
47
+ }],
48
+ }), new Set(["a"]), new Set(["feature", "other"]), repositories);
49
+ assert.deepEqual(result[0]?.relatedRepos, [{
50
+ id: "doer",
51
+ label: "doer",
52
+ confidence: 0.82,
53
+ source: "ai",
54
+ }]);
55
+ });
56
+ test("uses the most specific repository containing cwd", () => {
57
+ const repositories = [
58
+ { id: ".", label: "workspace", relativePath: ".", absolutePath: "/workspace" },
59
+ { id: "doer", label: "doer", relativePath: "doer", absolutePath: "/workspace/doer" },
60
+ ];
61
+ assert.deepEqual(relatedReposFromCwd("/workspace/doer/src", repositories), [{
62
+ id: "doer",
63
+ label: "doer",
64
+ confidence: 1,
65
+ source: "cwd",
66
+ }]);
67
+ });
68
+ test("discovers the workspace root and immediate child git repositories", async () => {
69
+ const root = await mkdtemp(path.join(os.tmpdir(), "doer-thread-repos-"));
70
+ try {
71
+ await mkdir(path.join(root, ".git"));
72
+ await mkdir(path.join(root, "child", ".git"), { recursive: true });
73
+ await mkdir(path.join(root, "not-a-repo"), { recursive: true });
74
+ await mkdir(path.join(root, "worktree"), { recursive: true });
75
+ await writeFile(path.join(root, "worktree", ".git"), "gitdir: ../actual\n", "utf8");
76
+ const repositories = await discoverWorkspaceRepositories(root);
77
+ assert.deepEqual(repositories.map((repository) => repository.id).sort(), [".", "child", "worktree"]);
78
+ }
79
+ finally {
80
+ await rm(root, { recursive: true, force: true });
81
+ }
82
+ });
27
83
  test("normalizes AI suggested tags and always adds other", () => {
28
84
  const result = parseSuggestedThreadTags(JSON.stringify({
29
85
  tags: [
@@ -1,7 +1,8 @@
1
1
  import { buildAgentSettingsEnvPatch, readAgentModelInstructions, resolveAgentModelInstructionsFilePath, } from "./agent-settings.js";
2
- import { buildCustomMcpConfigArgs, buildDaemonMcpConfigArgs, buildMobileMcpConfigArgs, buildThreadsMcpConfigArgs } from "./agent-codex-cli.js";
2
+ import { buildCustomMcpConfigArgs, buildDaemonMcpConfigArgs, buildMobileMcpConfigArgs, buildThreadsMcpConfigArgs, spawnManagedCodexCommand, } from "./agent-codex-cli.js";
3
3
  import { CodexAppServerClient } from "./codex-app-server-client.js";
4
4
  import { startCodexChatBridge } from "./codex-chat-bridge.js";
5
+ import { allocateMcpOauthCallbackPort, buildMcpOauthCallbackBaseUrl, relayMcpOauthCallbackToLocalListener, } from "./mcp-oauth-relay.js";
5
6
  function toTomlStringLiteral(value) {
6
7
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
7
8
  }
@@ -59,6 +60,8 @@ async function buildCodexAppServerArgs(args) {
59
60
  ...buildConfigArg("personality", toTomlStringLiteral(args.settings.general.personality)),
60
61
  ...buildConfigArg("approval_policy", toTomlStringLiteral("never")),
61
62
  ...buildConfigArg("sandbox_mode", toTomlStringLiteral("danger-full-access")),
63
+ ...buildConfigArg("mcp_oauth_callback_port", String(args.mcpOauthCallbackPort)),
64
+ ...buildConfigArg("mcp_oauth_callback_url", toTomlStringLiteral(args.mcpOauthCallbackUrl)),
62
65
  ];
63
66
  const customInstructions = await readAgentModelInstructions(args.workspaceRoot);
64
67
  if (customInstructions) {
@@ -140,18 +143,34 @@ export function createCodexAppServerManager(args) {
140
143
  let client = null;
141
144
  let providerProxy = null;
142
145
  let createPromise = null;
146
+ let mcpOauthCallbackPortPromise = null;
143
147
  let generation = 0;
144
148
  const notificationListeners = new Set();
149
+ const mcpOauthCallbackUrl = buildMcpOauthCallbackBaseUrl({
150
+ serverBaseUrl: args.serverBaseUrl,
151
+ userId: args.userId,
152
+ agentId: args.agentId,
153
+ });
154
+ const mcpOauthCallbackPathPrefix = new URL(mcpOauthCallbackUrl).pathname;
155
+ const resolveMcpOauthCallbackPort = async () => {
156
+ if (!mcpOauthCallbackPortPromise) {
157
+ mcpOauthCallbackPortPromise = allocateMcpOauthCallbackPort();
158
+ }
159
+ return await mcpOauthCallbackPortPromise;
160
+ };
145
161
  const createClient = async () => {
146
162
  const settings = await args.readAgentSettingsConfig({ workspaceRoot: args.workspaceRoot });
147
163
  const resolved = await resolveAppServerSettings({ settings, onLog: args.onLog });
148
164
  providerProxy = resolved.proxy;
165
+ const mcpOauthCallbackPort = await resolveMcpOauthCallbackPort();
149
166
  const appServerArgs = await buildCodexAppServerArgs({
150
167
  agentId: args.agentId,
151
168
  agentToken: args.agentToken,
152
169
  workspaceRoot: args.workspaceRoot,
153
170
  agentProjectDir: args.agentProjectDir,
154
171
  serverBaseUrl: args.serverBaseUrl,
172
+ mcpOauthCallbackPort,
173
+ mcpOauthCallbackUrl,
155
174
  settings: resolved.settings,
156
175
  userId: args.userId,
157
176
  });
@@ -164,7 +183,7 @@ export function createCodexAppServerManager(args) {
164
183
  settings: resolved.settings,
165
184
  userId: args.userId,
166
185
  });
167
- args.onLog?.(`starting codex app-server model=${resolveCodexModel(resolved.settings)} reasoningEffort=${resolved.settings.codex.reasoningEffort} personality=${resolved.settings.general.personality} computerUse=${resolved.settings.codex.computerUseEnabled} browserUse=${resolved.settings.codex.browserUseEnabled} mcpServers=${resolved.settings.mcp.servers.filter((server) => server.enabled).length}`);
186
+ args.onLog?.(`starting codex app-server model=${resolveCodexModel(resolved.settings)} reasoningEffort=${resolved.settings.codex.reasoningEffort} personality=${resolved.settings.general.personality} computerUse=${resolved.settings.codex.computerUseEnabled} browserUse=${resolved.settings.codex.browserUseEnabled} mcpServers=${resolved.settings.mcp.servers.filter((server) => server.enabled).length} mcpOauthCallbackPort=${mcpOauthCallbackPort}`);
168
187
  return new CodexAppServerClient({
169
188
  cwd: args.workspaceRoot,
170
189
  args: appServerArgs,
@@ -202,6 +221,61 @@ export function createCodexAppServerManager(args) {
202
221
  }
203
222
  }
204
223
  };
224
+ const restartClient = async (reason) => {
225
+ generation += 1;
226
+ const activeClient = client;
227
+ const activeProxy = providerProxy;
228
+ client = null;
229
+ providerProxy = null;
230
+ createPromise = null;
231
+ if (!activeClient) {
232
+ args.onLog?.(`codex app-server restart requested before start reason=${reason}`);
233
+ return;
234
+ }
235
+ args.onLog?.(`restarting codex app-server reason=${reason}`);
236
+ await activeClient.stop();
237
+ await activeProxy?.stop().catch((error) => {
238
+ args.onLog?.(`failed to stop provider proxy: ${error instanceof Error ? error.message : String(error)}`);
239
+ });
240
+ };
241
+ const runMcpLogout = async (name) => {
242
+ const settings = await args.readAgentSettingsConfig({ workspaceRoot: args.workspaceRoot });
243
+ const server = settings.mcp.servers.find((candidate) => candidate.name === name && candidate.transport === "streamable_http");
244
+ if (!server) {
245
+ throw new Error("Remote MCP server not found");
246
+ }
247
+ const env = await buildCodexAppServerEnv({
248
+ agentId: args.agentId,
249
+ agentToken: args.agentToken,
250
+ workspaceRoot: args.workspaceRoot,
251
+ serverBaseUrl: args.serverBaseUrl,
252
+ resolveCodexHomePath: args.resolveCodexHomePath,
253
+ settings,
254
+ userId: args.userId,
255
+ });
256
+ const child = spawnManagedCodexCommand({
257
+ codexArgs: ["mcp", "logout", name, ...buildCustomMcpConfigArgs([server])],
258
+ taskWorkspace: args.workspaceRoot,
259
+ env,
260
+ agentToken: args.agentToken,
261
+ });
262
+ let stdout = "";
263
+ let stderr = "";
264
+ child.stdout?.on("data", (chunk) => {
265
+ stdout = `${stdout}${chunk}`.slice(-16_384);
266
+ });
267
+ child.stderr?.on("data", (chunk) => {
268
+ stderr = `${stderr}${chunk}`.slice(-16_384);
269
+ });
270
+ const exitCode = await new Promise((resolve, reject) => {
271
+ child.once("error", reject);
272
+ child.once("exit", resolve);
273
+ });
274
+ if (exitCode !== 0) {
275
+ throw new Error(stderr.trim() || stdout.trim() || `MCP OAuth logout failed with exit code ${exitCode ?? "unknown"}`);
276
+ }
277
+ await restartClient(`mcp oauth logout ${name}`);
278
+ };
205
279
  return {
206
280
  onNotification(listener) {
207
281
  notificationListeners.add(listener);
@@ -213,23 +287,25 @@ export function createCodexAppServerManager(args) {
213
287
  const activeClient = await getClient();
214
288
  return await activeClient.request(method, params, timeoutMs);
215
289
  },
216
- async restart(reason) {
217
- generation += 1;
218
- const activeClient = client;
219
- const activeProxy = providerProxy;
220
- client = null;
221
- providerProxy = null;
222
- createPromise = null;
223
- if (!activeClient) {
224
- args.onLog?.(`codex app-server restart requested before start reason=${reason}`);
225
- return;
226
- }
227
- args.onLog?.(`restarting codex app-server reason=${reason}`);
228
- await activeClient.stop();
229
- await activeProxy?.stop().catch((error) => {
230
- args.onLog?.(`failed to stop provider proxy: ${error instanceof Error ? error.message : String(error)}`);
290
+ async relayMcpOauthCallback(path, search) {
291
+ await getClient();
292
+ return await relayMcpOauthCallbackToLocalListener({
293
+ callbackPort: await resolveMcpOauthCallbackPort(),
294
+ callbackPathPrefix: mcpOauthCallbackPathPrefix,
295
+ path,
296
+ search,
231
297
  });
232
298
  },
299
+ async logoutMcpServer(name) {
300
+ const normalizedName = name.trim();
301
+ if (!/^[A-Za-z0-9_-]+$/.test(normalizedName)) {
302
+ throw new Error("Invalid MCP server name");
303
+ }
304
+ await runMcpLogout(normalizedName);
305
+ },
306
+ async restart(reason) {
307
+ await restartClient(reason);
308
+ },
233
309
  async stop() {
234
310
  const activeClient = client;
235
311
  const activeProxy = providerProxy;
@@ -0,0 +1,64 @@
1
+ import net from "node:net";
2
+ const MAX_CALLBACK_QUERY_LENGTH = 16_384;
3
+ const MAX_CALLBACK_BODY_LENGTH = 512_000;
4
+ export function buildMcpOauthCallbackBaseUrl(args) {
5
+ const url = new URL(`/api/mcp-oauth/callback/${encodeURIComponent(args.userId)}/${encodeURIComponent(args.agentId)}`, `${args.serverBaseUrl.replace(/\/+$/, "")}/`);
6
+ return url.toString().replace(/\/$/, "");
7
+ }
8
+ export async function allocateMcpOauthCallbackPort() {
9
+ return await new Promise((resolve, reject) => {
10
+ const server = net.createServer();
11
+ server.unref();
12
+ server.once("error", reject);
13
+ server.listen(0, "0.0.0.0", () => {
14
+ const address = server.address();
15
+ if (!address || typeof address === "string") {
16
+ server.close();
17
+ reject(new Error("Failed to allocate MCP OAuth callback port"));
18
+ return;
19
+ }
20
+ const port = address.port;
21
+ server.close((error) => {
22
+ if (error) {
23
+ reject(error);
24
+ return;
25
+ }
26
+ resolve(port);
27
+ });
28
+ });
29
+ });
30
+ }
31
+ export async function relayMcpOauthCallbackToLocalListener(args) {
32
+ if (!Number.isInteger(args.callbackPort) || args.callbackPort < 1 || args.callbackPort > 65_535) {
33
+ throw new Error("MCP OAuth callback listener is unavailable");
34
+ }
35
+ if (!args.path.startsWith(`${args.callbackPathPrefix}/`)) {
36
+ throw new Error("Invalid MCP OAuth callback path");
37
+ }
38
+ if (args.search.length > MAX_CALLBACK_QUERY_LENGTH) {
39
+ throw new Error("MCP OAuth callback query is too large");
40
+ }
41
+ const target = new URL(`http://127.0.0.1:${args.callbackPort}`);
42
+ target.pathname = args.path;
43
+ target.search = args.search;
44
+ const response = await fetch(target, {
45
+ method: "GET",
46
+ redirect: "manual",
47
+ signal: AbortSignal.timeout(30_000),
48
+ });
49
+ const body = await response.text();
50
+ if (body.length > MAX_CALLBACK_BODY_LENGTH) {
51
+ throw new Error("MCP OAuth callback response is too large");
52
+ }
53
+ const headers = {};
54
+ for (const name of ["content-type", "location", "cache-control"]) {
55
+ const value = response.headers.get(name);
56
+ if (value)
57
+ headers[name] = value;
58
+ }
59
+ return {
60
+ status: response.status,
61
+ headers,
62
+ body,
63
+ };
64
+ }
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import http from "node:http";
3
+ import test from "node:test";
4
+ import { buildMcpOauthCallbackBaseUrl, relayMcpOauthCallbackToLocalListener, } from "./mcp-oauth-relay.js";
5
+ test("buildMcpOauthCallbackBaseUrl scopes callbacks to the user and agent", () => {
6
+ assert.equal(buildMcpOauthCallbackBaseUrl({
7
+ serverBaseUrl: "https://doer.example.com/",
8
+ userId: "user id",
9
+ agentId: "agent/id",
10
+ }), "https://doer.example.com/api/mcp-oauth/callback/user%20id/agent%2Fid");
11
+ });
12
+ test("relayMcpOauthCallbackToLocalListener preserves the callback path and query", async () => {
13
+ const server = http.createServer((request, response) => {
14
+ response.statusCode = 200;
15
+ response.setHeader("content-type", "text/html; charset=utf-8");
16
+ response.end(`<p>${request.url}</p>`);
17
+ });
18
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
19
+ const address = server.address();
20
+ assert.ok(address && typeof address !== "string");
21
+ try {
22
+ const result = await relayMcpOauthCallbackToLocalListener({
23
+ callbackPort: address.port,
24
+ callbackPathPrefix: "/api/mcp-oauth/callback/user/agent",
25
+ path: "/api/mcp-oauth/callback/user/agent/callback-id",
26
+ search: "?code=abc&state=xyz",
27
+ });
28
+ assert.equal(result.status, 200);
29
+ assert.equal(result.headers["content-type"], "text/html; charset=utf-8");
30
+ assert.equal(result.body, "<p>/api/mcp-oauth/callback/user/agent/callback-id?code=abc&state=xyz</p>");
31
+ }
32
+ finally {
33
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
34
+ }
35
+ });
36
+ test("relayMcpOauthCallbackToLocalListener rejects callbacks outside the configured path", async () => {
37
+ await assert.rejects(relayMcpOauthCallbackToLocalListener({
38
+ callbackPort: 4321,
39
+ callbackPathPrefix: "/api/mcp-oauth/callback/user/agent",
40
+ path: "/api/mcp-oauth/callback/other/agent/callback-id",
41
+ search: "",
42
+ }), /Invalid MCP OAuth callback path/);
43
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",