offrouter-adapter-codex 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +99 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +361 -0
- package/dist/install.js.map +1 -0
- package/dist/profile.d.ts +27 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +71 -0
- package/dist/profile.js.map +1 -0
- package/dist/rollback.d.ts +23 -0
- package/dist/rollback.d.ts.map +1 -0
- package/dist/rollback.js +219 -0
- package/dist/rollback.js.map +1 -0
- package/package.json +8 -3
- package/src/index.ts +0 -35
- package/src/install.test.ts +0 -551
- package/src/install.ts +0 -472
- package/src/profile.ts +0 -99
- package/src/rollback.ts +0 -283
- package/tsconfig.json +0 -10
package/src/install.ts
DELETED
|
@@ -1,472 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Profile-scoped installer for Codex personal profiles.
|
|
3
|
-
*
|
|
4
|
-
* Unlike the Claude adapter (which copies a plugin tree), the Codex adapter
|
|
5
|
-
* writes ONE MCP server entry into the profile's TOML config file
|
|
6
|
-
* (profile-scoped) at <profileDir>/config.toml and records rollback metadata
|
|
7
|
-
* inside the target profile (.offrouter/codex-install-state.json).
|
|
8
|
-
*/
|
|
9
|
-
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
-
import { constants } from "node:fs";
|
|
11
|
-
import { createHash } from "node:crypto";
|
|
12
|
-
import { dirname, join } from "node:path";
|
|
13
|
-
import { homedir as osHomedir } from "node:os";
|
|
14
|
-
import {
|
|
15
|
-
ROUTE_PROTOCOL_VERSION,
|
|
16
|
-
routeRequest,
|
|
17
|
-
type OffRouterConfig,
|
|
18
|
-
type PolicyDenialCode,
|
|
19
|
-
type RouteRequest,
|
|
20
|
-
} from "offrouter-core";
|
|
21
|
-
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
22
|
-
import { z } from "zod";
|
|
23
|
-
import {
|
|
24
|
-
isCodexHarnessProfile,
|
|
25
|
-
isSafeProfileId,
|
|
26
|
-
isWorkLikeProfile,
|
|
27
|
-
resolveCodexProfileDir,
|
|
28
|
-
} from "./profile.js";
|
|
29
|
-
|
|
30
|
-
export const CODEX_INSTALL_STATE_REL =
|
|
31
|
-
".offrouter/codex-install-state.json" as const;
|
|
32
|
-
|
|
33
|
-
export const CODEX_CONFIG_REL = "config.toml" as const;
|
|
34
|
-
|
|
35
|
-
/** Table name and command/args for the OffRouter MCP server entry. */
|
|
36
|
-
const OFFROUTER_MCP_SERVER_NAME = "offrouter";
|
|
37
|
-
const OFFROUTER_MCP_COMMAND = "offrouter";
|
|
38
|
-
const OFFROUTER_MCP_ARGS: readonly string[] = ["mcp", "serve"];
|
|
39
|
-
|
|
40
|
-
export const CodexInstallStateSchema = z.object({
|
|
41
|
-
version: z.literal(1),
|
|
42
|
-
profile: z.string().min(1),
|
|
43
|
-
harness: z.literal("codex"),
|
|
44
|
-
installedAt: z.string().min(1),
|
|
45
|
-
configRelative: z.literal(CODEX_CONFIG_REL),
|
|
46
|
-
files: z.record(
|
|
47
|
-
z.string().min(1),
|
|
48
|
-
z.object({
|
|
49
|
-
previous: z.string().nullable(),
|
|
50
|
-
hash: z.string().optional(),
|
|
51
|
-
}),
|
|
52
|
-
),
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
export type CodexInstallState = z.infer<typeof CodexInstallStateSchema>;
|
|
56
|
-
|
|
57
|
-
export type InstallChangeKind = "create" | "update" | "unchanged" | "remove";
|
|
58
|
-
|
|
59
|
-
export interface InstallFileChange {
|
|
60
|
-
path: string;
|
|
61
|
-
relativePath: string;
|
|
62
|
-
kind: InstallChangeKind;
|
|
63
|
-
/** Diff-like summary line for human output. */
|
|
64
|
-
summary: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface CodexInstallOptions {
|
|
68
|
-
profile: string;
|
|
69
|
-
config: OffRouterConfig;
|
|
70
|
-
env?: NodeJS.ProcessEnv;
|
|
71
|
-
homedir?: () => string;
|
|
72
|
-
dryRun?: boolean;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface CodexInstallResult {
|
|
76
|
-
ok: boolean;
|
|
77
|
-
dryRun: boolean;
|
|
78
|
-
profile: string;
|
|
79
|
-
profileDir: string;
|
|
80
|
-
changes: InstallFileChange[];
|
|
81
|
-
message: string;
|
|
82
|
-
errorCode?: PolicyDenialCode | "install_error";
|
|
83
|
-
statePath?: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function pathExists(path: string): Promise<boolean> {
|
|
87
|
-
try {
|
|
88
|
-
await access(path, constants.F_OK);
|
|
89
|
-
return true;
|
|
90
|
-
} catch {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function sha256(content: string): string {
|
|
96
|
-
return createHash("sha256").update(content).digest("hex");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function changeSummary(kind: InstallChangeKind, relativePath: string): string {
|
|
100
|
-
switch (kind) {
|
|
101
|
-
case "create":
|
|
102
|
-
return `+ ${relativePath}`;
|
|
103
|
-
case "update":
|
|
104
|
-
return `~ ${relativePath}`;
|
|
105
|
-
case "unchanged":
|
|
106
|
-
return `= ${relativePath}`;
|
|
107
|
-
case "remove":
|
|
108
|
-
return `- ${relativePath}`;
|
|
109
|
-
default:
|
|
110
|
-
return `? ${relativePath}`;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Produce the desired config.toml content for the OffRouter MCP entry using a
|
|
116
|
-
* TOML parser (smol-toml) instead of ad hoc string surgery.
|
|
117
|
-
*
|
|
118
|
-
* - Missing/empty file => fresh config containing only the MCP entry.
|
|
119
|
-
* - Existing file already has [mcp_servers.offrouter] with matching
|
|
120
|
-
* command/args => return the original bytes unchanged (idempotent).
|
|
121
|
-
* - Existing file has a conflicting offrouter entry (or none) => set/replace
|
|
122
|
-
* the entry and stringify, preserving all other unrelated keys.
|
|
123
|
-
*
|
|
124
|
-
* smol-toml does not preserve comments on round-trip; that is accepted as
|
|
125
|
-
* best-effort per the project rule against ad hoc config parsing.
|
|
126
|
-
*/
|
|
127
|
-
export function patchConfigForMcp(existing: string | null): string {
|
|
128
|
-
if (existing === null || existing.trim() === "") {
|
|
129
|
-
return stringifyToml(freshOffrouterConfig());
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const root: Record<string, unknown> = parseToml(existing);
|
|
133
|
-
const servers = isTomlTable(root.mcp_servers) ? root.mcp_servers : undefined;
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
servers !== undefined &&
|
|
137
|
-
isOffrouterMcpEntry(servers[OFFROUTER_MCP_SERVER_NAME])
|
|
138
|
-
) {
|
|
139
|
-
return existing;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const nextRoot: Record<string, unknown> = {
|
|
143
|
-
...root,
|
|
144
|
-
mcp_servers: {
|
|
145
|
-
...(servers ?? {}),
|
|
146
|
-
[OFFROUTER_MCP_SERVER_NAME]: offrouterMcpEntry(),
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
return stringifyToml(nextRoot);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function isTomlTable(value: unknown): value is Record<string, unknown> {
|
|
153
|
-
return (
|
|
154
|
-
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* True when an existing [mcp_servers.offrouter] entry already matches the
|
|
160
|
-
* OffRouter command/args. Detects a correct prior install by value (not by
|
|
161
|
-
* string equality) so a conflicting entry is overwritten.
|
|
162
|
-
*/
|
|
163
|
-
function isOffrouterMcpEntry(value: unknown): boolean {
|
|
164
|
-
if (!isTomlTable(value)) {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
if (value.command !== OFFROUTER_MCP_COMMAND) {
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
const args = value.args;
|
|
171
|
-
if (!Array.isArray(args) || args.length !== OFFROUTER_MCP_ARGS.length) {
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
return args.every((item, index) => item === OFFROUTER_MCP_ARGS[index]);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function offrouterMcpEntry(): Record<string, unknown> {
|
|
178
|
-
return {
|
|
179
|
-
command: OFFROUTER_MCP_COMMAND,
|
|
180
|
-
args: [...OFFROUTER_MCP_ARGS],
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function freshOffrouterConfig(): Record<string, unknown> {
|
|
185
|
-
return {
|
|
186
|
-
mcp_servers: {
|
|
187
|
-
[OFFROUTER_MCP_SERVER_NAME]: offrouterMcpEntry(),
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function tinyPolicyProbeRequest(profile: string): RouteRequest {
|
|
193
|
-
return {
|
|
194
|
-
requestId: "install-policy-probe",
|
|
195
|
-
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
196
|
-
harness: { kind: "codex", profile },
|
|
197
|
-
task: {
|
|
198
|
-
promptPreview: "install policy probe",
|
|
199
|
-
promptDigest: "sha256:install-policy-probe",
|
|
200
|
-
kind: "other",
|
|
201
|
-
risk: "low",
|
|
202
|
-
},
|
|
203
|
-
workspace: { cwd: "/tmp/offrouter-install-probe", trusted: true },
|
|
204
|
-
constraints: {
|
|
205
|
-
subscriptionFirst: true,
|
|
206
|
-
allowApiKeyFallback: false,
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Use routeRequest with empty candidates so work/unallowlisted profile denials
|
|
213
|
-
* reuse the same core policy codes users see from route explain / hooks.
|
|
214
|
-
*/
|
|
215
|
-
export function evaluateInstallProfilePolicy(
|
|
216
|
-
profile: string,
|
|
217
|
-
config: OffRouterConfig,
|
|
218
|
-
): { allowed: boolean; errorCode?: PolicyDenialCode; message?: string } {
|
|
219
|
-
const decision = routeRequest(
|
|
220
|
-
tinyPolicyProbeRequest(profile),
|
|
221
|
-
[],
|
|
222
|
-
config.policy,
|
|
223
|
-
);
|
|
224
|
-
const denials = decision.policyDenials ?? [];
|
|
225
|
-
const hard = denials.find(
|
|
226
|
-
(d) =>
|
|
227
|
-
d.code === "work_profile_denied" ||
|
|
228
|
-
d.code === "profile_not_allowlisted" ||
|
|
229
|
-
d.code === "privacy_label_denied",
|
|
230
|
-
);
|
|
231
|
-
if (hard) {
|
|
232
|
-
return {
|
|
233
|
-
allowed: false,
|
|
234
|
-
errorCode: hard.code,
|
|
235
|
-
message: hard.message,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
// Empty candidates also sets needsConfiguration + may include
|
|
239
|
-
// profile_not_allowlisted even for personal unallowlisted. When allowlisted,
|
|
240
|
-
// only needsConfiguration remains; that is fine for install.
|
|
241
|
-
const profileDenial = denials.find((d) => d.target === "profile");
|
|
242
|
-
if (profileDenial) {
|
|
243
|
-
return {
|
|
244
|
-
allowed: false,
|
|
245
|
-
errorCode: profileDenial.code,
|
|
246
|
-
message: profileDenial.message,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
if (!config.policy.allowlistedProfiles.includes(profile)) {
|
|
250
|
-
return {
|
|
251
|
-
allowed: false,
|
|
252
|
-
errorCode: "profile_not_allowlisted",
|
|
253
|
-
message: `Profile ${profile} is not allowlisted. Add it explicitly before installing or routing.`,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
return { allowed: true };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Install the OffRouter Codex MCP entry into a single profile directory.
|
|
261
|
-
*/
|
|
262
|
-
export async function installCodexProfile(
|
|
263
|
-
options: CodexInstallOptions,
|
|
264
|
-
): Promise<CodexInstallResult> {
|
|
265
|
-
const {
|
|
266
|
-
profile,
|
|
267
|
-
config,
|
|
268
|
-
dryRun = false,
|
|
269
|
-
env = process.env,
|
|
270
|
-
homedir = osHomedir,
|
|
271
|
-
} = options;
|
|
272
|
-
|
|
273
|
-
if (!isSafeProfileId(profile)) {
|
|
274
|
-
return {
|
|
275
|
-
ok: false,
|
|
276
|
-
dryRun,
|
|
277
|
-
profile,
|
|
278
|
-
profileDir: "",
|
|
279
|
-
changes: [],
|
|
280
|
-
message:
|
|
281
|
-
"Unsafe profile id. Use letters, numbers, dots, underscores, and dashes only.",
|
|
282
|
-
errorCode: "install_error",
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const profileDir = resolveCodexProfileDir(profile, { env, homedir });
|
|
287
|
-
const statePath = join(profileDir, CODEX_INSTALL_STATE_REL);
|
|
288
|
-
const configPath = join(profileDir, CODEX_CONFIG_REL);
|
|
289
|
-
|
|
290
|
-
if (!isCodexHarnessProfile(profile)) {
|
|
291
|
-
return {
|
|
292
|
-
ok: false,
|
|
293
|
-
dryRun,
|
|
294
|
-
profile,
|
|
295
|
-
profileDir,
|
|
296
|
-
changes: [],
|
|
297
|
-
message:
|
|
298
|
-
"Codex install supports only profile ids named codex or starting with codex-.",
|
|
299
|
-
errorCode: "install_error",
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Work-like profile ids (e.g. codex-work, codex-work-staging) are never
|
|
304
|
-
// installable, even when allowlisted. This mirrors the filter used by
|
|
305
|
-
// listAllowlistedPersonalCodexProfiles so explicit and --all-personal
|
|
306
|
-
// installs apply the same personal-only boundary.
|
|
307
|
-
if (isWorkLikeProfile(profile)) {
|
|
308
|
-
return {
|
|
309
|
-
ok: false,
|
|
310
|
-
dryRun,
|
|
311
|
-
profile,
|
|
312
|
-
profileDir,
|
|
313
|
-
changes: [],
|
|
314
|
-
message: `Profile ${profile} looks like a work profile and is never installable, even if allowlisted. Use a personal profile such as codex-personal.`,
|
|
315
|
-
errorCode: "work_profile_denied",
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const policy = evaluateInstallProfilePolicy(profile, config);
|
|
320
|
-
if (!policy.allowed) {
|
|
321
|
-
return {
|
|
322
|
-
ok: false,
|
|
323
|
-
dryRun,
|
|
324
|
-
profile,
|
|
325
|
-
profileDir,
|
|
326
|
-
changes: [],
|
|
327
|
-
message: policy.message ?? `Profile ${profile} is not installable.`,
|
|
328
|
-
errorCode: policy.errorCode ?? "install_error",
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Preserve original previous contents across re-installs (idempotent rollback).
|
|
333
|
-
let priorState: CodexInstallState | null = null;
|
|
334
|
-
if (await pathExists(statePath)) {
|
|
335
|
-
try {
|
|
336
|
-
const raw = await readFile(statePath, "utf8");
|
|
337
|
-
priorState = CodexInstallStateSchema.parse(JSON.parse(raw));
|
|
338
|
-
} catch (err) {
|
|
339
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
340
|
-
return {
|
|
341
|
-
ok: false,
|
|
342
|
-
dryRun,
|
|
343
|
-
profile,
|
|
344
|
-
profileDir,
|
|
345
|
-
changes: [],
|
|
346
|
-
message: `Invalid install state at ${statePath}: ${detail}`,
|
|
347
|
-
errorCode: "install_error",
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const changes: InstallFileChange[] = [];
|
|
353
|
-
|
|
354
|
-
// --- config.toml MCP entry ---
|
|
355
|
-
let current: string | null = null;
|
|
356
|
-
if (await pathExists(configPath)) {
|
|
357
|
-
current = await readFile(configPath, "utf8");
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const priorFile = priorState?.files[CODEX_CONFIG_REL];
|
|
361
|
-
const previous =
|
|
362
|
-
priorFile !== undefined ? priorFile.previous : current;
|
|
363
|
-
|
|
364
|
-
const desired = patchConfigForMcp(current);
|
|
365
|
-
|
|
366
|
-
let kind: InstallChangeKind;
|
|
367
|
-
if (current === null) {
|
|
368
|
-
kind = "create";
|
|
369
|
-
} else if (current === desired) {
|
|
370
|
-
kind = "unchanged";
|
|
371
|
-
} else {
|
|
372
|
-
kind = "update";
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
changes.push({
|
|
376
|
-
path: configPath,
|
|
377
|
-
relativePath: CODEX_CONFIG_REL,
|
|
378
|
-
kind,
|
|
379
|
-
summary: changeSummary(kind, CODEX_CONFIG_REL),
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
if (!dryRun && kind !== "unchanged") {
|
|
383
|
-
await mkdir(dirname(configPath), { recursive: true });
|
|
384
|
-
await writeFile(configPath, desired, "utf8");
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// --- install state metadata ---
|
|
388
|
-
const state: CodexInstallState = {
|
|
389
|
-
version: 1,
|
|
390
|
-
profile,
|
|
391
|
-
harness: "codex",
|
|
392
|
-
installedAt: priorState?.installedAt ?? new Date().toISOString(),
|
|
393
|
-
configRelative: CODEX_CONFIG_REL,
|
|
394
|
-
files: {
|
|
395
|
-
[CODEX_CONFIG_REL]: {
|
|
396
|
-
previous,
|
|
397
|
-
hash: sha256(desired),
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
const stateJson = `${JSON.stringify(state, null, 2)}\n`;
|
|
403
|
-
let stateKind: InstallChangeKind = "create";
|
|
404
|
-
if (await pathExists(statePath)) {
|
|
405
|
-
const existing = await readFile(statePath, "utf8");
|
|
406
|
-
stateKind = existing === stateJson ? "unchanged" : "update";
|
|
407
|
-
}
|
|
408
|
-
changes.push({
|
|
409
|
-
path: statePath,
|
|
410
|
-
relativePath: CODEX_INSTALL_STATE_REL,
|
|
411
|
-
kind: stateKind,
|
|
412
|
-
summary: changeSummary(stateKind, CODEX_INSTALL_STATE_REL),
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
if (!dryRun && stateKind !== "unchanged") {
|
|
416
|
-
await mkdir(dirname(statePath), { recursive: true });
|
|
417
|
-
await writeFile(statePath, stateJson, "utf8");
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const wrote = changes.filter(
|
|
421
|
-
(c) => c.kind === "create" || c.kind === "update",
|
|
422
|
-
);
|
|
423
|
-
const message = dryRun
|
|
424
|
-
? `Dry run: would apply ${wrote.length} file change(s) for profile ${profile}.`
|
|
425
|
-
: `Installed OffRouter Codex MCP entry for profile ${profile} (${wrote.length} file change(s)).`;
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
ok: true,
|
|
429
|
-
dryRun,
|
|
430
|
-
profile,
|
|
431
|
-
profileDir,
|
|
432
|
-
changes,
|
|
433
|
-
message,
|
|
434
|
-
statePath,
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Human-readable diff-like text for an install/uninstall result matrix.
|
|
440
|
-
*/
|
|
441
|
-
export function formatInstallDiffText(
|
|
442
|
-
results: CodexInstallResult[],
|
|
443
|
-
headline?: string,
|
|
444
|
-
): string {
|
|
445
|
-
const lines: string[] = [];
|
|
446
|
-
if (headline) {
|
|
447
|
-
lines.push(headline);
|
|
448
|
-
}
|
|
449
|
-
for (const result of results) {
|
|
450
|
-
lines.push(``);
|
|
451
|
-
lines.push(
|
|
452
|
-
`## ${result.profile} (${result.ok ? (result.dryRun ? "dry-run" : "applied") : "error"})`,
|
|
453
|
-
);
|
|
454
|
-
lines.push(`profileDir: ${result.profileDir}`);
|
|
455
|
-
if (!result.ok) {
|
|
456
|
-
lines.push(`error: ${result.message}`);
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
for (const change of result.changes) {
|
|
460
|
-
if (change.kind === "unchanged") continue;
|
|
461
|
-
lines.push(change.summary);
|
|
462
|
-
}
|
|
463
|
-
const unchanged = result.changes.filter(
|
|
464
|
-
(c) => c.kind === "unchanged",
|
|
465
|
-
).length;
|
|
466
|
-
if (unchanged > 0) {
|
|
467
|
-
lines.push(`= ${unchanged} unchanged`);
|
|
468
|
-
}
|
|
469
|
-
lines.push(result.message);
|
|
470
|
-
}
|
|
471
|
-
return lines.join("\n").trimEnd() + "\n";
|
|
472
|
-
}
|
package/src/profile.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Codex profile directory resolution and allowlist helpers.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the Claude adapter's profile isolation model:
|
|
5
|
-
* - env OFFROUTER_CODEX_PROFILES_DIR set => <root>/<profile>
|
|
6
|
-
* - profile === "codex" => env CODEX_HOME || ~/.codex (bare global; only
|
|
7
|
-
* mutable when explicitly allowlisted)
|
|
8
|
-
* - otherwise => ~/.<profile> (e.g. codex-personal => ~/.codex-personal)
|
|
9
|
-
*/
|
|
10
|
-
import { homedir as osHomedir } from "node:os";
|
|
11
|
-
import { join, resolve } from "node:path";
|
|
12
|
-
import type { OffRouterConfig } from "offrouter-core";
|
|
13
|
-
|
|
14
|
-
export const SAFE_PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
-
|
|
16
|
-
export function isSafeProfileId(profile: string): boolean {
|
|
17
|
-
return SAFE_PROFILE_ID_RE.test(profile);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface ResolveCodexProfileDirOptions {
|
|
21
|
-
env?: NodeJS.ProcessEnv;
|
|
22
|
-
homedir?: () => string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function envValue(
|
|
26
|
-
env: NodeJS.ProcessEnv | undefined,
|
|
27
|
-
key: string,
|
|
28
|
-
): string | undefined {
|
|
29
|
-
const v = env?.[key]?.trim();
|
|
30
|
-
return v || undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Resolve the on-disk Codex profile directory for a OffRouter profile id.
|
|
35
|
-
*
|
|
36
|
-
* - When OFFROUTER_CODEX_PROFILES_DIR is set: $DIR/<profile>
|
|
37
|
-
* - profile id `codex` -> CODEX_HOME (if set) else ~/.codex
|
|
38
|
-
* - otherwise -> ~/.<profile> (e.g. codex-personal -> ~/.codex-personal)
|
|
39
|
-
*/
|
|
40
|
-
export function resolveCodexProfileDir(
|
|
41
|
-
profile: string,
|
|
42
|
-
options: ResolveCodexProfileDirOptions = {},
|
|
43
|
-
): string {
|
|
44
|
-
if (!isSafeProfileId(profile)) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`Unsafe profile id "${profile}". Use letters, numbers, dots, underscores, and dashes only.`,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
const env = options.env ?? process.env;
|
|
50
|
-
const home = (options.homedir ?? osHomedir)();
|
|
51
|
-
const profilesRoot = envValue(env, "OFFROUTER_CODEX_PROFILES_DIR");
|
|
52
|
-
if (profilesRoot) {
|
|
53
|
-
return resolve(join(profilesRoot, profile));
|
|
54
|
-
}
|
|
55
|
-
if (profile === "codex") {
|
|
56
|
-
const codexHome = envValue(env, "CODEX_HOME");
|
|
57
|
-
return resolve(codexHome ?? join(home, ".codex"));
|
|
58
|
-
}
|
|
59
|
-
return resolve(join(home, `.${profile}`));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Personal Codex profiles only: id starts with "codex" or is exactly "codex".
|
|
64
|
-
*/
|
|
65
|
-
export function isCodexHarnessProfile(profile: string): boolean {
|
|
66
|
-
return profile === "codex" || profile.startsWith("codex-");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function isWorkLikeProfile(profile: string): boolean {
|
|
70
|
-
return profile.endsWith("-work") || profile.includes("-work-");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function matchGlob(pattern: string, value: string): boolean {
|
|
74
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
75
|
-
const re = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
|
|
76
|
-
return re.test(value);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Allowlisted personal Codex profiles eligible for install --all-personal.
|
|
81
|
-
* Excludes the bare global `codex` profile (only mutable when explicit) and
|
|
82
|
-
* work-like ids, and applies deniedProfilePatterns glob filters.
|
|
83
|
-
*/
|
|
84
|
-
export function listAllowlistedPersonalCodexProfiles(
|
|
85
|
-
config: OffRouterConfig,
|
|
86
|
-
): string[] {
|
|
87
|
-
return [...config.policy.allowlistedProfiles]
|
|
88
|
-
.filter(
|
|
89
|
-
(id) =>
|
|
90
|
-
isSafeProfileId(id) &&
|
|
91
|
-
isCodexHarnessProfile(id) &&
|
|
92
|
-
id !== "codex" &&
|
|
93
|
-
!isWorkLikeProfile(id) &&
|
|
94
|
-
!(config.policy.deniedProfilePatterns ?? []).some((p) =>
|
|
95
|
-
matchGlob(p, id),
|
|
96
|
-
),
|
|
97
|
-
)
|
|
98
|
-
.sort((a, b) => a.localeCompare(b));
|
|
99
|
-
}
|