agentschat-mcp 0.36.8 → 0.36.10

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.
@@ -911,6 +911,13 @@ function reasoningEffort(value) {
911
911
  throw new Error("effort must be a nonempty model-supported reasoning effort name");
912
912
  return value.trim();
913
913
  }
914
+ function modelName(value) {
915
+ if (value === undefined)
916
+ return;
917
+ if (typeof value !== "string" || !value.trim())
918
+ throw new Error("model must be a nonempty model ID");
919
+ return value.trim();
920
+ }
914
921
  function permissionMode(value) {
915
922
  if (value === undefined)
916
923
  return "full-access";
@@ -918,6 +925,13 @@ function permissionMode(value) {
918
925
  throw new Error("permissions must be full-access or read-only");
919
926
  return value;
920
927
  }
928
+ function codexHomeMode(value) {
929
+ if (value === undefined)
930
+ return "linked";
931
+ if (value !== "linked" && value !== "isolated" && value !== "auth-only")
932
+ throw new Error("codex_home_mode must be linked, isolated or auth-only");
933
+ return value;
934
+ }
921
935
  function readJson(file) {
922
936
  try {
923
937
  return JSON.parse(readFileSync(file, "utf8"));
@@ -938,10 +952,10 @@ function resolveConfig(opts, env = process.env, home = homedir()) {
938
952
  const project = opts.settings ?? (existsSync(configFile) ? readJson(configFile) : {});
939
953
  if (!project || typeof project !== "object" || Array.isArray(project))
940
954
  throw new Error("Invalid project config");
941
- const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
955
+ const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort", "model", "state_root", "codex_home_mode"]);
942
956
  if (Object.keys(project).some((k) => !allowed.has(k)))
943
957
  throw new Error("Unknown project config field (credentials belong in a private profile)");
944
- for (const k of ["profile", "agent_id", "api_url", "ws_url"])
958
+ for (const k of ["profile", "agent_id", "api_url", "ws_url", "state_root"])
945
959
  if (project[k] !== undefined && (typeof project[k] !== "string" || !project[k].trim()))
946
960
  throw new Error(`Invalid project ${k}`);
947
961
  const localProfile = join(cwd, ".agentschat/profile.json");
@@ -957,8 +971,9 @@ function resolveConfig(opts, env = process.env, home = homedir()) {
957
971
  const mcp = doc.mcp_servers?.agentschat;
958
972
  if (mcp && mcp.enabled !== false) {
959
973
  const args = Array.isArray(mcp.args) ? mcp.args : [];
960
- const index = args.indexOf("--profile");
961
- codexProfile = mcp.env?.AGENTSCHAT_PROFILE ?? mcp.env?.AGENTCHAT_PROFILE ?? (index >= 0 ? args[index + 1] : undefined);
974
+ const index = args.findIndex((arg) => typeof arg === "string" && (arg === "--profile" || arg.startsWith("--profile=")));
975
+ const flagProfile = index < 0 ? undefined : args[index] === "--profile" ? args[index + 1] : args[index].slice("--profile=".length);
976
+ codexProfile = index >= 0 ? flagProfile : mcp.env?.AGENTSCHAT_PROFILE ?? mcp.env?.AGENTCHAT_PROFILE;
962
977
  if ((index >= 0 || codexProfile !== undefined) && (typeof codexProfile !== "string" || !codexProfile.trim() || codexProfile.startsWith("--")))
963
978
  throw new Error("Invalid project MCP profile selector");
964
979
  }
@@ -1003,12 +1018,18 @@ function resolveConfig(opts, env = process.env, home = homedir()) {
1003
1018
  }
1004
1019
  const canonicalApi = new URL(apiUrl).href.replace(/\/$/, "");
1005
1020
  const key = createHash("sha256").update(JSON.stringify([cwd, canonicalApi, profile.agent_id])).digest("hex").slice(0, 24);
1021
+ const root = opts.stateRoot ?? project.state_root;
1022
+ if (root !== undefined && (typeof root !== "string" || !root.trim()))
1023
+ throw new Error("Invalid state_root");
1024
+ const stateRoot = root === undefined ? join(home, ".agentschat/codex-bridge") : root.startsWith("~/") ? join(home, root.slice(2)) : resolve(cwd, root);
1006
1025
  return {
1007
1026
  cwd,
1008
1027
  profileFile,
1009
1028
  source,
1029
+ codexHomeMode: codexHomeMode(opts.codexHomeMode ?? project.codex_home_mode),
1010
1030
  permissions: permissionMode(project.permissions),
1011
1031
  effort: reasoningEffort(project.effort),
1032
+ model: modelName(project.model),
1012
1033
  agentId: profile.agent_id,
1013
1034
  token: profile.token,
1014
1035
  apiUrl: canonicalApi,
@@ -1016,7 +1037,7 @@ function resolveConfig(opts, env = process.env, home = homedir()) {
1016
1037
  channels: strings(project.channels, "channels"),
1017
1038
  senders: strings(project.senders, "senders"),
1018
1039
  codexBin: opts.codexBin ?? "codex",
1019
- stateDir: join(home, ".agentschat/codex-bridge", key)
1040
+ stateDir: join(stateRoot, key)
1020
1041
  };
1021
1042
  }
1022
1043
 
@@ -1048,7 +1069,7 @@ function loadBots(file = defaultRegistry(), home = homedir2()) {
1048
1069
  const names = new Set, identities = new Set;
1049
1070
  const bots = [];
1050
1071
  for (const bot of doc.bots) {
1051
- fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
1072
+ fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort", "model", "state_root", "codex_home_mode"]);
1052
1073
  if (!text(bot.name) || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(bot.name) || names.has(bot.name))
1053
1074
  throw new Error("Bot names must be unique simple labels");
1054
1075
  names.add(bot.name);
@@ -1064,9 +1085,14 @@ function loadBots(file = defaultRegistry(), home = homedir2()) {
1064
1085
  mkdirSync(defaultDir, { recursive: true, mode: 448 });
1065
1086
  const cwd = realpathSync2(bot.workdir ? path(bot.workdir) : defaultDir);
1066
1087
  const settings = {};
1067
- for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"])
1088
+ for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort", "model", "codex_home_mode"])
1068
1089
  if (bot[k] !== undefined)
1069
1090
  settings[k] = bot[k];
1091
+ if (bot.state_root !== undefined) {
1092
+ if (!text(bot.state_root))
1093
+ throw new Error("Invalid state_root");
1094
+ settings.state_root = path(bot.state_root);
1095
+ }
1070
1096
  const config = resolveConfig({ cwd, profile: bot.profile, settings, codexBin: doc.codex_bin }, {}, home);
1071
1097
  const identity = JSON.stringify([config.apiUrl, config.agentId]);
1072
1098
  if (identities.has(identity))
@@ -1130,7 +1156,7 @@ function removeDeadLock(file) {
1130
1156
  }
1131
1157
  }
1132
1158
  function save() {
1133
- writeFileSync(statusFile + ".tmp", JSON.stringify({ pid: process.pid, updated_at: new Date().toISOString(), bots: [...workers.values()].map((w) => ({ name: w.config.name, agent_id: w.config.agentId, workdir: w.config.cwd, effort: w.config.effort, pid: w.child?.pid, status: w.status })) }, null, 2), { mode: 384 });
1159
+ writeFileSync(statusFile + ".tmp", JSON.stringify({ pid: process.pid, updated_at: new Date().toISOString(), bots: [...workers.values()].map((w) => ({ name: w.config.name, agent_id: w.config.agentId, workdir: w.config.cwd, effort: w.config.effort, model: w.config.model, pid: w.child?.pid, status: w.status })) }, null, 2), { mode: 384 });
1134
1160
  renameSync(statusFile + ".tmp", statusFile);
1135
1161
  }
1136
1162
  async function stop(w) {
@@ -1,9 +1,85 @@
1
1
  #!/usr/bin/env node
2
2
  // codex/runtime-home.ts
3
- import { existsSync, mkdirSync, lstatSync, readlinkSync, realpathSync, symlinkSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, accessSync, constants, lstatSync, readlinkSync, realpathSync, symlinkSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
6
- function prepareRuntimeHome(stateDir, source = process.env.CODEX_HOME || join(homedir(), ".codex")) {
5
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ function executablePath(bin) {
7
+ const candidates = isAbsolute(bin) || bin.includes("/") ? [resolve(bin)] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, bin));
8
+ for (const candidate of candidates) {
9
+ try {
10
+ accessSync(candidate, constants.X_OK);
11
+ return realpathSync(candidate);
12
+ } catch {}
13
+ }
14
+ }
15
+ function assertIndependentTree(home, allowedAuthOrigin, codexExecutable) {
16
+ if (lstatSync(home).isSymbolicLink())
17
+ throw new Error("Independent Codex home must not be a symlink");
18
+ const inspect = (dir) => {
19
+ for (const name of readdirSync(dir)) {
20
+ const path = join(dir, name), info = lstatSync(path);
21
+ if (info.isSymbolicLink()) {
22
+ if (dir === home && name === "auth.json" && allowedAuthOrigin && resolve(home, readlinkSync(path)) === allowedAuthOrigin)
23
+ continue;
24
+ const local = relative(home, path).split(sep).join("/");
25
+ if (codexExecutable && /^tmp\/arg0\/codex-arg0[A-Za-z0-9_-]+\/(apply_patch|applypatch|codex-execve-wrapper|codex-linux-sandbox)$/.test(local)) {
26
+ try {
27
+ if (realpathSync(path) === codexExecutable)
28
+ continue;
29
+ } catch {}
30
+ }
31
+ throw new Error("Independent Codex home must not link configuration or runtime state");
32
+ }
33
+ if (info.isDirectory())
34
+ inspect(path);
35
+ }
36
+ };
37
+ inspect(home);
38
+ }
39
+ function prepareRuntimeHome(stateDir, source = process.env.CODEX_HOME || join(homedir(), ".codex"), mode = "linked", codexBin = "codex") {
40
+ if (mode !== "linked" && mode !== "isolated" && mode !== "auth-only")
41
+ throw new Error("Invalid Codex home mode");
42
+ if (mode === "isolated") {
43
+ const home2 = join(stateDir, "codex-home-isolated");
44
+ mkdirSync(home2, { recursive: true, mode: 448 });
45
+ assertIndependentTree(home2, undefined, executablePath(codexBin));
46
+ return { home: realpathSync(home2) };
47
+ }
48
+ if (mode === "auth-only") {
49
+ const legacyHome2 = realpathSync(source);
50
+ const origin = join(legacyHome2, "auth.json");
51
+ if (!lstatSync(origin).isFile())
52
+ throw new Error("auth-only requires an existing regular auth.json; authorize its use explicitly");
53
+ const home2 = join(stateDir, "codex-home-auth-only");
54
+ mkdirSync(home2, { recursive: true, mode: 448 });
55
+ if (lstatSync(home2).isSymbolicLink() || realpathSync(home2) === legacyHome2)
56
+ throw new Error("auth-only home must be independent");
57
+ for (const name of ["config.toml", "AGENTS.md", "rules", "plugins", "hooks.json"]) {
58
+ try {
59
+ lstatSync(join(home2, name));
60
+ } catch (e) {
61
+ if (e.code === "ENOENT")
62
+ continue;
63
+ throw e;
64
+ }
65
+ throw new Error("auth-only home contains unexpected configuration; use a fresh state root");
66
+ }
67
+ const skills = join(home2, "skills");
68
+ if (existsSync(skills) && (!lstatSync(skills).isDirectory() || readdirSync(skills).some((name) => name !== ".system")))
69
+ throw new Error("auth-only home contains unexpected custom skills");
70
+ assertIndependentTree(home2, origin, executablePath(codexBin));
71
+ const target = join(home2, "auth.json");
72
+ try {
73
+ if (!lstatSync(target).isSymbolicLink() || resolve(home2, readlinkSync(target)) !== origin)
74
+ throw new Error("Unexpected auth-only login binding");
75
+ } catch (e) {
76
+ if (e.code !== "ENOENT")
77
+ throw e;
78
+ symlinkSync(origin, target);
79
+ }
80
+ assertIndependentTree(home2, origin, executablePath(codexBin));
81
+ return { home: realpathSync(home2) };
82
+ }
7
83
  const legacyHome = existsSync(source) ? realpathSync(source) : resolve(source);
8
84
  const home = join(stateDir, "codex-home");
9
85
  mkdirSync(home, { recursive: true, mode: 448 });
@@ -102,7 +178,7 @@ async function getOnboardingStatus(base, agentId, token, request = fetch) {
102
178
 
103
179
  // codex/gui-channel.ts
104
180
  import { randomUUID } from "node:crypto";
105
- import { mkdirSync as mkdirSync2, writeFileSync, readFileSync, renameSync, readdirSync, rmSync } from "node:fs";
181
+ import { mkdirSync as mkdirSync2, writeFileSync, readFileSync, renameSync, readdirSync as readdirSync2, rmSync } from "node:fs";
106
182
  import { join as join2 } from "node:path";
107
183
  function payload(result) {
108
184
  if (result?.isError || result?.success === false || result?.error)
@@ -138,7 +214,7 @@ class GuiChannel {
138
214
  return JSON.parse(readFileSync(this.path(id), "utf8"));
139
215
  }
140
216
  list() {
141
- return readdirSync(this.directory).filter((f) => /^[0-9a-f-]{36}\.json$/.test(f)).map((f) => this.get(f.slice(0, -5)));
217
+ return readdirSync2(this.directory).filter((f) => /^[0-9a-f-]{36}\.json$/.test(f)).map((f) => this.get(f.slice(0, -5)));
142
218
  }
143
219
  enqueue(threadId, text, hostId) {
144
220
  if (!this.allowedThreads.includes(threadId))
@@ -223,12 +299,12 @@ import { join as join9 } from "node:path";
223
299
  // codex/bots-config.ts
224
300
  import { readFileSync as readFileSync3, realpathSync as realpathSync3, mkdirSync as mkdirSync3 } from "node:fs";
225
301
  import { homedir as homedir3 } from "node:os";
226
- import { dirname, isAbsolute as isAbsolute2, join as join4, resolve as resolve3 } from "node:path";
302
+ import { dirname, isAbsolute as isAbsolute3, join as join4, resolve as resolve3 } from "node:path";
227
303
 
228
304
  // codex/config.ts
229
305
  import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync as realpathSync2, statSync } from "node:fs";
230
306
  import { homedir as homedir2 } from "node:os";
231
- import { isAbsolute, join as join3, resolve as resolve2 } from "node:path";
307
+ import { isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
232
308
  import { createHash } from "node:crypto";
233
309
 
234
310
  // src/identity.ts
@@ -491,7 +567,7 @@ function skipVoid(ctx, banNewLines, banComments) {
491
567
  skipComment(ctx);
492
568
  }
493
569
  }
494
- function skipUntil(ctx, sep, end) {
570
+ function skipUntil(ctx, sep2, end) {
495
571
  let ptr = ctx.p;
496
572
  if (!end) {
497
573
  ptr = indexOfNewline(ctx.s, ptr);
@@ -502,7 +578,7 @@ function skipUntil(ctx, sep, end) {
502
578
  let c = ctx.s.charCodeAt(ctx.p);
503
579
  if (c === 35) {
504
580
  skipComment(ctx);
505
- } else if (c === end || c === sep) {
581
+ } else if (c === end || c === sep2) {
506
582
  return;
507
583
  }
508
584
  }
@@ -1124,6 +1200,13 @@ function reasoningEffort(value) {
1124
1200
  throw new Error("effort must be a nonempty model-supported reasoning effort name");
1125
1201
  return value.trim();
1126
1202
  }
1203
+ function modelName(value) {
1204
+ if (value === undefined)
1205
+ return;
1206
+ if (typeof value !== "string" || !value.trim())
1207
+ throw new Error("model must be a nonempty model ID");
1208
+ return value.trim();
1209
+ }
1127
1210
  function permissionMode(value) {
1128
1211
  if (value === undefined)
1129
1212
  return "full-access";
@@ -1131,6 +1214,13 @@ function permissionMode(value) {
1131
1214
  throw new Error("permissions must be full-access or read-only");
1132
1215
  return value;
1133
1216
  }
1217
+ function codexHomeMode(value) {
1218
+ if (value === undefined)
1219
+ return "linked";
1220
+ if (value !== "linked" && value !== "isolated" && value !== "auth-only")
1221
+ throw new Error("codex_home_mode must be linked, isolated or auth-only");
1222
+ return value;
1223
+ }
1134
1224
  function readJson(file) {
1135
1225
  try {
1136
1226
  return JSON.parse(readFileSync2(file, "utf8"));
@@ -1151,10 +1241,10 @@ function resolveConfig(opts, env = process.env, home = homedir2()) {
1151
1241
  const project = opts.settings ?? (existsSync2(configFile) ? readJson(configFile) : {});
1152
1242
  if (!project || typeof project !== "object" || Array.isArray(project))
1153
1243
  throw new Error("Invalid project config");
1154
- const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
1244
+ const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort", "model", "state_root", "codex_home_mode"]);
1155
1245
  if (Object.keys(project).some((k) => !allowed.has(k)))
1156
1246
  throw new Error("Unknown project config field (credentials belong in a private profile)");
1157
- for (const k of ["profile", "agent_id", "api_url", "ws_url"])
1247
+ for (const k of ["profile", "agent_id", "api_url", "ws_url", "state_root"])
1158
1248
  if (project[k] !== undefined && (typeof project[k] !== "string" || !project[k].trim()))
1159
1249
  throw new Error(`Invalid project ${k}`);
1160
1250
  const localProfile = join3(cwd, ".agentschat/profile.json");
@@ -1170,8 +1260,9 @@ function resolveConfig(opts, env = process.env, home = homedir2()) {
1170
1260
  const mcp = doc.mcp_servers?.agentschat;
1171
1261
  if (mcp && mcp.enabled !== false) {
1172
1262
  const args = Array.isArray(mcp.args) ? mcp.args : [];
1173
- const index = args.indexOf("--profile");
1174
- codexProfile = mcp.env?.AGENTSCHAT_PROFILE ?? mcp.env?.AGENTCHAT_PROFILE ?? (index >= 0 ? args[index + 1] : undefined);
1263
+ const index = args.findIndex((arg) => typeof arg === "string" && (arg === "--profile" || arg.startsWith("--profile=")));
1264
+ const flagProfile = index < 0 ? undefined : args[index] === "--profile" ? args[index + 1] : args[index].slice("--profile=".length);
1265
+ codexProfile = index >= 0 ? flagProfile : mcp.env?.AGENTSCHAT_PROFILE ?? mcp.env?.AGENTCHAT_PROFILE;
1175
1266
  if ((index >= 0 || codexProfile !== undefined) && (typeof codexProfile !== "string" || !codexProfile.trim() || codexProfile.startsWith("--")))
1176
1267
  throw new Error("Invalid project MCP profile selector");
1177
1268
  }
@@ -1189,7 +1280,7 @@ function resolveConfig(opts, env = process.env, home = homedir2()) {
1189
1280
  let profileFile;
1190
1281
  if (selector.startsWith("~/"))
1191
1282
  profileFile = join3(home, selector.slice(2));
1192
- else if (isAbsolute(selector) || selector.includes("/"))
1283
+ else if (isAbsolute2(selector) || selector.includes("/"))
1193
1284
  profileFile = resolve2(cwd, selector);
1194
1285
  else {
1195
1286
  const name = selector.endsWith(".json") ? selector : `${selector}.json`;
@@ -1216,12 +1307,18 @@ function resolveConfig(opts, env = process.env, home = homedir2()) {
1216
1307
  }
1217
1308
  const canonicalApi = new URL(apiUrl).href.replace(/\/$/, "");
1218
1309
  const key = createHash("sha256").update(JSON.stringify([cwd, canonicalApi, profile.agent_id])).digest("hex").slice(0, 24);
1310
+ const root = opts.stateRoot ?? project.state_root;
1311
+ if (root !== undefined && (typeof root !== "string" || !root.trim()))
1312
+ throw new Error("Invalid state_root");
1313
+ const stateRoot = root === undefined ? join3(home, ".agentschat/codex-bridge") : root.startsWith("~/") ? join3(home, root.slice(2)) : resolve2(cwd, root);
1219
1314
  return {
1220
1315
  cwd,
1221
1316
  profileFile,
1222
1317
  source,
1318
+ codexHomeMode: codexHomeMode(opts.codexHomeMode ?? project.codex_home_mode),
1223
1319
  permissions: permissionMode(project.permissions),
1224
1320
  effort: reasoningEffort(project.effort),
1321
+ model: modelName(project.model),
1225
1322
  agentId: profile.agent_id,
1226
1323
  token: profile.token,
1227
1324
  apiUrl: canonicalApi,
@@ -1229,7 +1326,7 @@ function resolveConfig(opts, env = process.env, home = homedir2()) {
1229
1326
  channels: strings(project.channels, "channels"),
1230
1327
  senders: strings(project.senders, "senders"),
1231
1328
  codexBin: opts.codexBin ?? "codex",
1232
- stateDir: join3(home, ".agentschat/codex-bridge", key)
1329
+ stateDir: join3(stateRoot, key)
1233
1330
  };
1234
1331
  }
1235
1332
 
@@ -1256,12 +1353,12 @@ function loadBots(file = defaultRegistry(), home = homedir3()) {
1256
1353
  for (const k of ["default_workdir", "codex_bin"])
1257
1354
  if (doc[k] !== undefined && !text(doc[k]))
1258
1355
  throw new Error(`Invalid ${k}`);
1259
- const path = (value) => value.startsWith("~/") ? join4(home, value.slice(2)) : isAbsolute2(value) ? value : resolve3(dirname(file), value);
1356
+ const path = (value) => value.startsWith("~/") ? join4(home, value.slice(2)) : isAbsolute3(value) ? value : resolve3(dirname(file), value);
1260
1357
  const defaultDir = doc.default_workdir ? path(doc.default_workdir) : join4(home, ".agentschat/workspace");
1261
1358
  const names = new Set, identities = new Set;
1262
1359
  const bots = [];
1263
1360
  for (const bot of doc.bots) {
1264
- fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
1361
+ fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort", "model", "state_root", "codex_home_mode"]);
1265
1362
  if (!text(bot.name) || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(bot.name) || names.has(bot.name))
1266
1363
  throw new Error("Bot names must be unique simple labels");
1267
1364
  names.add(bot.name);
@@ -1277,9 +1374,14 @@ function loadBots(file = defaultRegistry(), home = homedir3()) {
1277
1374
  mkdirSync3(defaultDir, { recursive: true, mode: 448 });
1278
1375
  const cwd = realpathSync3(bot.workdir ? path(bot.workdir) : defaultDir);
1279
1376
  const settings = {};
1280
- for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"])
1377
+ for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort", "model", "codex_home_mode"])
1281
1378
  if (bot[k] !== undefined)
1282
1379
  settings[k] = bot[k];
1380
+ if (bot.state_root !== undefined) {
1381
+ if (!text(bot.state_root))
1382
+ throw new Error("Invalid state_root");
1383
+ settings.state_root = path(bot.state_root);
1384
+ }
1283
1385
  const config = resolveConfig({ cwd, profile: bot.profile, settings, codexBin: doc.codex_bin }, {}, home);
1284
1386
  const identity = JSON.stringify([config.apiUrl, config.agentId]);
1285
1387
  if (identities.has(identity))
@@ -1310,6 +1412,7 @@ class AppServer {
1310
1412
  permissions;
1311
1413
  runtime;
1312
1414
  effort;
1415
+ model;
1313
1416
  onFatal;
1314
1417
  closed = false;
1315
1418
  child;
@@ -1317,13 +1420,17 @@ class AppServer {
1317
1420
  pending = new Map;
1318
1421
  active;
1319
1422
  threadPermissions = new Map;
1423
+ initialized = false;
1424
+ readonlyFilesystemReported = false;
1425
+ diagnosticTail = "";
1320
1426
  disabledMcp = {};
1321
- constructor(bin = "codex", args = ["app-server", "--listen", "stdio://"], _legacyTurnTimeoutMs, permissions = "full-access", runtime, effort) {
1427
+ constructor(bin = "codex", args = ["app-server", "--listen", "stdio://"], _legacyTurnTimeoutMs, permissions = "full-access", runtime, effort, model) {
1322
1428
  this.bin = bin;
1323
1429
  this.args = args;
1324
1430
  this.permissions = permissions;
1325
1431
  this.runtime = runtime;
1326
1432
  this.effort = effort;
1433
+ this.model = model;
1327
1434
  }
1328
1435
  get namespace() {
1329
1436
  return this.runtime?.home;
@@ -1336,10 +1443,16 @@ class AppServer {
1336
1443
  }
1337
1444
  const args = this.runtime && this.args[0] === "app-server" ? [...this.args, "-c", `sqlite_home=${JSON.stringify(this.runtime.home)}`] : this.args;
1338
1445
  this.child = spawn(this.bin, args, { env, stdio: "pipe" });
1339
- this.child.stderr.resume();
1446
+ this.child.stderr.on("data", (chunk) => {
1447
+ if (this.initialized)
1448
+ return;
1449
+ this.diagnosticTail = (this.diagnosticTail + chunk.toString()).slice(-4096);
1450
+ if (/read-only file system|\bEROFS\b|os error 30/i.test(this.diagnosticTail))
1451
+ this.readonlyFilesystemReported = true;
1452
+ });
1340
1453
  this.child.stdin.on("error", () => this.fatal(new Error("Codex input pipe closed")));
1341
1454
  this.child.on("error", () => this.fatal(new Error("Could not start Codex app-server")));
1342
- this.child.on("exit", () => this.fatal(new Error("Codex app-server exited")));
1455
+ this.child.on("exit", () => this.fatal(new Error(!this.initialized && this.readonlyFilesystemReported ? "Codex app-server exited before initialization after reporting a read-only filesystem. CODEX_HOME must be writable (including installation_id); sqlite_home/log_dir overrides alone are insufficient. Use an authorized writable state_root and dedicated home; authentication is a separate step." : "Codex app-server exited")));
1343
1456
  createInterface({ input: this.child.stdout }).on("line", (line) => {
1344
1457
  try {
1345
1458
  this.receive(JSON.parse(line));
@@ -1349,6 +1462,20 @@ class AppServer {
1349
1462
  });
1350
1463
  await this.request("initialize", { clientInfo: { name: "agentschat_bridge", version: "0.1.0" } });
1351
1464
  this.write({ method: "initialized" });
1465
+ this.initialized = true;
1466
+ this.diagnosticTail = "";
1467
+ }
1468
+ async authenticationStatus() {
1469
+ try {
1470
+ const result = await this.request("account/read", { refreshToken: false });
1471
+ if (result?.account && typeof result.account === "object")
1472
+ return "authenticated";
1473
+ if (result?.requiresOpenaiAuth === false)
1474
+ return "not-required";
1475
+ if (result?.requiresOpenaiAuth === true && result?.account === null)
1476
+ return "required";
1477
+ } catch {}
1478
+ return "unknown";
1352
1479
  }
1353
1480
  write(value) {
1354
1481
  if (this.closed || !this.child || this.child.exitCode !== null || this.child.stdin.destroyed)
@@ -1377,7 +1504,12 @@ class AppServer {
1377
1504
  const waiter = this.pending.get(message.id);
1378
1505
  if (waiter) {
1379
1506
  this.pending.delete(message.id);
1380
- message.error ? waiter.reject(waiter.method === "thread/resume" && /already has an active writer/i.test(message.error.message ?? "") ? new ThreadBusyError : new Error(`App-server request rejected (${message.error.code})`)) : waiter.resolve(message.result);
1507
+ if (message.error) {
1508
+ const detail = typeof message.error.message === "string" ? message.error.message : "";
1509
+ const error = waiter.method === "thread/resume" && /already has an active writer/i.test(detail) ? new ThreadBusyError : /app-server socket directory must be a user-owned directory with mode 0700/i.test(detail) ? new Error("Host sandbox rejected the app-server socket directory ownership or mode 0700. This executor may protect the fixed daemon socket directory; use a supported executor or host configuration. Do not weaken the sandbox or change existing host directory permissions. Model execution is not verified.") : /fs sandbox helper failed/i.test(detail) ? new Error("Host filesystem sandbox helper failed. Initialization and authentication do not prove model execution; use a supported host without bypassing sandbox restrictions.") : new Error(`App-server request rejected (${message.error.code})`);
1510
+ waiter.reject(error);
1511
+ } else
1512
+ waiter.resolve(message.result);
1381
1513
  }
1382
1514
  return;
1383
1515
  }
@@ -1418,6 +1550,7 @@ class AppServer {
1418
1550
  const r = await this.request(existing ? "thread/resume" : "thread/start", {
1419
1551
  ...existing ? { threadId: existing } : { ephemeral },
1420
1552
  cwd,
1553
+ ...this.model ? { model: this.model } : {},
1421
1554
  approvalPolicy: "never",
1422
1555
  sandbox: permissions === "full-access" ? "danger-full-access" : "read-only",
1423
1556
  ...permissions === "read-only" ? { config: { mcp_servers: this.disabledMcp } } : {},
@@ -1461,7 +1594,7 @@ class AppServer {
1461
1594
  });
1462
1595
  completed.catch(() => {});
1463
1596
  try {
1464
- const r = await this.request("turn/start", { threadId: thread, approvalPolicy: "never", sandboxPolicy: { type: permissions === "full-access" ? "dangerFullAccess" : "readOnly" }, input: [{ type: "text", text }], ...effort ? { effort } : {} });
1597
+ const r = await this.request("turn/start", { threadId: thread, approvalPolicy: "never", sandboxPolicy: { type: permissions === "full-access" ? "dangerFullAccess" : "readOnly" }, input: [{ type: "text", text }], ...effort ? { effort } : {}, ...this.model ? { model: this.model } : {} });
1465
1598
  const active = this.active;
1466
1599
  if (!active)
1467
1600
  return await completed;
@@ -2319,7 +2452,11 @@ CWD/.agentschat/profile.json > CWD/.codex/config.toml MCP profile > AGENTSCHAT_P
2319
2452
  Only the exact CWD is searched. Named profiles live in ~/.agentschat (legacy ~/.agentchat).
2320
2453
  An optional project agent_id must match the selected profile; it cannot replace it.
2321
2454
  Credentials: private profile JSON {agent_id, token}, chmod 600; never put keys in argv.
2322
- Project config fields: profile, agent_id, channels, senders, api_url, ws_url, permissions, effort.
2455
+ Project config fields: profile, agent_id, channels, senders, api_url, ws_url, permissions, effort, state_root, codex_home_mode.
2456
+ --state-root DIRECTORY relocates private state while retaining project/server/identity isolation.
2457
+ --codex-home-mode linked|isolated|auth-only: linked (default) reuses operator configuration/login; isolated imports nothing; auth-only explicitly links only auth.json.
2458
+ CODEX_HOME itself must be writable (including installation_id); sqlite_home/log_dir overrides alone do not suffice.
2459
+ Any credential reuse or independent login needs operator authorization; isolated initialization is not authenticated readiness.
2323
2460
  --onboarding-status checks authentication/ownership and prints safe claim/chat links; it does not send messages.
2324
2461
  --check validates identity and official app-server initialization without opening chat.
2325
2462
  Live DMs and exact mentions trigger replies; channels/senders restrict this further.
@@ -2337,6 +2474,8 @@ async function main() {
2337
2474
  const { values } = parseArgs({ options: {
2338
2475
  "codex-bridge": { type: "boolean" },
2339
2476
  cwd: { type: "string" },
2477
+ "state-root": { type: "string" },
2478
+ "codex-home-mode": { type: "string" },
2340
2479
  conversations: { type: "boolean" },
2341
2480
  "read-conversation": { type: "string" },
2342
2481
  "gui-thread": { type: "string" },
@@ -2367,6 +2506,8 @@ async function main() {
2367
2506
  console.log(JSON.stringify(receipt));
2368
2507
  return;
2369
2508
  }
2509
+ if ((values.bot || values["managed-worker"]) && (values["state-root"] || values["codex-home-mode"]))
2510
+ throw new Error("For managed bots, configure state_root/codex_home_mode in the bot registry");
2370
2511
  const snapshot = values["managed-worker"] ? await new Promise((resolve4, reject) => {
2371
2512
  if (!process.connected) {
2372
2513
  reject(new Error("Managed worker needs parent IPC"));
@@ -2378,7 +2519,7 @@ async function main() {
2378
2519
  resolve4(config);
2379
2520
  });
2380
2521
  }) : undefined;
2381
- const c = snapshot ?? (values.bot ? loadBots(values.registry).find((b) => b.name === values.bot) : resolveConfig({ cwd: values.cwd, profile: values.profile, codexBin: values["codex-bin"] }));
2522
+ const c = snapshot ?? (values.bot ? loadBots(values.registry).find((b) => b.name === values.bot) : resolveConfig({ cwd: values.cwd, profile: values.profile, codexBin: values["codex-bin"], stateRoot: values["state-root"], codexHomeMode: values["codex-home-mode"] }));
2382
2523
  if (!c)
2383
2524
  throw new Error("Bot is absent or disabled");
2384
2525
  if (values["onboarding-status"]) {
@@ -2394,8 +2535,8 @@ async function main() {
2394
2535
  return;
2395
2536
  }
2396
2537
  console.log(JSON.stringify({ cwd: c.cwd, agent_id: c.agentId, profile: c.profileFile, source: c.source, stateDir: c.stateDir }));
2397
- const runtime = prepareRuntimeHome(c.stateDir);
2398
- codex = new AppServer(c.codexBin, undefined, undefined, c.permissions, runtime, c.effort);
2538
+ const runtime = prepareRuntimeHome(c.stateDir, undefined, c.codexHomeMode, c.codexBin);
2539
+ codex = new AppServer(c.codexBin, undefined, undefined, c.permissions, runtime, c.effort, c.model);
2399
2540
  if (values.conversations || values["read-conversation"]) {
2400
2541
  const state = JSON.parse(readFileSync8(join9(c.stateDir, "state.json"), "utf8"));
2401
2542
  const channels = state.channels ?? {};
@@ -2417,6 +2558,7 @@ async function main() {
2417
2558
  if (values.check) {
2418
2559
  await codex.start();
2419
2560
  console.log("Official app-server initialization: OK (no chat connection or generation)");
2561
+ console.log(JSON.stringify({ authentication: await codex.authenticationStatus(), generation_verified: false, reply_verified: false, codex_home_mode: c.codexHomeMode ?? "linked" }));
2420
2562
  codex.close();
2421
2563
  return;
2422
2564
  }