@parall/codex-agent 1.44.0 → 1.45.0
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/app-server-process.d.ts +9 -0
- package/dist/app-server-process.d.ts.map +1 -0
- package/dist/app-server-process.js +58 -0
- package/dist/app-server-protocol.d.ts +19 -0
- package/dist/app-server-protocol.d.ts.map +1 -0
- package/dist/app-server-protocol.js +42 -0
- package/dist/dispatch.d.ts +43 -5
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +28 -136
- package/dist/index.js +24 -10
- package/dist/legacy-workspace-config-migration.d.ts +112 -0
- package/dist/legacy-workspace-config-migration.d.ts.map +1 -0
- package/dist/legacy-workspace-config-migration.js +229 -0
- package/dist/turn-sink.d.ts +23 -0
- package/dist/turn-sink.d.ts.map +1 -0
- package/dist/turn-sink.js +40 -0
- package/dist/workspace.d.ts +23 -25
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +139 -138
- package/package.json +5 -5
- package/src/app-server-process.ts +59 -0
- package/src/app-server-protocol.ts +46 -0
- package/src/dispatch.ts +57 -140
- package/src/index.ts +32 -10
- package/src/legacy-workspace-config-migration.ts +296 -0
- package/src/turn-sink.ts +48 -0
- package/src/workspace.ts +156 -155
package/src/dispatch.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import * as fs from 'node:fs';
|
|
4
3
|
import * as path from 'node:path';
|
|
5
4
|
import {
|
|
6
5
|
appendPreparedLocalAttachmentRefs,
|
|
7
|
-
ensureLocalAttachmentGitExclude,
|
|
8
6
|
pinLocalAttachmentPaths,
|
|
9
7
|
} from '@parall/agent-core/internal/attachment-input';
|
|
10
8
|
import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
|
|
@@ -17,11 +15,18 @@ import type {
|
|
|
17
15
|
GatewayLogger,
|
|
18
16
|
RuntimeEvent,
|
|
19
17
|
} from '@parall/agent-core';
|
|
18
|
+
import { IS_WIN32, ensureGitRepo, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
|
|
19
|
+
import {
|
|
20
|
+
buildTurnInput,
|
|
21
|
+
extractThreadId,
|
|
22
|
+
extractThreadIdFromNotification,
|
|
23
|
+
extractTurnId,
|
|
24
|
+
} from './app-server-protocol.js';
|
|
20
25
|
import type { CodexAgentConfig } from './config.js';
|
|
21
26
|
import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
|
|
22
|
-
import { EventMapper } from './event-mapping.js';
|
|
23
27
|
import { JsonRpcStdioClient } from './jsonrpc-client.js';
|
|
24
28
|
import type { CodexSessionManager } from './session-manager.js';
|
|
29
|
+
import { TurnSink } from './turn-sink.js';
|
|
25
30
|
|
|
26
31
|
type CodexAppServerAdapterOptions = Pick<
|
|
27
32
|
CodexAgentConfig,
|
|
@@ -46,13 +51,28 @@ type CodexAppServerAdapterOptions = Pick<
|
|
|
46
51
|
* on its next shell command — no respawn needed.
|
|
47
52
|
*/
|
|
48
53
|
capabilityBinDir?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Platform system prompt, delivered per-thread via the app-server's typed
|
|
56
|
+
* top-level `developerInstructions` param — the ONLY delivery channel. It
|
|
57
|
+
* replaces the legacy workspace `.codex/config.toml` + global trust entry,
|
|
58
|
+
* which coupled the prompt to codex's interactive workspace-trust concept
|
|
59
|
+
* and made the bridge write into the operator's own config on shared homes.
|
|
60
|
+
*
|
|
61
|
+
* Where it lands, live-probed on 0.144.1 — the CLI accepts the param on all
|
|
62
|
+
* three entrypoints but only `thread/start` APPLIES it (instructions are
|
|
63
|
+
* baked into the thread there). `thread/resume` keeps the thread's own copy;
|
|
64
|
+
* `thread/fork` inherits the parent's. The bridge sends it on all three
|
|
65
|
+
* anyway: a failed resume falls back to thread/start on the same params, and
|
|
66
|
+
* a future CLI that honors it then needs no bridge change.
|
|
67
|
+
*
|
|
68
|
+
* Consequence: updateConfig() changes what the NEXT thread/start sends; it
|
|
69
|
+
* cannot re-instruct an already-persisted thread. Inherited from the retired
|
|
70
|
+
* channel, not introduced —
|
|
71
|
+
* docs/tech-debt/codex-persisted-thread-prompt-refresh.md.
|
|
72
|
+
*/
|
|
73
|
+
developerInstructions?: string;
|
|
49
74
|
};
|
|
50
75
|
|
|
51
|
-
type TurnEventEnvelope =
|
|
52
|
-
| { kind: 'runtime'; event: RuntimeEvent }
|
|
53
|
-
| { kind: 'turn_end'; threadId?: string }
|
|
54
|
-
| { kind: 'error'; message: string };
|
|
55
|
-
|
|
56
76
|
/**
|
|
57
77
|
* Bridge driver backed by `codex app-server --listen stdio://`.
|
|
58
78
|
*
|
|
@@ -67,22 +87,6 @@ type TurnEventEnvelope =
|
|
|
67
87
|
* main + fork can interleave turns on the same stdio pipe. We route
|
|
68
88
|
* notifications by threadId the server stamps on every item/turn event.
|
|
69
89
|
*/
|
|
70
|
-
const IS_WIN32 = process.platform === 'win32';
|
|
71
|
-
|
|
72
|
-
function quoteWin32Arg(arg: string): string {
|
|
73
|
-
if (!/[\s"&|^<>()]/.test(arg)) return arg;
|
|
74
|
-
return `"${arg.replace(/"/g, '""')}"`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function killWin32Tree(pid: number): boolean {
|
|
78
|
-
try {
|
|
79
|
-
execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: 'ignore' });
|
|
80
|
-
return true;
|
|
81
|
-
} catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
90
|
export class CodexAppServerAdapter implements DispatchAdapter {
|
|
87
91
|
private client: JsonRpcStdioClient | null = null;
|
|
88
92
|
private proc: ChildProcessWithoutNullStreams | null = null;
|
|
@@ -116,10 +120,16 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
116
120
|
|
|
117
121
|
constructor(private readonly opts: CodexAppServerAdapterOptions) {}
|
|
118
122
|
|
|
119
|
-
updateConfig(config: {
|
|
123
|
+
updateConfig(config: {
|
|
124
|
+
model?: string | null;
|
|
125
|
+
reasoningEffort?: string | null;
|
|
126
|
+
developerInstructions?: string | null;
|
|
127
|
+
}): void {
|
|
120
128
|
if (config.model !== undefined) this.opts.model = config.model ?? undefined;
|
|
121
129
|
if (config.reasoningEffort !== undefined)
|
|
122
130
|
this.opts.reasoningEffort = config.reasoningEffort ?? undefined;
|
|
131
|
+
if (config.developerInstructions !== undefined)
|
|
132
|
+
this.opts.developerInstructions = config.developerInstructions ?? undefined;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
async enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean> {
|
|
@@ -436,6 +446,11 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
436
446
|
if (this.opts.useParallProvider) {
|
|
437
447
|
forkParams.modelProvider = 'parall';
|
|
438
448
|
}
|
|
449
|
+
if (this.opts.developerInstructions) {
|
|
450
|
+
// Accepted, but the fork inherits the parent's instructions instead —
|
|
451
|
+
// so it carries the platform prompt either way. See the option doc.
|
|
452
|
+
forkParams.developerInstructions = this.opts.developerInstructions;
|
|
453
|
+
}
|
|
439
454
|
if (this.opts.model) forkParams.model = this.opts.model;
|
|
440
455
|
if (this.opts.reasoningEffort) {
|
|
441
456
|
// Raw config.toml key — see openThread for the snake_case rationale.
|
|
@@ -464,11 +479,14 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
464
479
|
}
|
|
465
480
|
|
|
466
481
|
/**
|
|
467
|
-
* Lazily restart the app-server before the NEXT turn
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
482
|
+
* Lazily restart the app-server before the NEXT turn, so a subprocess that
|
|
483
|
+
* has been running since before a capability change starts clean. Deferred
|
|
484
|
+
* to the next dispatch with no active turns — never kills an in-flight turn;
|
|
485
|
+
* thread state survives via thread/resume.
|
|
486
|
+
*
|
|
487
|
+
* A restart does NOT re-instruct an already-persisted thread — see the
|
|
488
|
+
* `developerInstructions` option doc. The capability shim dir on PATH is what
|
|
489
|
+
* makes a grant/revocation effective immediately.
|
|
472
490
|
*/
|
|
473
491
|
requestProcessRestart(): void {
|
|
474
492
|
this.restartRequested = true;
|
|
@@ -480,7 +498,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
480
498
|
if (!this.restartRequested || this.activeTurns.size > 0) return;
|
|
481
499
|
this.restartRequested = false;
|
|
482
500
|
(log ?? this.opts.log)?.info?.(
|
|
483
|
-
'restarting codex app-server
|
|
501
|
+
'restarting codex app-server after a capability change (new threads pick up the refreshed developerInstructions; an already-persisted thread keeps its own)',
|
|
484
502
|
);
|
|
485
503
|
await this.stop();
|
|
486
504
|
}
|
|
@@ -693,6 +711,13 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
693
711
|
commonParams.modelProvider = 'parall';
|
|
694
712
|
}
|
|
695
713
|
commonParams.sandbox = normalizeSandbox(this.opts.sandbox);
|
|
714
|
+
if (this.opts.developerInstructions) {
|
|
715
|
+
// Typed top-level param (camelCase), NOT a raw config.toml override —
|
|
716
|
+
// trust-independent, so the platform prompt loads regardless of any codex
|
|
717
|
+
// workspace-trust state. thread/start applies it; thread/resume keeps the
|
|
718
|
+
// thread's own copy. Sent on both — see the option doc.
|
|
719
|
+
commonParams.developerInstructions = this.opts.developerInstructions;
|
|
720
|
+
}
|
|
696
721
|
if (this.opts.model) commonParams.model = this.opts.model;
|
|
697
722
|
if (this.opts.reasoningEffort) {
|
|
698
723
|
// The nested `config` object is raw config.toml overrides and keeps the
|
|
@@ -747,114 +772,6 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
747
772
|
}
|
|
748
773
|
}
|
|
749
774
|
|
|
750
|
-
/** Per-turn buffered sink backed by an unbounded promise queue. */
|
|
751
|
-
class TurnSink {
|
|
752
|
-
readonly mapper = new EventMapper();
|
|
753
|
-
private readonly queue: TurnEventEnvelope[] = [];
|
|
754
|
-
private resolver: ((value: TurnEventEnvelope) => void) | null = null;
|
|
755
|
-
private closed = false;
|
|
756
|
-
|
|
757
|
-
push(envelope: TurnEventEnvelope) {
|
|
758
|
-
if (this.closed) return;
|
|
759
|
-
if (this.resolver) {
|
|
760
|
-
const r = this.resolver;
|
|
761
|
-
this.resolver = null;
|
|
762
|
-
r(envelope);
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
this.queue.push(envelope);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
next(): Promise<TurnEventEnvelope> {
|
|
769
|
-
// Drain any queued envelopes first, even after close(). Otherwise a final
|
|
770
|
-
// error envelope enqueued right before close() (e.g. by
|
|
771
|
-
// handleSubprocessClose) is silently dropped because the consumer would
|
|
772
|
-
// see turn_end before it.
|
|
773
|
-
const pending = this.queue.shift();
|
|
774
|
-
if (pending) return Promise.resolve(pending);
|
|
775
|
-
if (this.closed) {
|
|
776
|
-
return Promise.resolve({ kind: 'turn_end' });
|
|
777
|
-
}
|
|
778
|
-
return new Promise((resolve) => {
|
|
779
|
-
this.resolver = resolve;
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
close() {
|
|
784
|
-
this.closed = true;
|
|
785
|
-
const r = this.resolver;
|
|
786
|
-
this.resolver = null;
|
|
787
|
-
r?.({ kind: 'turn_end' });
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function ensureGitRepo(workingDirectory: string): void {
|
|
792
|
-
fs.mkdirSync(workingDirectory, { recursive: true });
|
|
793
|
-
// Only `git init` if the workspace isn't already inside any git repo. A
|
|
794
|
-
// bare existsSync(.git) check would miss the common case of a user pointing
|
|
795
|
-
// PRLL_WORKSPACE_DIR at a subdirectory of their existing project,
|
|
796
|
-
// and silently creating a nested repo there would mangle their layout.
|
|
797
|
-
try {
|
|
798
|
-
execSync('git rev-parse --is-inside-work-tree', { cwd: workingDirectory, stdio: 'pipe' });
|
|
799
|
-
ensureLocalAttachmentGitExclude(workingDirectory);
|
|
800
|
-
return;
|
|
801
|
-
} catch {
|
|
802
|
-
// Not inside a repo — fall through to init.
|
|
803
|
-
}
|
|
804
|
-
const env = {
|
|
805
|
-
...process.env,
|
|
806
|
-
GIT_AUTHOR_NAME: 'parall-codex-agent',
|
|
807
|
-
GIT_AUTHOR_EMAIL: 'agent@parall.local',
|
|
808
|
-
GIT_COMMITTER_NAME: 'parall-codex-agent',
|
|
809
|
-
GIT_COMMITTER_EMAIL: 'agent@parall.local',
|
|
810
|
-
};
|
|
811
|
-
try {
|
|
812
|
-
execSync('git init', { cwd: workingDirectory, stdio: 'pipe', env });
|
|
813
|
-
execSync('git commit --allow-empty -m init', { cwd: workingDirectory, stdio: 'pipe', env });
|
|
814
|
-
ensureLocalAttachmentGitExclude(workingDirectory);
|
|
815
|
-
} catch {
|
|
816
|
-
// Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
type CodexTurnInput = { type: 'text'; text: string } | { type: 'localImage'; path: string };
|
|
821
|
-
|
|
822
|
-
function buildTurnInput(body: string, images: PreparedLocalImage[]): CodexTurnInput[] {
|
|
823
|
-
return [
|
|
824
|
-
{ type: 'text', text: body },
|
|
825
|
-
...images.map((image) => ({ type: 'localImage' as const, path: image.localPath })),
|
|
826
|
-
];
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function extractThreadId(result: unknown): string | undefined {
|
|
830
|
-
if (!result || typeof result !== 'object') return undefined;
|
|
831
|
-
const r = result as Record<string, unknown>;
|
|
832
|
-
if (typeof r.threadId === 'string') return r.threadId;
|
|
833
|
-
const thread = r.thread as Record<string, unknown> | undefined;
|
|
834
|
-
if (thread && typeof thread.id === 'string') return thread.id;
|
|
835
|
-
return undefined;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function extractTurnId(result: unknown): string | undefined {
|
|
839
|
-
if (!result || typeof result !== 'object') return undefined;
|
|
840
|
-
const r = result as Record<string, unknown>;
|
|
841
|
-
if (typeof r.turnId === 'string') return r.turnId;
|
|
842
|
-
const turn = r.turn as Record<string, unknown> | undefined;
|
|
843
|
-
if (turn && typeof turn.id === 'string') return turn.id;
|
|
844
|
-
return undefined;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function extractThreadIdFromNotification(params: unknown): string | undefined {
|
|
848
|
-
if (!params || typeof params !== 'object') return undefined;
|
|
849
|
-
const p = params as Record<string, unknown>;
|
|
850
|
-
if (typeof p.threadId === 'string') return p.threadId;
|
|
851
|
-
const thread = p.thread as Record<string, unknown> | undefined;
|
|
852
|
-
if (thread && typeof thread.id === 'string') return thread.id;
|
|
853
|
-
const meta = (p._meta ?? p.meta) as Record<string, unknown> | undefined;
|
|
854
|
-
if (meta && typeof meta.threadId === 'string') return meta.threadId;
|
|
855
|
-
return undefined;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
775
|
function errToString(err: unknown): string {
|
|
859
776
|
if (err instanceof Error) return err.message;
|
|
860
777
|
return String(err);
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,6 @@ import { CodexSessionManager } from './session-manager.js';
|
|
|
34
34
|
import {
|
|
35
35
|
ensureCodexWorkspace,
|
|
36
36
|
ensureParallProvider,
|
|
37
|
-
ensureWorkspaceTrusted,
|
|
38
37
|
isParallProxyMode,
|
|
39
38
|
writeCodexSystemPrompt,
|
|
40
39
|
} from './workspace.js';
|
|
@@ -85,7 +84,6 @@ async function main() {
|
|
|
85
84
|
const agentUserId = me.id;
|
|
86
85
|
const agentLog = childLogger(activeLog, agentUserId);
|
|
87
86
|
activeLog = agentLog;
|
|
88
|
-
ensureWorkspaceTrusted(config.codexHome, config.workspaceDir, agentLog);
|
|
89
87
|
const useParallProvider = isParallProxyMode();
|
|
90
88
|
if (useParallProvider) {
|
|
91
89
|
ensureParallProvider(config.codexHome, config.apiUrl, agentLog);
|
|
@@ -124,11 +122,19 @@ async function main() {
|
|
|
124
122
|
materializeChannelCapabilities(config.stateDir, caps, agentLog);
|
|
125
123
|
return caps.map((c) => c.fragment);
|
|
126
124
|
};
|
|
127
|
-
// Assemble the workspace AFTER the first config fetch so
|
|
128
|
-
//
|
|
125
|
+
// Assemble the workspace AFTER the first config fetch so the platform
|
|
126
|
+
// prompt carries the capability declarations from boot.
|
|
129
127
|
const bootCapabilityFragments = applyChannelCapabilities();
|
|
130
128
|
let lastCapabilityFragments = bootCapabilityFragments.join('\n\n');
|
|
131
|
-
|
|
129
|
+
// Platform instructions are delivered per-thread via the app-server's
|
|
130
|
+
// developerInstructions param — no workspace config file, no dependence
|
|
131
|
+
// on codex's workspace-trust state, no writes to the operator's config.
|
|
132
|
+
const developerInstructions = ensureCodexWorkspace(
|
|
133
|
+
config.workspaceDir,
|
|
134
|
+
agentLog,
|
|
135
|
+
agentIdentity,
|
|
136
|
+
bootCapabilityFragments,
|
|
137
|
+
);
|
|
132
138
|
// Model precedence: operator PIN (override) > env > server FLOOR. The server
|
|
133
139
|
// now says which it is via model_is_pin (deriveModelIsPin presence-gates the
|
|
134
140
|
// dual-read: old servers omit it → fall back to model_management). A PIN beats
|
|
@@ -162,6 +168,7 @@ async function main() {
|
|
|
162
168
|
contextDirPath: dispatchContextDirPath(config.stateDir),
|
|
163
169
|
useParallProvider,
|
|
164
170
|
capabilityBinDir: capabilityBinDir(config.stateDir),
|
|
171
|
+
developerInstructions,
|
|
165
172
|
});
|
|
166
173
|
|
|
167
174
|
// Shared by onConfigUpdate + onSessionReady. /agents/me is only consumed as
|
|
@@ -191,15 +198,30 @@ async function main() {
|
|
|
191
198
|
model: resolveRuntimeModel(isPin, updated.model, config.model) ?? null,
|
|
192
199
|
reasoningEffort: updated.thinkingEffort ?? config.reasoningEffort ?? null,
|
|
193
200
|
});
|
|
194
|
-
// Capability heat-update: re-materialize shims +
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
201
|
+
// Capability heat-update: re-materialize shims + rebuild the prompt.
|
|
202
|
+
// The write is refresh-tolerant (warn + retry next refresh); only a
|
|
203
|
+
// SUCCESSFUL rebuild with a changed fragment set schedules the lazy
|
|
204
|
+
// app-server restart.
|
|
205
|
+
//
|
|
206
|
+
// What each half of the refresh actually reaches: the shim dir on PATH
|
|
207
|
+
// makes the granted TOOL work on the agent's next shell command, live,
|
|
208
|
+
// no restart needed. The refreshed PROMPT reaches the next thread that
|
|
209
|
+
// gets STARTED — codex bakes developerInstructions into a thread at
|
|
210
|
+
// thread/start and neither resume nor fork replaces them (live-probed on
|
|
211
|
+
// 0.144.1; the retired workspace-config channel had the same limitation).
|
|
212
|
+
// So an agent with a long-lived persisted thread keeps the older fragment
|
|
213
|
+
// text in its prompt until that thread is replaced —
|
|
214
|
+
// docs/tech-debt/codex-persisted-thread-prompt-refresh.md.
|
|
198
215
|
const fragments = applyChannelCapabilities();
|
|
199
216
|
const joinedFragments = fragments.join('\n\n');
|
|
200
217
|
let promptWritten = true;
|
|
201
218
|
try {
|
|
202
|
-
writeCodexSystemPrompt(
|
|
219
|
+
const refreshedPrompt = writeCodexSystemPrompt(
|
|
220
|
+
config.workspaceDir,
|
|
221
|
+
agentIdentity,
|
|
222
|
+
fragments,
|
|
223
|
+
);
|
|
224
|
+
adapter.updateConfig({ developerInstructions: refreshedPrompt });
|
|
203
225
|
} catch (err) {
|
|
204
226
|
promptWritten = false;
|
|
205
227
|
agentLog.warn(`system prompt refresh write failed (retrying next refresh): ${String(err)}`);
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One-shot migration for workspaces last bootstrapped by a pre-protocol-delivery
|
|
6
|
+
* bridge.
|
|
7
|
+
*
|
|
8
|
+
* Those generations (every released bridge up to and including v1.44.0) injected
|
|
9
|
+
* the system prompt by writing `<workspace>/.codex/config.toml`
|
|
10
|
+
* (`developer_instructions`) and marking the workspace trusted in the operator's
|
|
11
|
+
* global codex config. Instructions now ride the app-server's per-thread
|
|
12
|
+
* `developerInstructions` param, so a leftover bridge-authored file would DOUBLE
|
|
13
|
+
* the instructions on any workspace codex still considers trusted.
|
|
14
|
+
*
|
|
15
|
+
* Deleting a file in someone's workspace is the destructive direction, so the
|
|
16
|
+
* gate is fail-closed: we remove ONLY what is provably the retired bridge's own
|
|
17
|
+
* artifact, and preserve (with a warning) everything else. A duplicated prompt
|
|
18
|
+
* is a degraded agent; a deleted operator file is lost work.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS IS ONE-SHOT AND NOT MERELY IDEMPOTENT. The authorship proof is
|
|
21
|
+
* `.parall/system-prompt.md`, and the bridge REWRITES that file on every boot.
|
|
22
|
+
* So it is legacy-era evidence exactly once: on the first protocol-delivery boot,
|
|
23
|
+
* before anything overwrites it. A gate that merely re-ran each boot would, from
|
|
24
|
+
* boot 2 on, be comparing the legacy config against a prompt THIS bridge wrote —
|
|
25
|
+
* evidence it manufactured itself. That is not hypothetical: prompt assembly is
|
|
26
|
+
* deterministic, so a file the first boot explicitly preserved as "not provably
|
|
27
|
+
* ours" can match on the next boot and be silently deleted, contradicting the
|
|
28
|
+
* warning the operator was just given. Fail-closed has to hold across the whole
|
|
29
|
+
* migration lifecycle, not per function call.
|
|
30
|
+
*
|
|
31
|
+
* Hence a versioned, one-way CLAIM (`.parall/legacy-workspace-config-migration.v1`),
|
|
32
|
+
* created atomically (`wx`) BEFORE any prompt write:
|
|
33
|
+
* - exactly one boot ever wins the claim — it alone may delete;
|
|
34
|
+
* - every later boot (and every concurrent loser) skips, silently, forever;
|
|
35
|
+
* - a claim that cannot be persisted FAILS the boot before the proof is
|
|
36
|
+
* overwritten, so a retry still has real evidence;
|
|
37
|
+
* - a boot that dies mid-migration leaves the claim behind, so the file is
|
|
38
|
+
* preserved forever rather than re-judged against fabricated evidence.
|
|
39
|
+
*
|
|
40
|
+
* The whole module is dead code once no workspace can still hold a pre-#1866
|
|
41
|
+
* artifact — sunset conditions in
|
|
42
|
+
* docs/tech-debt/codex-legacy-workspace-config-shim.md.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Relative location of the retired bridge's workspace config. */
|
|
46
|
+
const LEGACY_CONFIG_RELPATH = ['.codex', 'config.toml'];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one-way claim. Versioned: a future migration gets its own sentinel rather
|
|
50
|
+
* than reusing (or re-arming) this one.
|
|
51
|
+
*/
|
|
52
|
+
const MIGRATION_SENTINEL_RELPATH = ['.parall', 'legacy-workspace-config-migration.v1'];
|
|
53
|
+
|
|
54
|
+
export function legacyWorkspaceConfigPath(workspaceDir: string): string {
|
|
55
|
+
return path.join(workspaceDir, ...LEGACY_CONFIG_RELPATH);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function migrationSentinelPath(workspaceDir: string): string {
|
|
59
|
+
return path.join(workspaceDir, ...MIGRATION_SENTINEL_RELPATH);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Byte-exact reconstruction of the retired serializer. Verified identical across
|
|
64
|
+
* every released bridge that wrote this file (v1.37.0 … v1.44.0 — the line is
|
|
65
|
+
* byte-for-byte the same in all of them), so a single reconstruction covers the
|
|
66
|
+
* whole legacy fleet. The deletion gate compares raw bytes against this: it must
|
|
67
|
+
* never drift.
|
|
68
|
+
*/
|
|
69
|
+
export function legacyWorkspaceConfigToml(prompt: string): string {
|
|
70
|
+
return `developer_instructions = """\n${prompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The PRE-OVERWRITE `.parall/system-prompt.md`. The retired bridge wrote the
|
|
75
|
+
* reference copy and the config from the SAME string in the same call, so a byte
|
|
76
|
+
* match against `legacyWorkspaceConfigToml(prompt)` is what identifies our own
|
|
77
|
+
* artifact.
|
|
78
|
+
*
|
|
79
|
+
* `absent` (ENOENT — the bridge never bootstrapped this workspace) and
|
|
80
|
+
* `unreadable` (it is there, but an I/O error hid it) both mean "cannot prove
|
|
81
|
+
* ownership, so do not delete" — but they are different facts, and reporting the
|
|
82
|
+
* second as the first tells the operator the wrong reason for a preserved file.
|
|
83
|
+
*/
|
|
84
|
+
export type AuthorshipProof =
|
|
85
|
+
| { kind: 'present'; prompt: string }
|
|
86
|
+
| { kind: 'absent' }
|
|
87
|
+
| { kind: 'unreadable' };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What the filesystem says about the legacy config. Gathered by the executor;
|
|
91
|
+
* the classifier below sees nothing else — no fs, no clock, no env.
|
|
92
|
+
*/
|
|
93
|
+
export type LegacyConfigFacts = {
|
|
94
|
+
/** lstat of the legacy path — never a stat: a symlink must not be followed. `null` = ENOENT. */
|
|
95
|
+
entry: { isPlainFile: boolean; hardLinks: number } | null;
|
|
96
|
+
/** Raw bytes of the legacy file. `null` = not a plain file, or unreadable. */
|
|
97
|
+
rawContent: string | null;
|
|
98
|
+
proof: AuthorshipProof;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type LegacyConfigVerdict =
|
|
102
|
+
| { action: 'none' }
|
|
103
|
+
| { action: 'remove' }
|
|
104
|
+
| { action: 'preserve'; reason: PreserveReason };
|
|
105
|
+
|
|
106
|
+
export type PreserveReason =
|
|
107
|
+
/** Symlink (a dotfiles arrangement) or a directory — unlink-by-path would sever the operator's link. */
|
|
108
|
+
| 'not-a-plain-file'
|
|
109
|
+
/** Extra hard links: the same inode is reachable from a path we know nothing about. */
|
|
110
|
+
| 'extra-hard-links'
|
|
111
|
+
/** No `.parall/system-prompt.md` from a previous boot — no proof can exist. */
|
|
112
|
+
| 'no-authorship-proof'
|
|
113
|
+
/** The proof copy exists but could not be read, so ownership cannot be evaluated. */
|
|
114
|
+
| 'authorship-proof-unreadable'
|
|
115
|
+
/** Could not read the legacy config's own bytes, so the proof cannot be applied. */
|
|
116
|
+
| 'config-unreadable'
|
|
117
|
+
/** Bytes differ from what the retired bridge would have written (operator-authored, comments, hand-edited). */
|
|
118
|
+
| 'content-mismatch';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pure. Decides the fate of the legacy config from filesystem facts alone.
|
|
122
|
+
*
|
|
123
|
+
* The only path to `remove` is: plain regular file + exactly one hard link + a
|
|
124
|
+
* pre-overwrite reference copy exists + raw bytes are EXACTLY the retired
|
|
125
|
+
* serializer's output for that reference prompt. Every other combination
|
|
126
|
+
* preserves — including value-level near-misses (an operator file carrying the
|
|
127
|
+
* same `developer_instructions` value plus a comment would be destroyed together
|
|
128
|
+
* with the comment by a value-level compare).
|
|
129
|
+
*/
|
|
130
|
+
export function classifyLegacyWorkspaceConfig(facts: LegacyConfigFacts): LegacyConfigVerdict {
|
|
131
|
+
const { entry, rawContent, proof } = facts;
|
|
132
|
+
if (!entry) return { action: 'none' };
|
|
133
|
+
if (!entry.isPlainFile) return { action: 'preserve', reason: 'not-a-plain-file' };
|
|
134
|
+
if (entry.hardLinks !== 1) return { action: 'preserve', reason: 'extra-hard-links' };
|
|
135
|
+
if (proof.kind === 'absent') return { action: 'preserve', reason: 'no-authorship-proof' };
|
|
136
|
+
if (proof.kind === 'unreadable') {
|
|
137
|
+
return { action: 'preserve', reason: 'authorship-proof-unreadable' };
|
|
138
|
+
}
|
|
139
|
+
if (rawContent === null) return { action: 'preserve', reason: 'config-unreadable' };
|
|
140
|
+
if (rawContent !== legacyWorkspaceConfigToml(proof.prompt)) {
|
|
141
|
+
return { action: 'preserve', reason: 'content-mismatch' };
|
|
142
|
+
}
|
|
143
|
+
return { action: 'remove' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const PRESERVE_DETAIL: Record<PreserveReason, string> = {
|
|
147
|
+
'not-a-plain-file':
|
|
148
|
+
'it is a symlink or directory, not a plain file the bridge could have written',
|
|
149
|
+
'extra-hard-links': 'the file has more than one hard link, so another path shares this inode',
|
|
150
|
+
'no-authorship-proof':
|
|
151
|
+
'this workspace has no .parall/system-prompt.md from a previous boot, so nothing here can be proven ours',
|
|
152
|
+
'authorship-proof-unreadable':
|
|
153
|
+
'the .parall/system-prompt.md authorship proof exists but could not be read, so ownership cannot be evaluated',
|
|
154
|
+
'config-unreadable': 'its own bytes could not be read, so authorship cannot be proven',
|
|
155
|
+
'content-mismatch': 'its bytes are not what the retired bridge would have written',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const SENTINEL_BODY = `Parall codex bridge — one-shot workspace migration record (v1)
|
|
159
|
+
|
|
160
|
+
The legacy workspace-config cleanup has been CLAIMED for this workspace. Only the
|
|
161
|
+
boot that created this file was allowed to remove a bridge-authored
|
|
162
|
+
.codex/config.toml, and only under a byte-exact authorship proof.
|
|
163
|
+
|
|
164
|
+
While this file exists the bridge will NEVER auto-remove .codex/config.toml again.
|
|
165
|
+
Deleting this file does not safely re-arm the migration: the evidence it relied on
|
|
166
|
+
(.parall/system-prompt.md as written by a pre-protocol-delivery bridge) has since
|
|
167
|
+
been overwritten by this bridge, so a re-run could mistake a prompt it wrote itself
|
|
168
|
+
for legacy evidence and delete a file that is not ours.
|
|
169
|
+
|
|
170
|
+
If a stale .codex/config.toml is still present, remove it by hand.
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
export type MigrationClaim = 'claimed' | 'already-claimed';
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Atomically take the one-way claim. `wx` makes this a single filesystem
|
|
177
|
+
* operation, so concurrent boots cannot both win.
|
|
178
|
+
*
|
|
179
|
+
* THROWS if the claim cannot be persisted (EACCES, EIO, EROFS …). That is
|
|
180
|
+
* deliberate and load-bearing: the caller runs this BEFORE overwriting
|
|
181
|
+
* `.parall/system-prompt.md`, so a boot that cannot record its claim must die
|
|
182
|
+
* with the legacy evidence still intact rather than proceed to manufacture a
|
|
183
|
+
* prompt that a later boot would mistake for that evidence.
|
|
184
|
+
*/
|
|
185
|
+
export function claimLegacyWorkspaceConfigMigration(workspaceDir: string): MigrationClaim {
|
|
186
|
+
const sentinel = migrationSentinelPath(workspaceDir);
|
|
187
|
+
// The module owns its own sentinel, directory included — a caller that has not
|
|
188
|
+
// created `.parall` yet must still get a real claim, not an ENOENT throw.
|
|
189
|
+
fs.mkdirSync(path.dirname(sentinel), { recursive: true });
|
|
190
|
+
try {
|
|
191
|
+
fs.writeFileSync(sentinel, SENTINEL_BODY, { flag: 'wx' });
|
|
192
|
+
return 'claimed';
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return 'already-claimed';
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The migration lifecycle: claim, then — only if we won the claim — read the
|
|
201
|
+
* proof and clean up.
|
|
202
|
+
*
|
|
203
|
+
* `readProof` is a THUNK, not a value, and that is the whole point. Reading the
|
|
204
|
+
* proof is not free of side effects: an unreadable `.parall/system-prompt.md`
|
|
205
|
+
* warns. Passed as an already-evaluated argument, that warning fires before the
|
|
206
|
+
* claim is even checked — so an already-claimed later boot, or a concurrent
|
|
207
|
+
* loser, would emit a migration warning about a decision it is not making. The
|
|
208
|
+
* thunk makes "only the winner touches the proof" a property of the type rather
|
|
209
|
+
* than of the caller's evaluation order.
|
|
210
|
+
*
|
|
211
|
+
* The caller still owns `.parall/system-prompt.md` and must hand over the
|
|
212
|
+
* PRE-OVERWRITE copy: the winner evaluates the thunk here, before anything
|
|
213
|
+
* writes a new prompt.
|
|
214
|
+
*
|
|
215
|
+
* A boot that does not win the claim returns silently — it must not delete, it
|
|
216
|
+
* must not read, and it must not warn: the winner may be deleting the very file
|
|
217
|
+
* it would warn about, and a stale warning about a file that is already gone is
|
|
218
|
+
* exactly the misleading diagnostic this module exists to avoid.
|
|
219
|
+
*/
|
|
220
|
+
export function runLegacyWorkspaceConfigMigration(
|
|
221
|
+
workspaceDir: string,
|
|
222
|
+
readProof: () => AuthorshipProof,
|
|
223
|
+
log?: { warn: (msg: string) => void },
|
|
224
|
+
): void {
|
|
225
|
+
if (claimLegacyWorkspaceConfigMigration(workspaceDir) === 'already-claimed') return;
|
|
226
|
+
cleanupLegacyWorkspaceConfig(workspaceDir, readProof(), log);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Filesystem execution for the one boot that holds the claim. Reads the facts,
|
|
231
|
+
* asks the classifier, applies the verdict.
|
|
232
|
+
*
|
|
233
|
+
* Never throws: a workspace that cannot be migrated must still boot. Every
|
|
234
|
+
* non-ENOENT problem warns.
|
|
235
|
+
*/
|
|
236
|
+
function cleanupLegacyWorkspaceConfig(
|
|
237
|
+
workspaceDir: string,
|
|
238
|
+
proof: AuthorshipProof,
|
|
239
|
+
log?: { warn: (msg: string) => void },
|
|
240
|
+
): void {
|
|
241
|
+
const configPath = legacyWorkspaceConfigPath(workspaceDir);
|
|
242
|
+
|
|
243
|
+
let entry: fs.Stats;
|
|
244
|
+
try {
|
|
245
|
+
entry = fs.lstatSync(configPath);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Only ENOENT means "nothing to clean up". Anything else (EACCES, EIO, …)
|
|
248
|
+
// leaves an unverifiable file behind — surface it rather than silently
|
|
249
|
+
// treating it as absent.
|
|
250
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
251
|
+
log?.warn(`could not inspect legacy workspace codex config ${configPath}: ${String(err)}`);
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const isPlainFile = entry.isFile();
|
|
257
|
+
let rawContent: string | null = null;
|
|
258
|
+
if (isPlainFile) {
|
|
259
|
+
try {
|
|
260
|
+
rawContent = fs.readFileSync(configPath, 'utf8');
|
|
261
|
+
} catch (err) {
|
|
262
|
+
// Read failure → rawContent stays null → the classifier preserves
|
|
263
|
+
// ('config-unreadable'). Log the cause here; the verdict warning follows.
|
|
264
|
+
log?.warn(`could not read legacy workspace codex config ${configPath}: ${String(err)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const verdict = classifyLegacyWorkspaceConfig({
|
|
269
|
+
entry: { isPlainFile, hardLinks: entry.nlink },
|
|
270
|
+
rawContent,
|
|
271
|
+
proof,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (verdict.action === 'none') return;
|
|
275
|
+
|
|
276
|
+
if (verdict.action === 'remove') {
|
|
277
|
+
try {
|
|
278
|
+
fs.unlinkSync(configPath);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// ENOENT here means the file went away between our lstat and this unlink
|
|
281
|
+
// — a second bridge booting the same workspace (rapid daemon respawn) got
|
|
282
|
+
// there first. That is exactly the end state we wanted, so it is not a
|
|
283
|
+
// failure: warning "could not remove" about a file that is already gone
|
|
284
|
+
// would send the operator after nothing. Concurrent migrations converge.
|
|
285
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
286
|
+
log?.warn(`could not remove legacy workspace codex config ${configPath}: ${String(err)}`);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
log?.warn(
|
|
292
|
+
`leaving ${configPath} in place — ${PRESERVE_DETAIL[verdict.reason]}, so it is not provably ` +
|
|
293
|
+
"the retired bridge's own artifact. Codex loads it for trusted workspaces IN ADDITION to " +
|
|
294
|
+
'the platform instructions; remove it manually if it is a stale artifact.',
|
|
295
|
+
);
|
|
296
|
+
}
|