ds-harness-remote 0.4.3 → 0.4.5
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/README.md +57 -9
- package/bin/ds-harness-remote.js +11 -0
- package/cordis.patch.yml +22 -0
- package/dist/cli.d.ts +31 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +283 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.github.js +31 -15
- package/dist/client.js +31 -15
- package/dist/client.js.map +1 -1
- package/dist/codex/domain.d.ts +6 -2
- package/dist/codex/domain.d.ts.map +1 -1
- package/dist/codex/domain.js +279 -68
- package/dist/codex/domain.js.map +1 -1
- package/dist/codex/method-policy.d.ts +14 -7
- package/dist/codex/method-policy.d.ts.map +1 -1
- package/dist/codex/method-policy.js +3 -0
- package/dist/codex/method-policy.js.map +1 -1
- package/dist/codex/virtual-harness.d.ts +6 -0
- package/dist/codex/virtual-harness.d.ts.map +1 -1
- package/dist/codex/virtual-harness.js +161 -23
- package/dist/codex/virtual-harness.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/harness-api-bridge.d.ts.map +1 -1
- package/dist/harness-api-bridge.js +14 -6
- package/dist/harness-api-bridge.js.map +1 -1
- package/dist/harness-remote-bridge.d.ts +1 -1
- package/dist/harness-remote-bridge.d.ts.map +1 -1
- package/dist/harness-remote-bridge.js +1 -0
- package/dist/harness-remote-bridge.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1342 -270
- package/dist/index.js.map +4 -4
- package/dist/server-api.d.ts.map +1 -1
- package/dist/server-api.js +15 -2
- package/dist/server-api.js.map +1 -1
- package/dist/server-credentials.d.ts +2 -2
- package/dist/service.d.ts +4 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +34 -2
- package/dist/service.js.map +1 -1
- package/dist/tui-command.d.ts +13 -0
- package/dist/tui-command.d.ts.map +1 -0
- package/dist/tui-command.js +378 -0
- package/dist/tui-command.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dsh-plugin.json +41 -0
- package/package.json +6 -1
- package/public.d.ts +1 -0
package/dist/codex/domain.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readdir, realpath, stat } from 'node:fs/promises';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
|
-
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { deriveCodexCwdWorkspaces } from '@dsh-remote/client-core';
|
|
4
6
|
import { RpcError } from '../safe-error.js';
|
|
5
7
|
import { CodexAppServerClient, CodexAppServerError, } from './app-server.js';
|
|
6
8
|
import { isThreadMutation, parseCodexCall, threadIdFromParams, } from './method-policy.js';
|
|
@@ -9,8 +11,10 @@ import { paginateCodexNativeHistory, projectCodexNativeHistory } from './virtual
|
|
|
9
11
|
const APPROVAL_TTL_MS = 5 * 60_000;
|
|
10
12
|
const DEFAULT_RESTART_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
|
|
11
13
|
const CODEX_PAGE_LIMIT = 100;
|
|
14
|
+
const MAX_CODEX_PAGES = 32;
|
|
12
15
|
const CODEX_HISTORY_PAGE_LIMIT = 25;
|
|
13
16
|
const MAX_CODEX_HISTORY_PAGES = 64;
|
|
17
|
+
const CODEX_DIRECTORY_ENTRY_LIMIT = 500;
|
|
14
18
|
/**
|
|
15
19
|
* Optional Codex business domain inside the existing Remote Plugin. It shares
|
|
16
20
|
* Remote identity/transport with Harness, but owns its App Server process,
|
|
@@ -25,6 +29,7 @@ export class CodexRemoteDomain {
|
|
|
25
29
|
unsubscribeInbound;
|
|
26
30
|
unsubscribeUnavailable;
|
|
27
31
|
peers = new Map();
|
|
32
|
+
peerDeviceIds = new Map();
|
|
28
33
|
turnOwners = new Map();
|
|
29
34
|
approvals = new Map();
|
|
30
35
|
approvalExpiryTimer;
|
|
@@ -72,6 +77,7 @@ export class CodexRemoteDomain {
|
|
|
72
77
|
return undefined;
|
|
73
78
|
const bridge = new CodexPeerBridge(this, context, publish, this.logger);
|
|
74
79
|
this.peers.set(context.connectionId, bridge);
|
|
80
|
+
this.peerDeviceIds.set(context.connectionId, context.peerDeviceId);
|
|
75
81
|
return bridge;
|
|
76
82
|
}
|
|
77
83
|
async call(connectionId, input) {
|
|
@@ -85,16 +91,16 @@ export class CodexRemoteDomain {
|
|
|
85
91
|
return sanitizeProjectList(await this.callUpstream(call.method, call.params));
|
|
86
92
|
}
|
|
87
93
|
if (call.method === 'thread/list') {
|
|
88
|
-
|
|
94
|
+
const result = sanitizeThreadList(await this.callUpstream(call.method, call.params));
|
|
95
|
+
return filterThreadListByWorkspaceAuthority(result, await this.readWorkspaceAuthority(result.data));
|
|
89
96
|
}
|
|
90
97
|
if (call.method === 'thread/start') {
|
|
91
98
|
const cwd = await this.requireCodexWorkspacePath(call.params.cwd);
|
|
92
|
-
const permission = codexPermission(call.params);
|
|
99
|
+
const permission = codexPermission(call.params, 'workspace-write');
|
|
93
100
|
const result = await this.callUpstream(call.method, {
|
|
94
101
|
...permission.params,
|
|
95
102
|
cwd,
|
|
96
|
-
|
|
97
|
-
sandbox: permission.sandbox,
|
|
103
|
+
...codexThreadPermissionParams(permission),
|
|
98
104
|
serviceName: 'deepseek_harness_remote',
|
|
99
105
|
});
|
|
100
106
|
if (extractThread(result)?.id === undefined) {
|
|
@@ -105,11 +111,18 @@ export class CodexRemoteDomain {
|
|
|
105
111
|
const threadId = threadIdFromParams(call.params);
|
|
106
112
|
if (call.method === 'dsh/sessionHistory') {
|
|
107
113
|
const thread = await this.readThreadForHistory(connectionId, threadId);
|
|
108
|
-
|
|
114
|
+
const page = paginateCodexNativeHistory(projectCodexNativeHistory(thread, `codex:${threadId}`), {
|
|
109
115
|
beforeSeq: optionalInteger(call.params.beforeSeq),
|
|
110
116
|
throughSeq: optionalInteger(call.params.throughSeq),
|
|
111
117
|
maxMessages: optionalInteger(call.params.maxMessages),
|
|
112
118
|
});
|
|
119
|
+
const activeTurnId = typeof page.activeTurnId === 'string'
|
|
120
|
+
? page.activeTurnId
|
|
121
|
+
: this.turnOwners.get(threadId)?.turnId;
|
|
122
|
+
return activeTurnId === undefined ? page : { ...page, activeTurnId };
|
|
123
|
+
}
|
|
124
|
+
if (call.method === 'dsh/directoryList') {
|
|
125
|
+
return this.listCodexDirectory(call.params.path);
|
|
113
126
|
}
|
|
114
127
|
const allowedThread = threadId === undefined ? undefined : await this.readKnownThread(threadId);
|
|
115
128
|
if (call.method === 'thread/read') {
|
|
@@ -125,8 +138,11 @@ export class CodexRemoteDomain {
|
|
|
125
138
|
return this.callUpstream(call.method, call.params);
|
|
126
139
|
}
|
|
127
140
|
let claimed = false;
|
|
141
|
+
let previousOwner;
|
|
128
142
|
if (isThreadMutation(call.method) && threadId !== undefined) {
|
|
129
|
-
|
|
143
|
+
const claim = this.claimTurn(threadId, connectionId, call.method, typeof call.params.turnId === 'string' ? call.params.turnId : undefined);
|
|
144
|
+
claimed = claim.claimed;
|
|
145
|
+
previousOwner = claim.previous;
|
|
130
146
|
}
|
|
131
147
|
try {
|
|
132
148
|
const permission = codexPermission(call.params);
|
|
@@ -134,31 +150,20 @@ export class CodexRemoteDomain {
|
|
|
134
150
|
? {
|
|
135
151
|
...permission.params,
|
|
136
152
|
...(allowedThread.cwd === undefined ? {} : { cwd: allowedThread.cwd }),
|
|
137
|
-
|
|
138
|
-
sandbox: permission.sandbox,
|
|
153
|
+
...codexThreadPermissionParams(permission),
|
|
139
154
|
excludeTurns: true,
|
|
140
155
|
}
|
|
141
156
|
: call.method === 'thread/fork' && allowedThread !== undefined
|
|
142
157
|
? {
|
|
143
158
|
...permission.params,
|
|
144
159
|
...(allowedThread.cwd === undefined ? {} : { cwd: allowedThread.cwd }),
|
|
145
|
-
|
|
146
|
-
sandbox: permission.sandbox,
|
|
160
|
+
...codexThreadPermissionParams(permission),
|
|
147
161
|
}
|
|
148
162
|
: call.method === 'turn/start'
|
|
149
163
|
? {
|
|
150
164
|
...permission.params,
|
|
151
165
|
...(allowedThread?.cwd === undefined ? {} : { cwd: allowedThread.cwd }),
|
|
152
|
-
|
|
153
|
-
sandboxPolicy: permission.sandbox === 'danger-full-access'
|
|
154
|
-
? { type: 'dangerFullAccess' }
|
|
155
|
-
: {
|
|
156
|
-
type: 'workspaceWrite',
|
|
157
|
-
writableRoots: allowedThread?.cwd === undefined ? [] : [allowedThread.cwd],
|
|
158
|
-
networkAccess: false,
|
|
159
|
-
excludeTmpdirEnvVar: false,
|
|
160
|
-
excludeSlashTmp: false,
|
|
161
|
-
},
|
|
166
|
+
...codexTurnPermissionParams(permission, allowedThread?.cwd),
|
|
162
167
|
}
|
|
163
168
|
: permission.params;
|
|
164
169
|
let result;
|
|
@@ -172,7 +177,8 @@ export class CodexRemoteDomain {
|
|
|
172
177
|
if (call.method === 'thread/resume'
|
|
173
178
|
&& allowedThread !== undefined
|
|
174
179
|
&& error instanceof RpcError
|
|
175
|
-
&& error.code === 'CODEX_UPSTREAM_ERROR'
|
|
180
|
+
&& error.code === 'CODEX_UPSTREAM_ERROR'
|
|
181
|
+
&& call.params.permissionPreset === undefined) {
|
|
176
182
|
return { thread: allowedThread.thread };
|
|
177
183
|
}
|
|
178
184
|
throw error;
|
|
@@ -180,11 +186,18 @@ export class CodexRemoteDomain {
|
|
|
180
186
|
if (call.method === 'thread/resume' || call.method === 'thread/fork' || call.method === 'thread/unarchive') {
|
|
181
187
|
await this.assertResultThreadAllowed(result);
|
|
182
188
|
}
|
|
189
|
+
if (call.method === 'turn/start' && threadId !== undefined) {
|
|
190
|
+
this.rememberTurnId(threadId, connectionId, extractTurnId(result));
|
|
191
|
+
}
|
|
183
192
|
return result;
|
|
184
193
|
}
|
|
185
194
|
catch (error) {
|
|
186
|
-
if (claimed && threadId !== undefined)
|
|
187
|
-
|
|
195
|
+
if (claimed && threadId !== undefined) {
|
|
196
|
+
if (previousOwner === undefined)
|
|
197
|
+
this.turnOwners.delete(threadId);
|
|
198
|
+
else
|
|
199
|
+
this.turnOwners.set(threadId, previousOwner);
|
|
200
|
+
}
|
|
188
201
|
throw mapAppServerError(error);
|
|
189
202
|
}
|
|
190
203
|
}
|
|
@@ -204,8 +217,9 @@ export class CodexRemoteDomain {
|
|
|
204
217
|
const bridge = this.peers.get(connectionId);
|
|
205
218
|
if (bridge !== undefined)
|
|
206
219
|
this.peers.delete(connectionId);
|
|
220
|
+
this.peerDeviceIds.delete(connectionId);
|
|
207
221
|
for (const [threadId, owner] of this.turnOwners) {
|
|
208
|
-
if (owner === connectionId)
|
|
222
|
+
if (owner.connectionId === connectionId)
|
|
209
223
|
this.turnOwners.delete(threadId);
|
|
210
224
|
}
|
|
211
225
|
const appServer = this.appServer;
|
|
@@ -228,6 +242,7 @@ export class CodexRemoteDomain {
|
|
|
228
242
|
for (const bridge of [...this.peers.values()])
|
|
229
243
|
await bridge.closeAll();
|
|
230
244
|
this.peers.clear();
|
|
245
|
+
this.peerDeviceIds.clear();
|
|
231
246
|
this.turnOwners.clear();
|
|
232
247
|
if (this.approvalExpiryTimer !== undefined)
|
|
233
248
|
clearTimeout(this.approvalExpiryTimer);
|
|
@@ -359,6 +374,13 @@ export class CodexRemoteDomain {
|
|
|
359
374
|
const threadId = extractThreadId(message.params);
|
|
360
375
|
if (message.method === 'turn/completed' && threadId !== undefined)
|
|
361
376
|
this.turnOwners.delete(threadId);
|
|
377
|
+
if (message.method === 'turn/started' && threadId !== undefined) {
|
|
378
|
+
const owner = this.turnOwners.get(threadId);
|
|
379
|
+
const turnId = extractTurnId(message.params);
|
|
380
|
+
if (owner !== undefined) {
|
|
381
|
+
this.turnOwners.set(threadId, { ...owner, ...(turnId === undefined ? {} : { turnId }) });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
362
384
|
if (message.method === 'serverRequest/resolved')
|
|
363
385
|
this.resolveUpstreamApproval(message.params);
|
|
364
386
|
if (threadId === undefined)
|
|
@@ -378,7 +400,7 @@ export class CodexRemoteDomain {
|
|
|
378
400
|
}
|
|
379
401
|
const threadId = extractThreadId(message.params);
|
|
380
402
|
const owner = threadId === undefined ? undefined : this.turnOwners.get(threadId);
|
|
381
|
-
const peer = owner === undefined ? undefined : this.peers.get(owner);
|
|
403
|
+
const peer = owner === undefined ? undefined : this.peers.get(owner.connectionId);
|
|
382
404
|
if (threadId === undefined || owner === undefined || peer === undefined || !peer.hasThreadSubscription(threadId)) {
|
|
383
405
|
await appServer.respond(message.id, { decision: 'decline' });
|
|
384
406
|
return;
|
|
@@ -386,7 +408,7 @@ export class CodexRemoteDomain {
|
|
|
386
408
|
const requestHandle = randomUUID();
|
|
387
409
|
this.approvals.set(requestHandle, {
|
|
388
410
|
upstreamId: message.id,
|
|
389
|
-
connectionId: owner,
|
|
411
|
+
connectionId: owner.connectionId,
|
|
390
412
|
threadId,
|
|
391
413
|
method: message.method,
|
|
392
414
|
expiresAt: Date.now() + APPROVAL_TTL_MS,
|
|
@@ -406,7 +428,7 @@ export class CodexRemoteDomain {
|
|
|
406
428
|
}
|
|
407
429
|
}
|
|
408
430
|
async readKnownThread(threadId) {
|
|
409
|
-
const authority = await this.
|
|
431
|
+
const authority = await this.readWorkspaceAuthority();
|
|
410
432
|
let result;
|
|
411
433
|
try {
|
|
412
434
|
result = await this.callUpstream('thread/read', { threadId, includeTurns: false });
|
|
@@ -421,7 +443,7 @@ export class CodexRemoteDomain {
|
|
|
421
443
|
return { thread: listed, ...(typeof listed.cwd === 'string' && listed.cwd.length > 0 ? { cwd: listed.cwd } : {}) };
|
|
422
444
|
}
|
|
423
445
|
const thread = extractThread(result);
|
|
424
|
-
if (thread === undefined || thread.id !== threadId || !
|
|
446
|
+
if (thread === undefined || thread.id !== threadId || !isThreadAllowedByWorkspaceAuthority(thread, authority)) {
|
|
425
447
|
throw new RpcError('CODEX_THREAD_NOT_ALLOWED', 'The Codex thread is not available through this Remote Host.');
|
|
426
448
|
}
|
|
427
449
|
return { thread, ...(typeof thread.cwd === 'string' && thread.cwd.length > 0 ? { cwd: thread.cwd } : {}) };
|
|
@@ -532,31 +554,48 @@ export class CodexRemoteDomain {
|
|
|
532
554
|
if (thread === undefined || typeof thread.id !== 'string') {
|
|
533
555
|
throw new RpcError('CODEX_INVALID_RESPONSE', 'Codex App Server returned an invalid thread.');
|
|
534
556
|
}
|
|
535
|
-
if (!
|
|
557
|
+
if (!isThreadAllowedByWorkspaceAuthority(thread, await this.readWorkspaceAuthority())) {
|
|
536
558
|
throw new RpcError('CODEX_THREAD_NOT_ALLOWED', 'The Codex thread is not available through this Remote Host.');
|
|
537
559
|
}
|
|
538
560
|
}
|
|
539
|
-
claimTurn(threadId, connectionId, method) {
|
|
561
|
+
claimTurn(threadId, connectionId, method, turnId) {
|
|
540
562
|
const owner = this.turnOwners.get(threadId);
|
|
563
|
+
const peerDeviceId = this.peerDeviceIds.get(connectionId) ?? connectionId;
|
|
564
|
+
const nextOwner = { connectionId, peerDeviceId, ...(turnId === undefined ? {} : { turnId }) };
|
|
541
565
|
if (method === 'turn/interrupt') {
|
|
542
566
|
if (owner === undefined) {
|
|
543
567
|
// A replacement connection may recover an already-running persisted
|
|
544
568
|
// turn. Claim only for this explicit mutation; passive viewers still
|
|
545
569
|
// remain observers and cannot steal a live connection's lease.
|
|
546
|
-
this.turnOwners.set(threadId,
|
|
547
|
-
return true;
|
|
570
|
+
this.turnOwners.set(threadId, nextOwner);
|
|
571
|
+
return { claimed: true };
|
|
548
572
|
}
|
|
549
|
-
if (owner !== connectionId)
|
|
573
|
+
if (owner.connectionId !== connectionId && owner.peerDeviceId !== peerDeviceId) {
|
|
550
574
|
throw new RpcError('CODEX_TURN_OWNED', 'Only the connection that started this Codex turn can interrupt it.');
|
|
551
|
-
|
|
575
|
+
}
|
|
576
|
+
if (owner.connectionId !== connectionId) {
|
|
577
|
+
this.turnOwners.set(threadId, { ...owner, connectionId, peerDeviceId, ...(turnId === undefined ? {} : { turnId }) });
|
|
578
|
+
return { claimed: true, previous: owner };
|
|
579
|
+
}
|
|
580
|
+
if (turnId !== undefined && owner.turnId === undefined)
|
|
581
|
+
this.turnOwners.set(threadId, { ...owner, turnId });
|
|
582
|
+
return { claimed: false };
|
|
552
583
|
}
|
|
553
|
-
if (owner !== undefined && owner !== connectionId) {
|
|
584
|
+
if (owner !== undefined && owner.connectionId !== connectionId && owner.peerDeviceId !== peerDeviceId) {
|
|
554
585
|
throw new RpcError('CODEX_TURN_OWNED', 'Another Remote connection owns the active Codex turn.');
|
|
555
586
|
}
|
|
556
|
-
if (owner === connectionId)
|
|
557
|
-
return false;
|
|
558
|
-
this.turnOwners.set(threadId, connectionId);
|
|
559
|
-
return true;
|
|
587
|
+
if (owner?.connectionId === connectionId)
|
|
588
|
+
return { claimed: false };
|
|
589
|
+
this.turnOwners.set(threadId, owner === undefined ? nextOwner : { ...owner, connectionId, peerDeviceId });
|
|
590
|
+
return { claimed: true, previous: owner };
|
|
591
|
+
}
|
|
592
|
+
rememberTurnId(threadId, connectionId, turnId) {
|
|
593
|
+
if (turnId === undefined)
|
|
594
|
+
return;
|
|
595
|
+
const owner = this.turnOwners.get(threadId);
|
|
596
|
+
if (owner === undefined || owner.connectionId !== connectionId)
|
|
597
|
+
return;
|
|
598
|
+
this.turnOwners.set(threadId, { ...owner, turnId });
|
|
560
599
|
}
|
|
561
600
|
hasSubscriber(threadId) {
|
|
562
601
|
return [...this.peers.values()].some(peer => peer.hasThreadSubscription(threadId));
|
|
@@ -609,31 +648,60 @@ export class CodexRemoteDomain {
|
|
|
609
648
|
throw mapAppServerError(error);
|
|
610
649
|
}
|
|
611
650
|
}
|
|
612
|
-
async
|
|
651
|
+
async readWorkspaceAuthority(listedThreads) {
|
|
613
652
|
const projectIds = new Set();
|
|
614
653
|
const roots = [];
|
|
615
654
|
let cursor;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
655
|
+
try {
|
|
656
|
+
for (let page = 0; page < MAX_CODEX_PAGES; page += 1) {
|
|
657
|
+
const result = sanitizeProjectList(await this.callUpstream('project/list', {
|
|
658
|
+
limit: CODEX_PAGE_LIMIT,
|
|
659
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
660
|
+
}));
|
|
661
|
+
for (const project of result.data) {
|
|
662
|
+
const projectRoots = project.roots.map(root => root.path).filter(path => isAbsolute(path));
|
|
663
|
+
if (projectRoots.length === 0)
|
|
664
|
+
continue;
|
|
665
|
+
projectIds.add(project.id);
|
|
666
|
+
roots.push(...projectRoots);
|
|
667
|
+
}
|
|
668
|
+
cursor = typeof result.nextCursor === 'string' && result.nextCursor.length > 0 ? result.nextCursor : undefined;
|
|
669
|
+
if (cursor === undefined)
|
|
670
|
+
break;
|
|
627
671
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
if (!isProjectListFallbackError(error))
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
if (roots.length > 0)
|
|
678
|
+
return { projectIds, roots };
|
|
679
|
+
const threads = listedThreads === undefined ? [] : [...listedThreads];
|
|
680
|
+
if (listedThreads === undefined) {
|
|
681
|
+
cursor = undefined;
|
|
682
|
+
for (let page = 0; page < MAX_CODEX_PAGES; page += 1) {
|
|
683
|
+
const result = sanitizeThreadList(await this.callUpstream('thread/list', {
|
|
684
|
+
limit: CODEX_PAGE_LIMIT,
|
|
685
|
+
sortKey: 'updated_at',
|
|
686
|
+
sortDirection: 'desc',
|
|
687
|
+
archived: false,
|
|
688
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
689
|
+
}));
|
|
690
|
+
threads.push(...result.data);
|
|
691
|
+
cursor = typeof result.nextCursor === 'string' && result.nextCursor.length > 0 ? result.nextCursor : undefined;
|
|
692
|
+
if (cursor === undefined)
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (const workspace of deriveCodexCwdWorkspaces(threads)) {
|
|
697
|
+
if (isAbsolute(workspace.path))
|
|
698
|
+
roots.push(workspace.path);
|
|
631
699
|
}
|
|
632
700
|
return { projectIds, roots };
|
|
633
701
|
}
|
|
634
702
|
async findKnownThreadInList(threadId, authority) {
|
|
635
703
|
let cursor;
|
|
636
|
-
for (let page = 0; page <
|
|
704
|
+
for (let page = 0; page < MAX_CODEX_PAGES; page += 1) {
|
|
637
705
|
const result = sanitizeThreadList(await this.callUpstream('thread/list', {
|
|
638
706
|
limit: 100,
|
|
639
707
|
sortKey: 'updated_at',
|
|
@@ -643,7 +711,7 @@ export class CodexRemoteDomain {
|
|
|
643
711
|
}));
|
|
644
712
|
const thread = result.data.find(item => item.id === threadId);
|
|
645
713
|
if (thread !== undefined) {
|
|
646
|
-
return
|
|
714
|
+
return isThreadAllowedByWorkspaceAuthority(thread, authority) ? thread : undefined;
|
|
647
715
|
}
|
|
648
716
|
cursor = typeof result.nextCursor === 'string' && result.nextCursor.length > 0 ? result.nextCursor : undefined;
|
|
649
717
|
if (cursor === undefined)
|
|
@@ -655,16 +723,105 @@ export class CodexRemoteDomain {
|
|
|
655
723
|
if (!isAbsolute(path)) {
|
|
656
724
|
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
657
725
|
}
|
|
658
|
-
const paths = await this.
|
|
659
|
-
const
|
|
660
|
-
|
|
726
|
+
const paths = await this.listCodexWorkspacePaths();
|
|
727
|
+
const lexicalCandidate = resolve(path);
|
|
728
|
+
let candidate;
|
|
729
|
+
try {
|
|
730
|
+
candidate = await realpath(path);
|
|
731
|
+
if (!(await stat(candidate)).isDirectory())
|
|
732
|
+
throw new Error('not a directory');
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
736
|
+
}
|
|
737
|
+
for (const root of paths.values()) {
|
|
738
|
+
try {
|
|
739
|
+
if (!containsCodexPath(resolve(root), lexicalCandidate))
|
|
740
|
+
continue;
|
|
741
|
+
const canonicalRoot = await realpath(root);
|
|
742
|
+
if (containsCodexPath(canonicalRoot, candidate))
|
|
743
|
+
return lexicalCandidate;
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// A stale project or Thread cwd is not authority for a new Thread.
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
750
|
+
}
|
|
751
|
+
async listCodexDirectory(path) {
|
|
752
|
+
const target = await this.resolveCodexDirectory(path);
|
|
753
|
+
const rows = await readdir(target.path, { withFileTypes: true }).catch(() => undefined);
|
|
754
|
+
if (rows === undefined) {
|
|
755
|
+
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
756
|
+
}
|
|
757
|
+
const entries = [];
|
|
758
|
+
for (const row of rows) {
|
|
759
|
+
const child = resolve(target.path, row.name);
|
|
760
|
+
let directory = row.isDirectory();
|
|
761
|
+
if (!directory && row.isSymbolicLink()) {
|
|
762
|
+
directory = await stat(child).then(value => value.isDirectory()).catch(() => false);
|
|
763
|
+
}
|
|
764
|
+
if (!directory)
|
|
765
|
+
continue;
|
|
766
|
+
try {
|
|
767
|
+
const canonicalChild = await realpath(child);
|
|
768
|
+
if (!(await stat(canonicalChild)).isDirectory())
|
|
769
|
+
continue;
|
|
770
|
+
if (!containsCodexPath(target.canonicalRoot, canonicalChild))
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
entries.push({
|
|
777
|
+
name: row.name,
|
|
778
|
+
path: child,
|
|
779
|
+
hidden: process.platform !== 'win32' && row.name.startsWith('.'),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }));
|
|
783
|
+
return {
|
|
784
|
+
path: target.path,
|
|
785
|
+
home: target.root,
|
|
786
|
+
crumbs: codexDirectoryCrumbs(target.root, target.path),
|
|
787
|
+
entries: entries.slice(0, CODEX_DIRECTORY_ENTRY_LIMIT),
|
|
788
|
+
truncated: entries.length > CODEX_DIRECTORY_ENTRY_LIMIT,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
async resolveCodexDirectory(path) {
|
|
792
|
+
if (!isAbsolute(path)) {
|
|
793
|
+
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
794
|
+
}
|
|
795
|
+
const authority = await this.readWorkspaceAuthority();
|
|
796
|
+
const lexicalCandidate = resolve(path);
|
|
797
|
+
let canonicalCandidate;
|
|
798
|
+
try {
|
|
799
|
+
canonicalCandidate = await realpath(path);
|
|
800
|
+
if (!(await stat(canonicalCandidate)).isDirectory())
|
|
801
|
+
throw new Error('not a directory');
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
661
804
|
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
662
805
|
}
|
|
663
|
-
|
|
806
|
+
for (const root of authority.roots) {
|
|
807
|
+
try {
|
|
808
|
+
const lexicalRoot = resolve(root);
|
|
809
|
+
if (!containsCodexPath(lexicalRoot, lexicalCandidate))
|
|
810
|
+
continue;
|
|
811
|
+
const canonicalRoot = await realpath(root);
|
|
812
|
+
if (containsCodexPath(canonicalRoot, canonicalCandidate)) {
|
|
813
|
+
return { path: lexicalCandidate, root: lexicalRoot, canonicalRoot };
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// A stale project or Thread cwd is not authority for directory browsing.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
throw new RpcError('CODEX_PATH_NOT_ALLOWED', 'The CodeX working directory is not available as a Workspace.');
|
|
664
821
|
}
|
|
665
|
-
async
|
|
822
|
+
async listCodexWorkspacePaths() {
|
|
666
823
|
const paths = new Map();
|
|
667
|
-
const authority = await this.
|
|
824
|
+
const authority = await this.readWorkspaceAuthority();
|
|
668
825
|
for (const root of authority.roots)
|
|
669
826
|
paths.set(normalizeCodexPathForCompare(root), root);
|
|
670
827
|
return paths;
|
|
@@ -749,7 +906,7 @@ function sanitizeThreadList(result) {
|
|
|
749
906
|
: {}),
|
|
750
907
|
};
|
|
751
908
|
}
|
|
752
|
-
function
|
|
909
|
+
function filterThreadListByWorkspaceAuthority(result, authority) {
|
|
753
910
|
if (!isRecord(result) || !Array.isArray(result.data)) {
|
|
754
911
|
throw new RpcError('CODEX_INVALID_RESPONSE', 'Codex App Server returned an invalid thread list.');
|
|
755
912
|
}
|
|
@@ -757,7 +914,7 @@ function filterThreadListByProjectAuthority(result, authority) {
|
|
|
757
914
|
...result,
|
|
758
915
|
data: result.data
|
|
759
916
|
.map(record => isRecord(record) ? record : undefined)
|
|
760
|
-
.filter((thread) => (thread !== undefined &&
|
|
917
|
+
.filter((thread) => (thread !== undefined && isThreadAllowedByWorkspaceAuthority(thread, authority))),
|
|
761
918
|
};
|
|
762
919
|
}
|
|
763
920
|
function sanitizeProjectList(result) {
|
|
@@ -789,7 +946,7 @@ function sanitizeProjectList(result) {
|
|
|
789
946
|
...(typeof result.nextCursor === 'string' && result.nextCursor.length > 0 ? { nextCursor: result.nextCursor } : { nextCursor: null }),
|
|
790
947
|
};
|
|
791
948
|
}
|
|
792
|
-
function
|
|
949
|
+
function isThreadAllowedByWorkspaceAuthority(thread, authority) {
|
|
793
950
|
const projectId = typeof thread.projectId === 'string' ? thread.projectId : undefined;
|
|
794
951
|
if (projectId !== undefined && authority.projectIds.has(projectId))
|
|
795
952
|
return true;
|
|
@@ -806,9 +963,30 @@ function containsCodexPath(root, candidate) {
|
|
|
806
963
|
function normalizeCodexPathForCompare(path) {
|
|
807
964
|
return path.replace(/[\\/]+$/u, '') || path;
|
|
808
965
|
}
|
|
966
|
+
function codexDirectoryCrumbs(root, path) {
|
|
967
|
+
const crumbs = [{ name: basename(root) || root, path: root, hidden: false }];
|
|
968
|
+
const remainder = relative(root, path);
|
|
969
|
+
if (remainder === '')
|
|
970
|
+
return crumbs;
|
|
971
|
+
let current = root;
|
|
972
|
+
for (const segment of remainder.split(/[\\/]+/u).filter(Boolean)) {
|
|
973
|
+
current = resolve(current, segment);
|
|
974
|
+
crumbs.push({ name: segment, path: current, hidden: false });
|
|
975
|
+
}
|
|
976
|
+
return crumbs;
|
|
977
|
+
}
|
|
809
978
|
function extractThread(result) {
|
|
810
979
|
return isRecord(result) && isRecord(result.thread) ? result.thread : undefined;
|
|
811
980
|
}
|
|
981
|
+
function extractTurnId(result) {
|
|
982
|
+
if (!isRecord(result))
|
|
983
|
+
return undefined;
|
|
984
|
+
if (typeof result.turnId === 'string' && result.turnId.length > 0)
|
|
985
|
+
return result.turnId;
|
|
986
|
+
if (isRecord(result.turn) && typeof result.turn.id === 'string' && result.turn.id.length > 0)
|
|
987
|
+
return result.turn.id;
|
|
988
|
+
return undefined;
|
|
989
|
+
}
|
|
812
990
|
function extractThreadId(params) {
|
|
813
991
|
if (!isRecord(params))
|
|
814
992
|
return undefined;
|
|
@@ -823,15 +1001,44 @@ function extractThreadId(params) {
|
|
|
823
1001
|
function optionalInteger(value) {
|
|
824
1002
|
return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
|
|
825
1003
|
}
|
|
826
|
-
function codexPermission(params) {
|
|
1004
|
+
function codexPermission(params, fallbackPreset) {
|
|
827
1005
|
const { permissionPreset, ...rest } = params;
|
|
828
|
-
const
|
|
1006
|
+
const preset = permissionPreset === 'workspace-write' || permissionPreset === 'danger-full-access'
|
|
1007
|
+
? permissionPreset
|
|
1008
|
+
: fallbackPreset;
|
|
1009
|
+
if (preset === undefined)
|
|
1010
|
+
return { params: mapCodexImageInputs(rest) };
|
|
1011
|
+
const fullAccess = preset === 'danger-full-access';
|
|
829
1012
|
return {
|
|
830
1013
|
params: mapCodexImageInputs(rest),
|
|
831
1014
|
approvalPolicy: fullAccess ? 'never' : 'on-request',
|
|
832
1015
|
sandbox: fullAccess ? 'danger-full-access' : 'workspace-write',
|
|
833
1016
|
};
|
|
834
1017
|
}
|
|
1018
|
+
function codexThreadPermissionParams(permission) {
|
|
1019
|
+
if (permission.approvalPolicy === undefined || permission.sandbox === undefined)
|
|
1020
|
+
return {};
|
|
1021
|
+
return {
|
|
1022
|
+
approvalPolicy: permission.approvalPolicy,
|
|
1023
|
+
sandbox: permission.sandbox,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
function codexTurnPermissionParams(permission, cwd) {
|
|
1027
|
+
if (permission.approvalPolicy === undefined || permission.sandbox === undefined)
|
|
1028
|
+
return {};
|
|
1029
|
+
return {
|
|
1030
|
+
approvalPolicy: permission.approvalPolicy,
|
|
1031
|
+
sandboxPolicy: permission.sandbox === 'danger-full-access'
|
|
1032
|
+
? { type: 'dangerFullAccess' }
|
|
1033
|
+
: {
|
|
1034
|
+
type: 'workspaceWrite',
|
|
1035
|
+
writableRoots: cwd === undefined ? [] : [cwd],
|
|
1036
|
+
networkAccess: false,
|
|
1037
|
+
excludeTmpdirEnvVar: false,
|
|
1038
|
+
excludeSlashTmp: false,
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
835
1042
|
function mapCodexImageInputs(params) {
|
|
836
1043
|
if (!Array.isArray(params.input))
|
|
837
1044
|
return params;
|
|
@@ -875,6 +1082,10 @@ function isHistoryReadRecoverable(error) {
|
|
|
875
1082
|
return error instanceof RpcError
|
|
876
1083
|
&& ['METHOD_NOT_ALLOWED', 'METHOD_NOT_FOUND', 'CODEX_UPSTREAM_ERROR', 'CODEX_REQUEST_TIMEOUT', 'CODEX_THREAD_BUSY'].includes(error.code);
|
|
877
1084
|
}
|
|
1085
|
+
function isProjectListFallbackError(error) {
|
|
1086
|
+
return error instanceof RpcError
|
|
1087
|
+
&& ['METHOD_NOT_ALLOWED', 'METHOD_NOT_FOUND', 'CODEX_UPSTREAM_ERROR'].includes(error.code);
|
|
1088
|
+
}
|
|
878
1089
|
function canTryNextBinary(error) {
|
|
879
1090
|
return !(error instanceof RpcError) || !['CODEX_AUTH_REQUIRED', 'CODEX_CLOSED'].includes(error.code);
|
|
880
1091
|
}
|