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.
Files changed (54) hide show
  1. package/README.md +57 -9
  2. package/bin/ds-harness-remote.js +11 -0
  3. package/cordis.patch.yml +22 -0
  4. package/dist/cli.d.ts +31 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +283 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/client.github.js +31 -15
  9. package/dist/client.js +31 -15
  10. package/dist/client.js.map +1 -1
  11. package/dist/codex/domain.d.ts +6 -2
  12. package/dist/codex/domain.d.ts.map +1 -1
  13. package/dist/codex/domain.js +279 -68
  14. package/dist/codex/domain.js.map +1 -1
  15. package/dist/codex/method-policy.d.ts +14 -7
  16. package/dist/codex/method-policy.d.ts.map +1 -1
  17. package/dist/codex/method-policy.js +3 -0
  18. package/dist/codex/method-policy.js.map +1 -1
  19. package/dist/codex/virtual-harness.d.ts +6 -0
  20. package/dist/codex/virtual-harness.d.ts.map +1 -1
  21. package/dist/codex/virtual-harness.js +161 -23
  22. package/dist/codex/virtual-harness.js.map +1 -1
  23. package/dist/config.d.ts +1 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +1 -0
  26. package/dist/config.js.map +1 -1
  27. package/dist/harness-api-bridge.d.ts.map +1 -1
  28. package/dist/harness-api-bridge.js +14 -6
  29. package/dist/harness-api-bridge.js.map +1 -1
  30. package/dist/harness-remote-bridge.d.ts +1 -1
  31. package/dist/harness-remote-bridge.d.ts.map +1 -1
  32. package/dist/harness-remote-bridge.js +1 -0
  33. package/dist/harness-remote-bridge.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1342 -270
  37. package/dist/index.js.map +4 -4
  38. package/dist/server-api.d.ts.map +1 -1
  39. package/dist/server-api.js +15 -2
  40. package/dist/server-api.js.map +1 -1
  41. package/dist/server-credentials.d.ts +2 -2
  42. package/dist/service.d.ts +4 -1
  43. package/dist/service.d.ts.map +1 -1
  44. package/dist/service.js +34 -2
  45. package/dist/service.js.map +1 -1
  46. package/dist/tui-command.d.ts +13 -0
  47. package/dist/tui-command.d.ts.map +1 -0
  48. package/dist/tui-command.js +378 -0
  49. package/dist/tui-command.js.map +1 -0
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/dsh-plugin.json +41 -0
  53. package/package.json +6 -1
  54. package/public.d.ts +1 -0
@@ -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
- return filterThreadListByProjectAuthority(sanitizeThreadList(await this.callUpstream(call.method, call.params)), await this.readProjectAuthority());
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
- approvalPolicy: permission.approvalPolicy,
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
- return paginateCodexNativeHistory(projectCodexNativeHistory(thread, `codex:${threadId}`), {
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
- claimed = this.claimTurn(threadId, connectionId, call.method);
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
- approvalPolicy: permission.approvalPolicy,
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
- approvalPolicy: permission.approvalPolicy,
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
- approvalPolicy: permission.approvalPolicy,
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
- this.turnOwners.delete(threadId);
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.readProjectAuthority();
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 || !isThreadAllowedByProjectAuthority(thread, authority)) {
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 (!isThreadAllowedByProjectAuthority(thread, await this.readProjectAuthority())) {
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, connectionId);
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
- return false;
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 readProjectAuthority() {
651
+ async readWorkspaceAuthority(listedThreads) {
613
652
  const projectIds = new Set();
614
653
  const roots = [];
615
654
  let cursor;
616
- for (let page = 0; page < 32; page += 1) {
617
- const result = sanitizeProjectList(await this.callUpstream('project/list', {
618
- limit: 100,
619
- ...(cursor === undefined ? {} : { cursor }),
620
- }));
621
- for (const project of result.data) {
622
- const projectRoots = project.roots.map(root => root.path).filter(path => isAbsolute(path));
623
- if (projectRoots.length === 0)
624
- continue;
625
- projectIds.add(project.id);
626
- roots.push(...projectRoots);
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
- cursor = typeof result.nextCursor === 'string' && result.nextCursor.length > 0 ? result.nextCursor : undefined;
629
- if (cursor === undefined)
630
- break;
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 < 32; page += 1) {
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 isThreadAllowedByProjectAuthority(thread, authority) ? thread : undefined;
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.listCodexProjectWorkspacePaths();
659
- const canonical = paths.get(normalizeCodexPathForCompare(path));
660
- if (canonical === undefined) {
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
- return canonical;
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 listCodexProjectWorkspacePaths() {
822
+ async listCodexWorkspacePaths() {
666
823
  const paths = new Map();
667
- const authority = await this.readProjectAuthority();
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 filterThreadListByProjectAuthority(result, authority) {
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 && isThreadAllowedByProjectAuthority(thread, authority))),
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 isThreadAllowedByProjectAuthority(thread, authority) {
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 fullAccess = permissionPreset === 'danger-full-access';
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
  }