@muyichengshayu/promptx 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/apps/runner/src/engines/index.js +13 -3
  3. package/apps/runner/src/engines/shellRunner.js +216 -0
  4. package/apps/runner/src/runManager.js +68 -9
  5. package/apps/server/src/agents/index.js +18 -3
  6. package/apps/server/src/codexRuns.js +24 -7
  7. package/apps/server/src/db.js +2 -0
  8. package/apps/server/src/gitDiff.js +279 -17
  9. package/apps/server/src/internalRoutes.js +8 -1
  10. package/apps/server/src/relayClient.js +5 -1
  11. package/apps/server/src/relayConfig.js +10 -0
  12. package/apps/server/src/runDispatchService.js +44 -13
  13. package/apps/server/src/runEventIngest.js +6 -4
  14. package/apps/server/src/taskRoutes.js +72 -0
  15. package/apps/web/dist/assets/{CodexSessionManagerDialog-BbaObUl_.js → CodexSessionManagerDialog-CELTkz9T.js} +1 -1
  16. package/apps/web/dist/assets/{TaskDiffReviewDialog-ClUn4Ni4.js → TaskDiffReviewDialog-VwZmo00b.js} +1 -1
  17. package/apps/web/dist/assets/WorkbenchSettingsDialog-CThWkZHd.js +1 -0
  18. package/apps/web/dist/assets/WorkbenchView-D6auwJnA.js +60 -0
  19. package/apps/web/dist/assets/index-8vfFmsVl.js +2 -0
  20. package/apps/web/dist/assets/{index-CTHBQ5Ng.css → index-BPfQQtEB.css} +1 -1
  21. package/apps/web/dist/index.html +2 -2
  22. package/package.json +1 -1
  23. package/packages/shared/src/index.js +6 -0
  24. package/packages/shared/src/shellCommands.js +81 -0
  25. package/packages/shared/src/shellCommands.test.js +45 -0
  26. package/apps/web/dist/assets/WorkbenchSettingsDialog-C74C5fs1.js +0 -1
  27. package/apps/web/dist/assets/WorkbenchView-BgfyrrvY.js +0 -57
  28. package/apps/web/dist/assets/index-BIa_ZvMq.js +0 -2
@@ -787,6 +787,185 @@ function parseNumstat(output = '') {
787
787
  }
788
788
  }
789
789
 
790
+ function parseGitEntryMode(output = '') {
791
+ const line = String(output || '')
792
+ .split('\0')
793
+ .find(Boolean)
794
+ || ''
795
+ const match = line.match(/^(\d{6})\s+/)
796
+ return match?.[1] ? match[1] : ''
797
+ }
798
+
799
+ function isGitSubmodulePath(repoRoot = '', filePath = '', headOid = '') {
800
+ const normalizedPath = String(filePath || '').trim()
801
+ if (!repoRoot || !normalizedPath) {
802
+ return false
803
+ }
804
+
805
+ if (headOid) {
806
+ const result = runGit(repoRoot, ['ls-tree', '-z', headOid, '--', normalizedPath])
807
+ return parseGitEntryMode(result.stdout) === '160000'
808
+ }
809
+
810
+ const result = runGit(repoRoot, ['ls-files', '-z', '-s', '--', normalizedPath])
811
+ return parseGitEntryMode(result.stdout) === '160000'
812
+ }
813
+
814
+ function resolveSubmoduleRootForNestedPath(repoRoot = '', filePath = '', headOid = '') {
815
+ const normalizedPath = String(filePath || '').trim().replace(/^\/+|\/+$/g, '')
816
+ if (!repoRoot || !normalizedPath || !normalizedPath.includes('/')) {
817
+ return ''
818
+ }
819
+
820
+ const segments = normalizedPath.split('/')
821
+ for (let index = segments.length - 1; index >= 1; index -= 1) {
822
+ const candidate = segments.slice(0, index).join('/')
823
+ if (isGitSubmodulePath(repoRoot, candidate, headOid) || isGitSubmodulePath(repoRoot, candidate)) {
824
+ return candidate
825
+ }
826
+ }
827
+
828
+ return ''
829
+ }
830
+
831
+ function buildSubmoduleDiffPayload(repoRoot = '', filePath = '', options = {}) {
832
+ const normalizedPath = String(filePath || '').trim()
833
+ const fromHeadOid = String(options.fromHeadOid || '').trim()
834
+ const toHeadOid = String(options.toHeadOid || '').trim()
835
+ const workspaceMode = Boolean(options.workspaceMode)
836
+ const includePatch = Boolean(options.includePatch)
837
+ const includeStats = includePatch || Boolean(options.includeStats)
838
+
839
+ if (!repoRoot || !normalizedPath) {
840
+ return {
841
+ binary: false,
842
+ tooLarge: false,
843
+ patch: '',
844
+ patchLoaded: false,
845
+ additions: 0,
846
+ deletions: 0,
847
+ statsLoaded: false,
848
+ message: '',
849
+ }
850
+ }
851
+
852
+ const diffArgs = ['diff', '--submodule=diff', '--no-color', '--unified=3']
853
+ if (!workspaceMode && fromHeadOid && toHeadOid) {
854
+ diffArgs.push(`${fromHeadOid}..${toHeadOid}`)
855
+ } else {
856
+ diffArgs.push('HEAD')
857
+ }
858
+ diffArgs.push('--', normalizedPath)
859
+
860
+ const result = runGit(repoRoot, diffArgs)
861
+ const patch = String(result.stdout || '').trim()
862
+ const stats = includeStats ? parsePatchStats(patch) : { additions: null, deletions: null }
863
+
864
+ if (!includePatch) {
865
+ return {
866
+ binary: false,
867
+ tooLarge: false,
868
+ patch: '',
869
+ patchLoaded: false,
870
+ additions: stats.additions,
871
+ deletions: stats.deletions,
872
+ statsLoaded: includeStats,
873
+ message: '',
874
+ }
875
+ }
876
+
877
+ if (patch.length > MAX_PATCH_TEXT_BYTES) {
878
+ return {
879
+ binary: false,
880
+ tooLarge: true,
881
+ patch: '',
882
+ patchLoaded: true,
883
+ additions: stats.additions,
884
+ deletions: stats.deletions,
885
+ statsLoaded: includeStats,
886
+ message: 'diff 内容较长,暂不在页面内完整展示。',
887
+ }
888
+ }
889
+
890
+ return {
891
+ binary: false,
892
+ tooLarge: false,
893
+ patch,
894
+ patchLoaded: true,
895
+ additions: stats.additions,
896
+ deletions: stats.deletions,
897
+ statsLoaded: includeStats,
898
+ message: '',
899
+ }
900
+ }
901
+
902
+ function parseSubmodulePatchEntries(submodulePath = '', patch = '') {
903
+ const normalizedSubmodulePath = String(submodulePath || '').trim().replace(/\/+$/, '')
904
+ const text = String(patch || '').trim()
905
+ if (!normalizedSubmodulePath || !text) {
906
+ return []
907
+ }
908
+
909
+ const lines = text.split('\n')
910
+ const sections = []
911
+ let currentSection = []
912
+
913
+ lines.forEach((line) => {
914
+ if (line.startsWith('diff --git a/')) {
915
+ if (currentSection.length) {
916
+ sections.push(currentSection.join('\n').trim())
917
+ }
918
+ currentSection = [line]
919
+ return
920
+ }
921
+
922
+ if (currentSection.length) {
923
+ currentSection.push(line)
924
+ }
925
+ })
926
+
927
+ if (currentSection.length) {
928
+ sections.push(currentSection.join('\n').trim())
929
+ }
930
+
931
+ return sections
932
+ .map((section) => {
933
+ const header = section.split('\n', 1)[0] || ''
934
+ const matched = header.match(/^diff --git a\/(.+?) b\/(.+)$/)
935
+ const rawPath = matched?.[2] || matched?.[1] || ''
936
+ let nestedPath = String(rawPath || '').trim()
937
+ if (!nestedPath) {
938
+ return null
939
+ }
940
+
941
+ const duplicatedPrefix = `${normalizedSubmodulePath}/`
942
+ if (nestedPath === normalizedSubmodulePath) {
943
+ nestedPath = ''
944
+ } else if (nestedPath.startsWith(duplicatedPrefix)) {
945
+ nestedPath = nestedPath.slice(duplicatedPrefix.length)
946
+ }
947
+
948
+ if (!nestedPath) {
949
+ return null
950
+ }
951
+
952
+ const stats = parsePatchStats(section)
953
+ return {
954
+ path: `${normalizedSubmodulePath}/${nestedPath}`,
955
+ status: 'M',
956
+ additions: stats.additions,
957
+ deletions: stats.deletions,
958
+ statsLoaded: true,
959
+ binary: false,
960
+ tooLarge: false,
961
+ patch: section,
962
+ patchLoaded: true,
963
+ message: '',
964
+ }
965
+ })
966
+ .filter(Boolean)
967
+ }
968
+
790
969
  function buildDiffPayloadForFile(filePath = '', previousState = null, nextState = null, options = {}) {
791
970
  const includePatch = Boolean(options.includePatch)
792
971
  const includeStats = includePatch || Boolean(options.includeStats)
@@ -1057,7 +1236,24 @@ function createDiffFileEntry(filePath = '', previousState = null, nextState = nu
1057
1236
  return null
1058
1237
  }
1059
1238
 
1060
- const patchPayload = buildDiffPayloadForFile(filePath, previousState, nextState, options)
1239
+ const repoRoot = String(options.repoRoot || '').trim()
1240
+ const fromHeadOid = String(options.fromHeadOid || '').trim()
1241
+ const toHeadOid = String(options.toHeadOid || '').trim()
1242
+ const isSubmodule = repoRoot && (
1243
+ isGitSubmodulePath(repoRoot, filePath, fromHeadOid)
1244
+ || isGitSubmodulePath(repoRoot, filePath, toHeadOid)
1245
+ || isGitSubmodulePath(repoRoot, filePath)
1246
+ )
1247
+
1248
+ const patchPayload = isSubmodule
1249
+ ? buildSubmoduleDiffPayload(repoRoot, filePath, {
1250
+ includePatch: Boolean(options.includePatch),
1251
+ includeStats: Boolean(options.includeStats),
1252
+ fromHeadOid,
1253
+ toHeadOid,
1254
+ workspaceMode: Boolean(options.workspaceMode),
1255
+ })
1256
+ : buildDiffPayloadForFile(filePath, previousState, nextState, options)
1061
1257
  return {
1062
1258
  path: filePath,
1063
1259
  status: deriveFileStatus(previousState, nextState),
@@ -1072,6 +1268,61 @@ function createDiffFileEntry(filePath = '', previousState = null, nextState = nu
1072
1268
  }
1073
1269
  }
1074
1270
 
1271
+ function createDiffEntriesForPath(filePath = '', previousState = null, nextState = null, options = {}) {
1272
+ if (areFileStatesEqual(previousState, nextState)) {
1273
+ return []
1274
+ }
1275
+
1276
+ const repoRoot = String(options.repoRoot || '').trim()
1277
+ const fromHeadOid = String(options.fromHeadOid || '').trim()
1278
+ const toHeadOid = String(options.toHeadOid || '').trim()
1279
+ const submoduleRoot = repoRoot && (
1280
+ isGitSubmodulePath(repoRoot, filePath, fromHeadOid)
1281
+ || isGitSubmodulePath(repoRoot, filePath, toHeadOid)
1282
+ || isGitSubmodulePath(repoRoot, filePath)
1283
+ ? filePath
1284
+ : resolveSubmoduleRootForNestedPath(repoRoot, filePath, fromHeadOid)
1285
+ || resolveSubmoduleRootForNestedPath(repoRoot, filePath, toHeadOid)
1286
+ || resolveSubmoduleRootForNestedPath(repoRoot, filePath)
1287
+ )
1288
+
1289
+ if (!submoduleRoot) {
1290
+ const entry = createDiffFileEntry(filePath, previousState, nextState, options)
1291
+ return entry ? [entry] : []
1292
+ }
1293
+
1294
+ const payload = buildSubmoduleDiffPayload(repoRoot, submoduleRoot, {
1295
+ includePatch: true,
1296
+ includeStats: Boolean(options.includeStats),
1297
+ fromHeadOid,
1298
+ toHeadOid,
1299
+ workspaceMode: Boolean(options.workspaceMode),
1300
+ })
1301
+
1302
+ const nestedEntries = payload.patchLoaded
1303
+ ? parseSubmodulePatchEntries(submoduleRoot, payload.patch)
1304
+ : []
1305
+
1306
+ if (nestedEntries.length) {
1307
+ const normalizedPath = String(filePath || '').trim()
1308
+ const matchedEntry = nestedEntries.find((entry) => entry.path === normalizedPath)
1309
+ return matchedEntry ? [matchedEntry] : nestedEntries
1310
+ }
1311
+
1312
+ return [{
1313
+ path: filePath,
1314
+ status: deriveFileStatus(previousState, nextState),
1315
+ additions: payload.additions,
1316
+ deletions: payload.deletions,
1317
+ statsLoaded: payload.statsLoaded,
1318
+ binary: payload.binary,
1319
+ tooLarge: payload.tooLarge,
1320
+ patch: payload.patch,
1321
+ patchLoaded: payload.patchLoaded,
1322
+ message: payload.message,
1323
+ }]
1324
+ }
1325
+
1075
1326
  function createUnsupportedResult(reason = '', repoRoot = '', branch = '') {
1076
1327
  return {
1077
1328
  supported: false,
@@ -1141,20 +1392,26 @@ export function getWorkspaceGitDiffReviewByCwd(cwd = '', options = {}) {
1141
1392
  candidatePaths.forEach((filePath) => {
1142
1393
  const previousState = baselineStateForPath(filePath)
1143
1394
  const nextState = readFileState(repoRoot, filePath)
1144
- const diffEntry = createDiffFileEntry(filePath, previousState, nextState, {
1395
+ const diffEntries = createDiffEntriesForPath(filePath, previousState, nextState, {
1145
1396
  includePatch: Boolean(targetFilePath),
1146
1397
  includeStats,
1398
+ repoRoot,
1399
+ fromHeadOid: headOid,
1400
+ toHeadOid: currentHeadOid,
1401
+ workspaceMode: true,
1147
1402
  })
1148
- if (!diffEntry) {
1403
+ if (!diffEntries.length) {
1149
1404
  return
1150
1405
  }
1151
1406
 
1152
- fileCount += 1
1153
- additions += Math.max(0, Number(diffEntry.additions) || 0)
1154
- deletions += Math.max(0, Number(diffEntry.deletions) || 0)
1155
- if (includeFiles) {
1156
- files.push(diffEntry)
1157
- }
1407
+ diffEntries.forEach((diffEntry) => {
1408
+ fileCount += 1
1409
+ additions += Math.max(0, Number(diffEntry.additions) || 0)
1410
+ deletions += Math.max(0, Number(diffEntry.deletions) || 0)
1411
+ if (includeFiles) {
1412
+ files.push(diffEntry)
1413
+ }
1414
+ })
1158
1415
  })
1159
1416
 
1160
1417
  const payload = {
@@ -1400,20 +1657,25 @@ export function getTaskGitDiffReview(taskSlug = '', options = {}) {
1400
1657
  candidatePaths.forEach((filePath) => {
1401
1658
  const previousState = baselineStateForPath(filePath)
1402
1659
  const nextState = nextStateForPath(filePath)
1403
- const diffEntry = createDiffFileEntry(filePath, previousState, nextState, {
1660
+ const diffEntries = createDiffEntriesForPath(filePath, previousState, nextState, {
1404
1661
  includePatch: Boolean(targetFilePath),
1405
1662
  includeStats,
1663
+ repoRoot,
1664
+ fromHeadOid: baseline.headOid,
1665
+ toHeadOid: currentHeadOid,
1406
1666
  })
1407
- if (!diffEntry) {
1667
+ if (!diffEntries.length) {
1408
1668
  return
1409
1669
  }
1410
1670
 
1411
- fileCount += 1
1412
- additions += Math.max(0, Number(diffEntry.additions) || 0)
1413
- deletions += Math.max(0, Number(diffEntry.deletions) || 0)
1414
- if (includeFiles) {
1415
- files.push(diffEntry)
1416
- }
1671
+ diffEntries.forEach((diffEntry) => {
1672
+ fileCount += 1
1673
+ additions += Math.max(0, Number(diffEntry.additions) || 0)
1674
+ deletions += Math.max(0, Number(diffEntry.deletions) || 0)
1675
+ if (includeFiles) {
1676
+ files.push(diffEntry)
1677
+ }
1678
+ })
1417
1679
  })
1418
1680
 
1419
1681
  const payload = {
@@ -1,12 +1,19 @@
1
1
  import { assertInternalRequest } from './internalAuth.js'
2
2
 
3
+ const RUNNER_EVENTS_BODY_LIMIT = Math.max(
4
+ 1024 * 1024,
5
+ Number(process.env.PROMPTX_INTERNAL_RUNNER_EVENTS_BODY_LIMIT) || 8 * 1024 * 1024
6
+ )
7
+
3
8
  function registerInternalRunnerRoutes(app, options = {}) {
4
9
  const {
5
10
  runEventIngestService,
6
11
  taskAutomationService,
7
12
  } = options
8
13
 
9
- app.post('/internal/runner-events', async (request, reply) => {
14
+ app.post('/internal/runner-events', {
15
+ bodyLimit: RUNNER_EVENTS_BODY_LIMIT,
16
+ }, async (request, reply) => {
10
17
  try {
11
18
  assertInternalRequest(request.headers)
12
19
  return runEventIngestService.ingestEvents(request.body?.items || [])
@@ -1,6 +1,7 @@
1
1
  import process from 'node:process'
2
2
  import WebSocket from 'ws'
3
3
 
4
+ import { buildInternalAuthHeaders } from './internalAuth.js'
4
5
  import {
5
6
  buildRelayWebSocketUrl,
6
7
  createRelayRequestId,
@@ -315,7 +316,10 @@ function createRelayClient({
315
316
  const targetUrl = new URL(record.path, config.localBaseUrl)
316
317
  const response = await fetch(targetUrl, {
317
318
  method: record.method,
318
- headers: sanitizeProxyHeaders(record.headers, ['cookie']),
319
+ headers: buildInternalAuthHeaders({
320
+ ...sanitizeProxyHeaders(record.headers, ['cookie']),
321
+ 'x-promptx-relay-request': '1',
322
+ }),
319
323
  body: ['GET', 'HEAD'].includes(record.method) || !bodyBuffer.length ? undefined : bodyBuffer,
320
324
  signal: controller.signal,
321
325
  })
@@ -14,6 +14,9 @@ function normalizeRelayConfig(input = {}) {
14
14
  const relayUrl = String(input?.relayUrl || '').trim()
15
15
  const deviceId = String(input?.deviceId || '').trim()
16
16
  const deviceToken = String(input?.deviceToken || '').trim()
17
+ const allowRemoteShell = typeof input?.allowRemoteShell === 'boolean'
18
+ ? input.allowRemoteShell
19
+ : ['1', 'true', 'on', 'yes'].includes(String(input?.allowRemoteShell || '').trim().toLowerCase())
17
20
  const enabled = typeof input?.enabled === 'boolean'
18
21
  ? input.enabled
19
22
  : !['0', 'false', 'off', 'no'].includes(String(input?.enabled || '').trim().toLowerCase())
@@ -22,6 +25,7 @@ function normalizeRelayConfig(input = {}) {
22
25
  relayUrl,
23
26
  deviceId,
24
27
  deviceToken,
28
+ allowRemoteShell,
25
29
  enabled: Boolean(enabled && relayUrl && deviceId && deviceToken),
26
30
  }
27
31
  }
@@ -49,6 +53,7 @@ function getRelayConfigForClient() {
49
53
  const relayUrl = String(process.env.PROMPTX_RELAY_URL || stored.relayUrl || '').trim()
50
54
  const deviceId = String(process.env.PROMPTX_RELAY_DEVICE_ID || stored.deviceId || '').trim()
51
55
  const deviceToken = String(process.env.PROMPTX_RELAY_DEVICE_TOKEN || stored.deviceToken || '').trim()
56
+ const envAllowRemoteShell = String(process.env.PROMPTX_RELAY_ALLOW_REMOTE_SHELL || '').trim()
52
57
  const envEnabled = String(process.env.PROMPTX_RELAY_ENABLED || '').trim()
53
58
  const managedByEnv = isRelayConfigManagedByEnv()
54
59
  const enabled = envEnabled
@@ -56,11 +61,15 @@ function getRelayConfigForClient() {
56
61
  : managedByEnv
57
62
  ? Boolean(relayUrl && deviceId && deviceToken)
58
63
  : Boolean(stored.enabled)
64
+ const allowRemoteShell = envAllowRemoteShell
65
+ ? ['1', 'true', 'on', 'yes'].includes(envAllowRemoteShell.toLowerCase())
66
+ : Boolean(stored.allowRemoteShell)
59
67
 
60
68
  return normalizeRelayConfig({
61
69
  relayUrl,
62
70
  deviceId,
63
71
  deviceToken,
72
+ allowRemoteShell,
64
73
  enabled,
65
74
  })
66
75
  }
@@ -70,6 +79,7 @@ function isRelayConfigManagedByEnv() {
70
79
  String(process.env.PROMPTX_RELAY_URL || '').trim()
71
80
  || String(process.env.PROMPTX_RELAY_DEVICE_ID || '').trim()
72
81
  || String(process.env.PROMPTX_RELAY_DEVICE_TOKEN || '').trim()
82
+ || String(process.env.PROMPTX_RELAY_ALLOW_REMOTE_SHELL || '').trim()
73
83
  || String(process.env.PROMPTX_RELAY_ENABLED || '').trim()
74
84
  )
75
85
  }
@@ -2,6 +2,7 @@ import {
2
2
  extractRunnerDispatchPatch,
3
3
  reconcileRunAfterRunnerDispatchError,
4
4
  } from './runnerDispatch.js'
5
+ import { extractShellCommandIntent } from '../../../packages/shared/src/index.js'
5
6
  import { createApiError } from './apiErrors.js'
6
7
 
7
8
  function normalizeBaseUrl(value = '') {
@@ -82,6 +83,11 @@ function buildRunnerPromptPayload(session = {}, input = {}, options = {}) {
82
83
  }
83
84
  }
84
85
 
86
+ function normalizeCommandMode(value = '') {
87
+ const normalized = String(value || '').trim().toLowerCase()
88
+ return normalized === 'shell' ? 'shell' : ''
89
+ }
90
+
85
91
  export function createRunDispatchService(options = {}) {
86
92
  const runnerClient = options.runnerClient
87
93
  const logger = options.logger || console
@@ -100,36 +106,56 @@ export function createRunDispatchService(options = {}) {
100
106
 
101
107
  async function startTaskRunForTask(payload = {}) {
102
108
  const normalizedTaskSlug = String(payload.taskSlug || '').trim()
103
- const normalizedSessionId = String(payload.sessionId || '').trim()
104
- const normalizedProjectSessionId = String(payload.projectSessionId || normalizedSessionId).trim()
109
+ const requestedSessionId = String(payload.sessionId || '').trim()
110
+ const normalizedProjectSessionId = String(payload.projectSessionId || requestedSessionId).trim()
105
111
  const normalizedPrompt = String(payload.prompt || '').trim()
106
112
  const promptBlocks = Array.isArray(payload.promptBlocks) ? payload.promptBlocks : []
113
+ const displayEngine = String(payload.displayEngine || '').trim()
114
+ const requestedCommandMode = normalizeCommandMode(payload.commandMode)
115
+ const shellIntent = extractShellCommandIntent({
116
+ prompt: normalizedPrompt,
117
+ promptBlocks,
118
+ })
119
+ const commandMode = shellIntent.mode === 'shell' ? 'shell' : ''
120
+ const normalizedCommand = commandMode === 'shell' ? shellIntent.command : ''
121
+ const allowShellCommand = payload.allowShellCommand === true
107
122
 
108
123
  if (!normalizedTaskSlug) {
109
124
  throw createApiError('errors.taskNotFound', '任务不存在。', 404)
110
125
  }
111
- if (!normalizedSessionId) {
126
+ if (!requestedSessionId) {
112
127
  throw createApiError('errors.sessionRequired', '请先选择一个 PromptX 项目。')
113
128
  }
114
129
  if (!normalizedPrompt) {
115
130
  throw createApiError('errors.noPromptToSend', '没有可发送的提示词。')
116
131
  }
132
+ if (requestedCommandMode === 'shell' && shellIntent.reason === 'unsupported_blocks') {
133
+ throw createApiError('errors.shellUnsupportedBlocks', '命令模式暂不支持图片或导入文件,请只保留纯文本命令。', 400)
134
+ }
135
+ if (requestedCommandMode === 'shell' && shellIntent.reason === 'empty_command') {
136
+ throw createApiError('errors.shellEmptyCommand', '请输入要执行的命令,例如 !git status', 400)
137
+ }
138
+ if (commandMode === 'shell' && !allowShellCommand) {
139
+ throw createApiError('errors.shellLocalOnly', '命令模式默认仅允许在本机本地界面中使用;如需对远程访问开放,请先到设置里显式开启。', 403)
140
+ }
117
141
 
118
142
  const task = getTaskBySlug(normalizedTaskSlug)
119
143
  if (!task || task.expired) {
120
144
  throw createApiError('errors.taskNotFound', '任务不存在。', 404)
121
145
  }
122
146
 
123
- const session = getPromptxCodexSessionById(normalizedSessionId)
124
- if (!session) {
147
+ const requestedSession = getPromptxCodexSessionById(requestedSessionId)
148
+ if (!requestedSession) {
125
149
  throw createApiError('errors.sessionNotFound', '没有找到对应的 PromptX 项目。', 404)
126
150
  }
127
- const projectSession = normalizedProjectSessionId === normalizedSessionId
128
- ? session
151
+ const projectSession = normalizedProjectSessionId === requestedSessionId
152
+ ? requestedSession
129
153
  : getPromptxCodexSessionById(normalizedProjectSessionId)
130
154
  if (!projectSession) {
131
155
  throw createApiError('errors.sessionNotFound', '没有找到对应的 PromptX 项目。', 404)
132
156
  }
157
+ const session = commandMode === 'shell' ? projectSession : requestedSession
158
+ const normalizedSessionId = String(session?.id || '').trim()
133
159
 
134
160
  const relatedSessionIds = new Set([
135
161
  normalizedProjectSessionId,
@@ -145,11 +171,16 @@ export function createRunDispatchService(options = {}) {
145
171
  throw createApiError('errors.currentProjectRunning', '当前项目正在执行中,请等待完成后再发送。', 409)
146
172
  }
147
173
 
174
+ const runEngine = commandMode === 'shell' ? 'shell' : (session.engine || 'codex')
175
+ const runnerPrompt = commandMode === 'shell' ? normalizedCommand : normalizedPrompt
176
+
148
177
  const runRecord = createCodexRun({
149
178
  taskSlug: normalizedTaskSlug,
150
179
  sessionId: normalizedSessionId,
151
180
  prompt: normalizedPrompt,
152
181
  promptBlocks,
182
+ engine: runEngine,
183
+ displayEngine,
153
184
  status: 'queued',
154
185
  })
155
186
 
@@ -160,7 +191,7 @@ export function createRunDispatchService(options = {}) {
160
191
 
161
192
  try {
162
193
  const runnerPromptPayload = buildRunnerPromptPayload(session, {
163
- prompt: normalizedPrompt,
194
+ prompt: runnerPrompt,
164
195
  promptBlocks,
165
196
  }, {
166
197
  localServerBaseUrl,
@@ -172,15 +203,15 @@ export function createRunDispatchService(options = {}) {
172
203
  runId: runRecord.id,
173
204
  taskSlug: normalizedTaskSlug,
174
205
  sessionId: normalizedSessionId,
175
- engine: session.engine,
206
+ engine: runEngine,
176
207
  prompt: runnerPromptPayload.prompt,
177
208
  promptBlocks: runnerPromptPayload.promptBlocks,
178
209
  cwd: session.cwd,
179
210
  title: session.title,
180
- codexThreadId: session.codexThreadId,
181
- engineSessionId: session.engineSessionId,
182
- engineThreadId: session.engineThreadId,
183
- engineMeta: session.engineMeta,
211
+ codexThreadId: commandMode === 'shell' ? '' : session.codexThreadId,
212
+ engineSessionId: commandMode === 'shell' ? '' : session.engineSessionId,
213
+ engineThreadId: commandMode === 'shell' ? '' : session.engineThreadId,
214
+ engineMeta: commandMode === 'shell' ? {} : session.engineMeta,
184
215
  sessionCreatedAt: session.createdAt,
185
216
  sessionUpdatedAt: session.updatedAt,
186
217
  })
@@ -8,26 +8,28 @@ import {
8
8
  import { getPromptxCodexSessionById, updatePromptxCodexSession } from './codexSessions.js'
9
9
 
10
10
  function toSafeSessionPatch(session = {}) {
11
+ const isShellEngine = String(session?.engine || '').trim().toLowerCase() === 'shell'
11
12
  const hasIdentityPatch = [
12
13
  'codexThreadId',
13
14
  'engineSessionId',
14
15
  'engineThreadId',
15
16
  ].some((key) => Object.prototype.hasOwnProperty.call(session, key))
17
+ && !isShellEngine
16
18
 
17
19
  return {
18
20
  ...(Object.prototype.hasOwnProperty.call(session, 'title')
19
21
  ? { title: session.title }
20
22
  : {}),
21
- ...(Object.prototype.hasOwnProperty.call(session, 'codexThreadId')
23
+ ...(!isShellEngine && Object.prototype.hasOwnProperty.call(session, 'codexThreadId')
22
24
  ? { codexThreadId: session.codexThreadId }
23
25
  : {}),
24
- ...(Object.prototype.hasOwnProperty.call(session, 'engineSessionId')
26
+ ...(!isShellEngine && Object.prototype.hasOwnProperty.call(session, 'engineSessionId')
25
27
  ? { engineSessionId: session.engineSessionId }
26
28
  : {}),
27
- ...(Object.prototype.hasOwnProperty.call(session, 'engineThreadId')
29
+ ...(!isShellEngine && Object.prototype.hasOwnProperty.call(session, 'engineThreadId')
28
30
  ? { engineThreadId: session.engineThreadId }
29
31
  : {}),
30
- ...(Object.prototype.hasOwnProperty.call(session, 'engineMeta')
32
+ ...(!isShellEngine && Object.prototype.hasOwnProperty.call(session, 'engineMeta')
31
33
  ? { engineMeta: session.engineMeta }
32
34
  : {}),
33
35
  ...(hasIdentityPatch
@@ -1,8 +1,75 @@
1
1
  import { normalizeCodexRunEventsMode } from '../../../packages/shared/src/index.js'
2
2
  import { getApiErrorPayload } from './apiErrors.js'
3
+ import { isValidInternalAuthToken, readInternalAuthToken } from './internalAuth.js'
4
+ import { getRelayConfigForClient } from './relayConfig.js'
3
5
 
4
6
  const MAX_TASK_REORDER_COUNT = 200
5
7
 
8
+ function isLoopbackHost(value = '') {
9
+ const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '')
10
+ return host === '127.0.0.1'
11
+ || host === '::1'
12
+ || host === 'localhost'
13
+ || host.endsWith('.localhost')
14
+ }
15
+
16
+ function isLoopbackUrl(value = '') {
17
+ const text = String(value || '').trim()
18
+ if (!text) {
19
+ return false
20
+ }
21
+
22
+ try {
23
+ return isLoopbackHost(new URL(text).hostname)
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ function isLocalShellCommandRequest(request) {
30
+ if (isRelayProxyRequest(request)) {
31
+ return false
32
+ }
33
+
34
+ const origin = String(request?.headers?.origin || '').trim()
35
+ if (origin) {
36
+ return isLoopbackUrl(origin)
37
+ }
38
+
39
+ const referer = String(request?.headers?.referer || '').trim()
40
+ if (referer) {
41
+ return isLoopbackUrl(referer)
42
+ }
43
+
44
+ const hostname = String(request?.hostname || '').trim()
45
+ if (hostname) {
46
+ return isLoopbackHost(hostname)
47
+ }
48
+
49
+ const hostHeader = String(request?.headers?.host || '').trim()
50
+ if (hostHeader) {
51
+ return isLoopbackHost(hostHeader.split(':')[0])
52
+ }
53
+
54
+ return false
55
+ }
56
+
57
+ function isRelayProxyRequest(request) {
58
+ const relayRequestFlag = String(request?.headers?.['x-promptx-relay-request'] || '').trim()
59
+ if (relayRequestFlag !== '1') {
60
+ return false
61
+ }
62
+ return isValidInternalAuthToken(readInternalAuthToken(request?.headers || {}))
63
+ }
64
+
65
+ function isShellCommandRequestAllowed(request, relayConfig = {}) {
66
+ if (isLocalShellCommandRequest(request)) {
67
+ return true
68
+ }
69
+
70
+ return isRelayProxyRequest(request) && Boolean(relayConfig?.allowRemoteShell)
71
+ }
72
+
6
73
  function createEmptyWorkspaceDiffSummary() {
7
74
  return {
8
75
  supported: false,
@@ -116,6 +183,7 @@ function registerTaskRoutes(app, options = {}) {
116
183
  deleteTask,
117
184
  deleteTaskCodexRuns,
118
185
  getPromptxCodexSessionById,
186
+ getRelayConfig = getRelayConfigForClient,
119
187
  getRunningCodexRunByTaskSlug,
120
188
  getTaskBySlug,
121
189
  getTaskGitDiffReviewInSubprocess,
@@ -366,6 +434,10 @@ function registerTaskRoutes(app, options = {}) {
366
434
  sessionId: request.body?.sessionId,
367
435
  prompt: request.body?.prompt,
368
436
  promptBlocks: request.body?.promptBlocks,
437
+ displayEngine: request.body?.displayEngine,
438
+ commandMode: request.body?.commandMode,
439
+ command: request.body?.command,
440
+ allowShellCommand: isShellCommandRequestAllowed(request, getRelayConfig()),
369
441
  })
370
442
  return reply.code(payload?.runnerDispatchPending ? 202 : 201).send(payload)
371
443
  } catch (error) {