@shawnstack/quickforge 1.7.5 → 1.7.6

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 (66) hide show
  1. package/dist/assets/AgentProfilesPage-Dl5Peqov.js +1 -0
  2. package/dist/assets/ChatPanelHost-D3987TUJ.js +307 -0
  3. package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +1 -0
  4. package/dist/assets/PluginsPage-BDKYfLna.js +1 -0
  5. package/dist/assets/ScheduledTasksPage--GAki3lB.js +2 -0
  6. package/dist/assets/SettingsWorkspacePage-DSy66Tuw.js +1696 -0
  7. package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +1 -0
  8. package/dist/assets/SharedConversationPage-B9BUVCri.js +1 -0
  9. package/dist/assets/TerminalDock-CT1nYo7P.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-D9kdnLDd.js → WorkspaceInspector-BnfDLf9a.js} +5 -5
  11. package/dist/assets/{dist-DLKC_odd.js → dist-yDHbi5ux.js} +1 -1
  12. package/dist/assets/{esm-Df4edDpj.js → esm-sdsK6gaB.js} +2 -2
  13. package/dist/assets/{icons-CXPP1fFT.js → icons-2D18YbSI.js} +1 -1
  14. package/dist/assets/index-1ei6nhs-.css +3 -0
  15. package/dist/assets/index-BivLuZvX.js +65 -0
  16. package/dist/assets/{mcp-servers-dialog-BUSPvaqI.js → mcp-servers-dialog-BEV4_TXC.js} +2 -2
  17. package/dist/assets/{logger-8REuLPGK.js → model-display-label-C6-0eDxF.js} +1 -1
  18. package/dist/assets/{monaco-D6OhXgjg.js → monaco-DvXErxp2.js} +1 -1
  19. package/dist/assets/{react-vendor-1KC5PyRx.js → react-vendor-3UaA-C9W.js} +1 -1
  20. package/dist/assets/{skills-dialog-BThnIUBs.js → skills-dialog-BTJ7mzk_.js} +3 -3
  21. package/dist/assets/{web-DoV8MSMn.js → web-B5qgZJRv.js} +1 -1
  22. package/dist/index.html +8 -8
  23. package/dist/sw.js +17 -0
  24. package/package.json +2 -1
  25. package/runtime-assets/agent/darwin-arm64/qf-agent +0 -0
  26. package/runtime-assets/agent/darwin-x64/qf-agent +0 -0
  27. package/runtime-assets/agent/linux-arm64/qf-agent +0 -0
  28. package/runtime-assets/agent/linux-x64/qf-agent +0 -0
  29. package/runtime-assets/agent/win32-x64/qf-agent.exe +0 -0
  30. package/server/access-policy.mjs +11 -0
  31. package/server/agent-manager.mjs +349 -48
  32. package/server/agent-profiles.mjs +27 -8
  33. package/server/auto-compaction.mjs +35 -8
  34. package/server/cloud/client.mjs +4 -7
  35. package/server/cloud/config.mjs +6 -5
  36. package/server/cloud/credential-store.mjs +10 -3
  37. package/server/cloud/identity.mjs +17 -60
  38. package/server/cloud/models.mjs +1 -1
  39. package/server/cloud/qf-agent-process.mjs +571 -0
  40. package/server/cloud/runtime.mjs +11 -5
  41. package/server/cloud/service-config.mjs +8 -4
  42. package/server/index.mjs +96 -37
  43. package/server/mcp/registry.mjs +8 -7
  44. package/server/message-converters.mjs +40 -19
  45. package/server/model-catalog.mjs +11 -4
  46. package/server/opencode-acp-agent.mjs +1151 -0
  47. package/server/public-api.mjs +11 -5
  48. package/server/routes/agent-profiles.mjs +6 -3
  49. package/server/routes/agent.mjs +43 -3
  50. package/server/routes/backup.mjs +5 -4
  51. package/server/routes/cloud.mjs +54 -35
  52. package/server/routes/shares.mjs +7 -8
  53. package/server/routes/storage.mjs +9 -24
  54. package/server/routes/system.mjs +7 -4
  55. package/server/utils/process-tree.mjs +78 -0
  56. package/dist/assets/AgentProfilesPage-XNjuqJs7.js +0 -1
  57. package/dist/assets/ChatPanelHost-CuF-PZpc.js +0 -292
  58. package/dist/assets/CloudAccountSettingsPage-GJSoi6sO.js +0 -1
  59. package/dist/assets/PluginsPage-BQgp0qN-.js +0 -1
  60. package/dist/assets/ScheduledTasksPage-DdekATzq.js +0 -2
  61. package/dist/assets/SettingsWorkspacePage-Cb54Ic91.js +0 -1639
  62. package/dist/assets/ShareLinksSettingsPage-C75WJpaF.js +0 -1
  63. package/dist/assets/SharedConversationPage-CjMtSQTW.js +0 -1
  64. package/dist/assets/TerminalDock-D4zJBJc4.js +0 -2
  65. package/dist/assets/index-_ZhK6bvp.js +0 -65
  66. package/dist/assets/index-iVV2bP_f.css +0 -3
package/server/index.mjs CHANGED
@@ -35,6 +35,8 @@ import { handleTerminalApi, handleTerminalUpgrade } from './routes/terminal.mjs'
35
35
  import { handleChannelsApi } from './routes/channels.mjs'
36
36
  import { handleModelsApi } from './routes/models.mjs'
37
37
  import { handleCloudApi } from './routes/cloud.mjs'
38
+ import { readCloudServiceConfig } from './cloud/service-config.mjs'
39
+ import { startQfAgent, stopQfAgent, getQfAgentStatus } from './cloud/qf-agent-process.mjs'
38
40
  import { serveStatic } from './routes/static.mjs'
39
41
  import { logger, flushLogger } from './utils/logger.mjs'
40
42
  import { getPackageInfo, checkForUpdates, checkDesktopRelease } from './utils/package-update.mjs'
@@ -47,6 +49,7 @@ import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
47
49
  import { shutdownMcpConnections } from './mcp/registry.mjs'
48
50
  import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
49
51
  import { createNodeProcessEnv } from './utils/process-env.mjs'
52
+ import { isAuthenticatedAppClient } from './access-policy.mjs'
50
53
 
51
54
  const __filename = fileURLToPath(import.meta.url)
52
55
  const __dirname = path.dirname(__filename)
@@ -67,6 +70,10 @@ const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
67
70
  const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
68
71
  let restartInProgress = false
69
72
  let updateInProgress = false
73
+ let shutdownPromise = null
74
+ let shutdownStarted = false
75
+ let qfAgentStartPromise = null
76
+ let boundServerPort = null
70
77
 
71
78
  setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || path.join(dataDir, 'workspace'))
72
79
 
@@ -74,11 +81,12 @@ function getRestartSupport() {
74
81
  return { supported: true, reason: null }
75
82
  }
76
83
 
77
- async function getSystemStatus({ isLocalRequest = true } = {}) {
84
+ async function getSystemStatus({ isLocalRequest = true, remoteAuthorized = false } = {}) {
78
85
  const config = await readProjectConfig()
79
86
  const restartSupport = getRestartSupport()
80
87
  const packageInfo = await getPackageInfo(projectRoot)
81
- const restartSupported = isLocalRequest && restartSupport.supported
88
+ const restartAllowed = isAuthenticatedAppClient({ isLocalRequest, remoteAuthorized })
89
+ const restartSupported = restartAllowed && restartSupport.supported
82
90
  return {
83
91
  ok: true,
84
92
  mode: isDev ? 'development' : 'production',
@@ -94,7 +102,7 @@ async function getSystemStatus({ isLocalRequest = true } = {}) {
94
102
  terminal: isLocalRequest,
95
103
  },
96
104
  restartSupported,
97
- restartUnsupportedReason: isLocalRequest ? restartSupport.reason : 'Restart is only allowed from this computer',
105
+ restartUnsupportedReason: restartAllowed ? restartSupport.reason : 'Restart requires an authenticated client',
98
106
  dataDir,
99
107
  configDir,
100
108
  storageDir,
@@ -151,11 +159,9 @@ function closeHttpServer() {
151
159
  })
152
160
  }
153
161
 
154
- async function performRestart() {
155
- logger.info('Restart requested from settings UI.')
156
- const supervisorPid = await spawnRestartSupervisor()
157
- logger.info(`Restart supervisor started (PID ${supervisorPid}).`)
158
-
162
+ async function shutdownRuntime() {
163
+ shutdownStarted = true
164
+ await stopQfAgent()
159
165
  stopScheduledTaskRunner()
160
166
  stopAutoArchiveRunner()
161
167
  stopVite()
@@ -164,6 +170,19 @@ async function performRestart() {
164
170
  await shutdownChannels()
165
171
  shutdownTerminalSessions()
166
172
  await closeHttpServer()
173
+ }
174
+
175
+ export function stopQuickForgeServer() {
176
+ if (!shutdownPromise) shutdownPromise = shutdownRuntime()
177
+ return shutdownPromise
178
+ }
179
+
180
+ async function performRestart() {
181
+ logger.info('Restart requested from settings UI.')
182
+ const supervisorPid = await spawnRestartSupervisor()
183
+ logger.info(`Restart supervisor started (PID ${supervisorPid}).`)
184
+
185
+ await stopQuickForgeServer()
167
186
  process.exit(0)
168
187
  }
169
188
 
@@ -230,14 +249,7 @@ function spawnUpdateSupervisor(update) {
230
249
 
231
250
  async function shutdownForUpdate() {
232
251
  logger.info('Shutting down QuickForge for external updater.')
233
- stopScheduledTaskRunner()
234
- stopAutoArchiveRunner()
235
- stopVite()
236
- await shutdownAgentManager()
237
- await shutdownMcpConnections()
238
- await shutdownChannels()
239
- shutdownTerminalSessions()
240
- await closeHttpServer()
252
+ await stopQuickForgeServer()
241
253
  flushLogger()
242
254
  process.exit(0)
243
255
  }
@@ -284,6 +296,22 @@ async function updateQuickForge() {
284
296
  }
285
297
  }
286
298
 
299
+ async function applyCloudServiceConfig(cloudConfig, { urlChanged = false } = {}) {
300
+ if (shutdownStarted) return null
301
+ if (!cloudConfig.enabled || !cloudConfig.valid) {
302
+ if (!cloudConfig.valid) logger.warn(`QuickForge remote agent was not started: ${cloudConfig.configurationError || 'invalid Cloud configuration'}`)
303
+ return stopQfAgent({ disabled: true })
304
+ }
305
+ if (!boundServerPort) return null
306
+ const agentStatus = getQfAgentStatus()
307
+ if (urlChanged || agentStatus.status === 'disabled' || agentStatus.enabled === false) await stopQfAgent()
308
+ return startQfAgent({
309
+ serverUrl: `http://127.0.0.1:${boundServerPort}/`,
310
+ ownerPid: process.pid,
311
+ cloudUrl: cloudConfig.cloudUrl,
312
+ })
313
+ }
314
+
287
315
  // --- Route dispatching ---
288
316
  async function handleApi(req, res, url, requestContext = {}) {
289
317
  const pathname = url.pathname
@@ -348,6 +376,7 @@ async function handleApi(req, res, url, requestContext = {}) {
348
376
  isLocalRequest: requestContext.isLocalRequest === true,
349
377
  remoteAddress: requestContext.remoteAddress,
350
378
  remoteAuthorized: requestContext.remoteAuthorized === true,
379
+ onCloudServiceConfigChanged: applyCloudServiceConfig,
351
380
  })
352
381
  return
353
382
  }
@@ -449,6 +478,7 @@ async function handleApi(req, res, url, requestContext = {}) {
449
478
  checkDesktopRelease: () => checkDesktopRelease(projectRoot),
450
479
  updateQuickForge,
451
480
  isLocalRequest: requestContext.isLocalRequest === true,
481
+ remoteAuthorized: requestContext.remoteAuthorized === true,
452
482
  getTerminalShellSetting: readTerminalShellSetting,
453
483
  updateTerminalShellSetting,
454
484
  getTerminalShellConfig: readTerminalShellConfig,
@@ -544,6 +574,24 @@ function isAllowedHostHeader(value) {
544
574
  return allowedHosts.has(parsed.hostname) && hostPort === expectedPort
545
575
  }
546
576
 
577
+ const trustedTunnelSocket = Symbol('trustedTunnelSocket')
578
+
579
+ function isTunnelClientRequest(req) {
580
+ if (
581
+ isLoopbackAddress(req.socket.remoteAddress)
582
+ && req.headers['x-quickforge-tunnel'] === '1'
583
+ && req.headers.host === '127.0.0.1:18080'
584
+ ) {
585
+ req.socket[trustedTunnelSocket] = true
586
+ }
587
+ return req.socket[trustedTunnelSocket] === true
588
+ }
589
+
590
+ function isAllowedRequestHost(req, isTunnelClient) {
591
+ return isAllowedHostHeader(req.headers.host)
592
+ || (isTunnelClient && req.headers.host === '127.0.0.1:18080')
593
+ }
594
+
547
595
  function isLanAccessBootstrapPath(pathname) {
548
596
  return pathname === '/api/health'
549
597
  || pathname === '/api/lan-access/status'
@@ -583,7 +631,11 @@ const server = createServer(async (req, res) => {
583
631
  reqLogger.info(`${req.method} ${req.url} ${res.statusCode}`, { method: req.method, url: req.url, status: res.statusCode, durationMs })
584
632
  })
585
633
 
586
- if (!isAllowedHostHeader(req.headers.host)) {
634
+ const remoteAddress = req.socket.remoteAddress
635
+ // 云远程访问(RemoteTunnel):agent 与 qf 同机时流量经 127.0.0.1 回环进入并携带
636
+ // X-QuickForge-Tunnel: 1。仅该可信隧道允许手机本地入口 127.0.0.1:18080 作为 Host。
637
+ const isTunnelClient = isTunnelClientRequest(req)
638
+ if (!isAllowedRequestHost(req, isTunnelClient)) {
587
639
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
588
640
  res.end(JSON.stringify({ error: 'Forbidden host' }))
589
641
  return
@@ -605,11 +657,11 @@ const server = createServer(async (req, res) => {
605
657
  }
606
658
  try {
607
659
  const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`)
608
- const remoteAddress = req.socket.remoteAddress
609
- const isRemoteRequest = !isLoopbackAddress(remoteAddress)
610
- const remoteAuthorized = isRemoteRequest ? await isAuthorizedRemoteRequest(req) : true
660
+ // 隧道请求视为“已认证远程客户端”:认证通过,但本地能力按远程请求裁剪。
661
+ const isRemoteRequest = isTunnelClient || !isLoopbackAddress(remoteAddress)
662
+ const remoteAuthorized = isRemoteRequest ? (isTunnelClient ? true : await isAuthorizedRemoteRequest(req)) : true
611
663
 
612
- if (isRemoteRequest && shareLanEnabled && !remoteAuthorized && !isLanAccessBootstrapPath(url.pathname) && !isSharePath(url.pathname) && !isStaticAssetPath(url.pathname)) {
664
+ if (isRemoteRequest && !remoteAuthorized && !isLanAccessBootstrapPath(url.pathname) && !isSharePath(url.pathname) && !isStaticAssetPath(url.pathname)) {
613
665
  if (url.pathname.startsWith('/api/')) {
614
666
  sendLanAuthRequired(res)
615
667
  } else {
@@ -621,7 +673,6 @@ const server = createServer(async (req, res) => {
621
673
  if (
622
674
  url.pathname.startsWith('/api/') &&
623
675
  isRemoteRequest &&
624
- shareLanEnabled &&
625
676
  !remoteAuthorized &&
626
677
  !(url.pathname.startsWith('/api/shared/') || url.pathname === '/api/health' || url.pathname.startsWith('/api/lan-access/'))
627
678
  ) {
@@ -635,6 +686,7 @@ const server = createServer(async (req, res) => {
635
686
  isLocalRequest: !isRemoteRequest,
636
687
  remoteAddress,
637
688
  remoteAuthorized,
689
+ tunnelClient: isTunnelClient,
638
690
  })
639
691
  return
640
692
  }
@@ -649,7 +701,7 @@ const server = createServer(async (req, res) => {
649
701
  return
650
702
  }
651
703
 
652
- if (isRemoteRequest && shareLanEnabled && !remoteAuthorized) {
704
+ if (isRemoteRequest && !remoteAuthorized) {
653
705
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
654
706
  res.end(JSON.stringify({ error: 'Remote access is limited to shared conversation links.' }))
655
707
  return
@@ -677,7 +729,8 @@ function writeAndDestroySocket(socket, statusLine) {
677
729
  }
678
730
 
679
731
  server.on('upgrade', (req, socket, head) => {
680
- if (!isAllowedHostHeader(req.headers.host)) {
732
+ const isTunnelClient = isTunnelClientRequest(req)
733
+ if (!isAllowedRequestHost(req, isTunnelClient)) {
681
734
  writeAndDestroySocket(socket, 'HTTP/1.1 403 Forbidden')
682
735
  return
683
736
  }
@@ -686,7 +739,8 @@ server.on('upgrade', (req, socket, head) => {
686
739
  const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`)
687
740
  if (url.pathname.startsWith('/api/terminal/sessions/')) {
688
741
  handleTerminalUpgrade(req, socket, head, url, {
689
- isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
742
+ // 隧道流量同样是远程客户端:终端 WebSocket 保持禁止。
743
+ isLocalRequest: isLoopbackAddress(req.socket.remoteAddress) && !isTunnelClient,
690
744
  })
691
745
  return
692
746
  }
@@ -725,35 +779,40 @@ server.on('error', (error) => {
725
779
  })
726
780
 
727
781
  server.listen(port, host, () => {
728
- logger.info(`QuickForge local API: http://${host}:${port}`)
782
+ const address = server.address()
783
+ const boundPort = typeof address === 'object' && address ? address.port : port
784
+ boundServerPort = boundPort
785
+ logger.info(`QuickForge local API: http://${host}:${boundPort}`)
729
786
  if (shareLanEnabled) {
730
- const lanUrls = getLanUrls(port)
731
- logger.info(`QuickForge LAN sharing is enabled. Share pages are available at: ${lanUrls.length ? lanUrls.join(', ') : `http://<your-lan-ip>:${port}`}`)
787
+ const lanUrls = getLanUrls(boundPort)
788
+ logger.info(`QuickForge LAN sharing is enabled. Share pages are available at: ${lanUrls.length ? lanUrls.join(', ') : `http://<your-lan-ip>:${boundPort}`}`)
732
789
  logger.info('Remote non-share API routes are restricted while QUICKFORGE_SHARE_LAN=1.')
733
790
  }
734
791
  logger.info(`QuickForge data dir: ${dataDir}`)
735
792
  logger.info(`QuickForge project: ${getWorkspaceRoot()}`)
736
793
 
794
+ const pending = readCloudServiceConfig({ strict: false }).then((cloudConfig) => applyCloudServiceConfig(cloudConfig)).catch((error) => {
795
+ if (!shutdownStarted) logger.warn(`QuickForge remote agent failed to start: ${error?.message || error}`)
796
+ return null
797
+ }).finally(() => {
798
+ if (qfAgentStartPromise === pending) qfAgentStartPromise = null
799
+ })
800
+ qfAgentStartPromise = pending
801
+
737
802
  if (isDev) {
738
803
  startVite()
739
804
  setTimeout(() => openBrowser(`http://localhost:${vitePort}`), 1000)
740
805
  } else if (shareLanEnabled) {
741
- openBrowser(`http://localhost:${port}`)
806
+ openBrowser(`http://localhost:${boundPort}`)
742
807
  } else {
743
- openBrowser(`http://localhost:${port}`)
808
+ openBrowser(`http://localhost:${boundPort}`)
744
809
  }
745
810
  })
746
811
 
747
812
  // Graceful shutdown
748
813
  async function gracefulShutdown(signal) {
749
814
  logger.info(`Received ${signal}, shutting down gracefully...`)
750
- stopScheduledTaskRunner()
751
- stopAutoArchiveRunner()
752
- stopVite()
753
- await shutdownAgentManager()
754
- await shutdownMcpConnections()
755
- await shutdownChannels()
756
- shutdownTerminalSessions()
815
+ await stopQuickForgeServer()
757
816
  flushLogger()
758
817
  process.exit(0)
759
818
  }
@@ -215,22 +215,21 @@ async function refreshConnections() {
215
215
  }
216
216
  }
217
217
 
218
- for (const config of enabled.values()) {
218
+ const refreshed = await Promise.all([...enabled.values()].map(async (config) => {
219
219
  const existing = connections.get(config.name)
220
220
  if (existing) {
221
221
  if (existing.status === 'error' && Date.now() - (existing.lastAttemptAt || 0) >= RETRY_ERROR_AFTER_MS) {
222
222
  connections.delete(config.name)
223
223
  await closeConnection(existing)
224
224
  } else {
225
- continue
225
+ return [config.name, existing]
226
226
  }
227
227
  }
228
228
  try {
229
- const connection = await connectServer(config)
230
- connections.set(config.name, connection)
229
+ return [config.name, await connectServer(config)]
231
230
  } catch (error) {
232
231
  logger.error(`Failed to connect MCP server ${config.name}:`, error)
233
- connections.set(config.name, {
232
+ return [config.name, {
234
233
  config,
235
234
  client: null,
236
235
  transport: null,
@@ -240,10 +239,12 @@ async function refreshConnections() {
240
239
  connectedAt: null,
241
240
  stderr: '',
242
241
  lastAttemptAt: Date.now(),
243
- })
242
+ }]
244
243
  }
245
- }
244
+ }))
246
245
 
246
+ connections.clear()
247
+ for (const [name, connection] of refreshed) connections.set(name, connection)
247
248
  return connections
248
249
  }
249
250
 
@@ -21,30 +21,51 @@ export function omitDetailsForLlm(message) {
21
21
  * Handles "user-with-attachments" → "user" with multi-modal content blocks.
22
22
  * Without this the default pi-agent-core convertToLlm silently drops
23
23
  * user-with-attachments messages, so the LLM never sees attachments.
24
+ *
25
+ * Also guards against orphaned toolResult messages (a tool result without a
26
+ * preceding assistant tool_call): OpenAI-compatible APIs reject such sequences
27
+ * with 400 "Messages with role 'tool' must be a response to a preceding
28
+ * message with 'tool_calls'". Assistant messages that pi-ai would skip
29
+ * (stopReason error/aborted) are not counted as valid tool_call ancestors,
30
+ * matching the upstream skip logic so their results are dropped too.
24
31
  */
25
32
  export function serverConvertToLlm(messages) {
26
- return messages
27
- .filter(m => m.role !== 'artifact')
28
- .map(m => {
29
- if (m.role === 'user-with-attachments') {
30
- const textContent = typeof m.content === 'string'
31
- ? [{ type: 'text', text: m.content }]
32
- : [...m.content]
33
- if (Array.isArray(m.attachments)) {
34
- for (const att of m.attachments) {
35
- if (att.type === 'image' && att.content) {
36
- textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
37
- } else if (att.type === 'document' && att.extractedText) {
38
- textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
39
- }
33
+ const pendingToolCallIds = new Set()
34
+ const converted = []
35
+ for (const m of messages) {
36
+ if (!m || typeof m !== 'object') continue
37
+ if (m.role === 'artifact') continue
38
+ if (m.role === 'user-with-attachments') {
39
+ const textContent = typeof m.content === 'string'
40
+ ? [{ type: 'text', text: m.content }]
41
+ : [...m.content]
42
+ if (Array.isArray(m.attachments)) {
43
+ for (const att of m.attachments) {
44
+ if (att.type === 'image' && att.content) {
45
+ textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
46
+ } else if (att.type === 'document' && att.extractedText) {
47
+ textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
40
48
  }
41
49
  }
42
- return omitDetailsForLlm({ ...m, role: 'user', content: textContent })
43
50
  }
44
- if (m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') return omitDetailsForLlm(m)
45
- return null
46
- })
47
- .filter(Boolean)
51
+ converted.push(omitDetailsForLlm({ ...m, role: 'user', content: textContent }))
52
+ } else if (m.role === 'user') {
53
+ converted.push(omitDetailsForLlm(m))
54
+ } else if (m.role === 'assistant') {
55
+ if (m.stopReason !== 'error' && m.stopReason !== 'aborted' && Array.isArray(m.content)) {
56
+ for (const block of m.content) {
57
+ if (block?.type === 'toolCall' && block.id) pendingToolCallIds.add(block.id)
58
+ }
59
+ }
60
+ converted.push(omitDetailsForLlm(m))
61
+ } else if (m.role === 'toolResult') {
62
+ // 孤儿 toolResult(无前导 assistant tool_calls)直接丢弃,避免上游 400
63
+ if (!m.toolCallId || !pendingToolCallIds.has(m.toolCallId)) continue
64
+ pendingToolCallIds.delete(m.toolCallId)
65
+ converted.push(omitDetailsForLlm(m))
66
+ }
67
+ }
68
+ return converted
48
69
  }
49
70
 
50
71
  /**
@@ -1,7 +1,7 @@
1
+ import { isAuthenticatedAppClient } from './access-policy.mjs'
1
2
  import { getCloudRuntime } from './cloud/runtime.mjs'
2
3
  import { isManagedCloudModel } from './cloud/models.mjs'
3
4
  import { readStore } from './storage.mjs'
4
- import { isTailscaleAddress } from './utils/network.mjs'
5
5
 
6
6
  export const MODEL_REFERENCE_VERSION = 1
7
7
 
@@ -57,10 +57,10 @@ function configuredEntries(providers) {
57
57
  }
58
58
 
59
59
  export function cloudAllowedForContext(context = {}) {
60
+ if (context.source === 'shared') return context.allowCloud === true
60
61
  if (context.allowCloud === false) return false
61
62
  if (context.allowCloud === true || context.source === 'acp' || context.source === 'scheduled') return true
62
- if (context.isLocalRequest !== false) return true
63
- return context.remoteAuthorized === true && isTailscaleAddress(context.remoteAddress)
63
+ return isAuthenticatedAppClient(context)
64
64
  }
65
65
 
66
66
  export function modelReferenceFromSnapshot(model, providers = []) {
@@ -176,7 +176,14 @@ async function resolveCloud(ref, context) {
176
176
  throw requestError('QuickForge Cloud is not available from this client.', 403, 'cloud_access_denied')
177
177
  }
178
178
  const runtime = await getCloudRuntime()
179
- if (!runtime?.enabled) throw requestError('QuickForge Cloud is not configured.', 503, 'cloud_not_configured')
179
+ if (!runtime?.enabled) {
180
+ const disabled = runtime?.config?.baseUrl && runtime?.config?.enabled !== true
181
+ throw requestError(
182
+ disabled ? 'QuickForge Cloud is disabled.' : 'QuickForge Cloud is not configured.',
183
+ 503,
184
+ disabled ? 'cloud_disabled' : 'cloud_not_configured',
185
+ )
186
+ }
180
187
  return (await runtime.models.resolve({
181
188
  provider: 'quickforge-cloud',
182
189
  quickforgeModelSource: 'cloud',