@shawnstack/quickforge 1.7.6 → 1.7.8

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 (87) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/index-D-J_8Smf.css +3 -0
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/auto-compaction.mjs +43 -1
  29. package/server/cloud/auto-approval.mjs +155 -0
  30. package/server/cloud/client.mjs +5 -0
  31. package/server/cloud/config.mjs +8 -16
  32. package/server/cloud/qf-agent-process.mjs +163 -3
  33. package/server/cloud/service-config.mjs +1 -1
  34. package/server/context-usage.mjs +11 -4
  35. package/server/index.mjs +69 -13
  36. package/server/lan-access-backup.mjs +256 -0
  37. package/server/lan-access-cutover.mjs +306 -0
  38. package/server/lan-access-json-file.mjs +75 -0
  39. package/server/lan-access-service.mjs +155 -0
  40. package/server/lan-access-store.mjs +110 -9
  41. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  42. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  43. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  44. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  45. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  46. package/server/maintenance/export-session-state-v1.mjs +67 -0
  47. package/server/maintenance/export-share-v1.mjs +77 -0
  48. package/server/routes/agent.mjs +37 -4
  49. package/server/routes/backup.mjs +221 -39
  50. package/server/routes/cloud.mjs +28 -1
  51. package/server/routes/scheduled-tasks.mjs +347 -134
  52. package/server/routes/shares.mjs +4 -2
  53. package/server/routes/storage.mjs +138 -8
  54. package/server/routes/workspace.mjs +265 -4
  55. package/server/scheduled-runs-backup.mjs +235 -0
  56. package/server/scheduled-runs-cutover.mjs +497 -0
  57. package/server/scheduled-task-runs-service.mjs +279 -0
  58. package/server/session-index-service.mjs +495 -0
  59. package/server/session-state-backup.mjs +277 -0
  60. package/server/session-state-cutover.mjs +370 -0
  61. package/server/session-state-service.mjs +709 -0
  62. package/server/share-backup.mjs +255 -0
  63. package/server/share-cutover.mjs +296 -0
  64. package/server/share-json-file.mjs +60 -0
  65. package/server/share-service.mjs +163 -0
  66. package/server/share-store.mjs +196 -19
  67. package/server/skills.mjs +0 -1
  68. package/server/sqlite/database.mjs +233 -0
  69. package/server/sqlite/lan-access-repository.mjs +652 -0
  70. package/server/sqlite/migrations.mjs +577 -0
  71. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  72. package/server/sqlite/session-index-repository.mjs +297 -0
  73. package/server/sqlite/session-state-repository.mjs +786 -0
  74. package/server/sqlite/share-repository.mjs +854 -0
  75. package/server/storage.mjs +317 -1
  76. package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
  77. package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
  78. package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
  79. package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
  80. package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
  81. package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
  82. package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
  83. package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
  84. package/dist/assets/index-1ei6nhs-.css +0 -3
  85. package/dist/assets/index-BivLuZvX.js +0 -65
  86. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  87. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -1,8 +1,8 @@
1
- import { atomicSessionValueUpdate, atomicUpdate, readSessionValue, readStore } from './storage.mjs'
1
+ import { atomicSessionValueUpdate, atomicUpdate, atomicSessionRecordUpdate, isSessionStateAuthoritative, readSessionValue, readStore } from './storage.mjs'
2
2
  import { logger } from './utils/logger.mjs'
3
3
  import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
4
4
 
5
- const defaultStorage = { atomicSessionValueUpdate, atomicUpdate, readSessionValue, readStore }
5
+ const defaultStorage = { atomicSessionValueUpdate, atomicUpdate, atomicSessionRecordUpdate, readSessionValue, readStore, isSessionStateAuthoritative }
6
6
 
7
7
  export const AUTO_ARCHIVE_SETTINGS_KEY = 'auto-archive-settings'
8
8
  export const AUTO_ARCHIVE_AFTER_MS = 30 * 24 * 60 * 60 * 1000
@@ -75,35 +75,48 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
75
75
  }
76
76
 
77
77
  const archivedSessionIds = []
78
- for (const sessionId of candidates) {
79
- const updatedSession = await storage.atomicSessionValueUpdate(sessionId, (session) => {
80
- const activityTime = sessionActivityTime(session)
81
- if (session.archivedAt || activityTime === null || activityTime >= cutoff) return session
82
- return { ...session, archivedAt }
83
- })
84
- if (!updatedSession || updatedSession.archivedAt !== archivedAt) continue
85
-
86
- let metadataArchived = false
87
- await storage.atomicUpdate('sessions-metadata', (current) => {
88
- const metadata = current[sessionId]
89
- const activityTime = sessionActivityTime(metadata, updatedSession)
90
- if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) {
78
+ if (typeof storage.atomicSessionRecordUpdate === 'function' && await storage.isSessionStateAuthoritative()) {
79
+ // Authoritative: archive body + metadata in one SQLite transaction (CAS
80
+ // retried on concurrent metadata-only changes), so no half-archived state.
81
+ for (const sessionId of candidates) {
82
+ const updated = await storage.atomicSessionRecordUpdate(sessionId, ({ state, metadata }) => {
83
+ const activityTime = sessionActivityTime(metadata, state)
84
+ if (state.archivedAt || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) return null
85
+ return { state: { ...state, archivedAt }, metadata: { ...metadata, archivedAt } }
86
+ })
87
+ if (updated?.state?.archivedAt === archivedAt) archivedSessionIds.push(sessionId)
88
+ }
89
+ } else {
90
+ for (const sessionId of candidates) {
91
+ const updatedSession = await storage.atomicSessionValueUpdate(sessionId, (session) => {
92
+ const activityTime = sessionActivityTime(session)
93
+ if (session.archivedAt || activityTime === null || activityTime >= cutoff) return session
94
+ return { ...session, archivedAt }
95
+ })
96
+ if (!updatedSession || updatedSession.archivedAt !== archivedAt) continue
97
+
98
+ let metadataArchived = false
99
+ await storage.atomicUpdate('sessions-metadata', (current) => {
100
+ const metadata = current[sessionId]
101
+ const activityTime = sessionActivityTime(metadata, updatedSession)
102
+ if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) {
103
+ return current
104
+ }
105
+ current[sessionId] = { ...metadata, archivedAt }
106
+ metadataArchived = true
91
107
  return current
92
- }
93
- current[sessionId] = { ...metadata, archivedAt }
94
- metadataArchived = true
95
- return current
96
- })
97
-
98
- if (metadataArchived) {
99
- archivedSessionIds.push(sessionId)
100
- } else {
101
- await storage.atomicSessionValueUpdate(sessionId, (session) => {
102
- if (session.archivedAt !== archivedAt) return session
103
- const next = { ...session }
104
- delete next.archivedAt
105
- return next
106
108
  })
109
+
110
+ if (metadataArchived) {
111
+ archivedSessionIds.push(sessionId)
112
+ } else {
113
+ await storage.atomicSessionValueUpdate(sessionId, (session) => {
114
+ if (session.archivedAt !== archivedAt) return session
115
+ const next = { ...session }
116
+ delete next.archivedAt
117
+ return next
118
+ })
119
+ }
107
120
  }
108
121
  }
109
122
 
@@ -175,6 +175,46 @@ export function buildAutoCompactLoopMessages(session, messages) {
175
175
  return [summaryMessage, ...source.slice(tailStart)]
176
176
  }
177
177
 
178
+ function messageTimestampMs(message) {
179
+ const value = message?.timestamp
180
+ if (typeof value === 'number' && Number.isFinite(value)) return value
181
+ if (typeof value === 'string') {
182
+ const parsed = Date.parse(value)
183
+ if (Number.isFinite(parsed)) return parsed
184
+ }
185
+ return 0
186
+ }
187
+
188
+ function minimumProviderUsageIndex(session, messages) {
189
+ if (!session?.contextCompaction?.summaryMessage) return 0
190
+ const source = Array.isArray(messages) ? messages : []
191
+ const compactedUpToIndex = Math.min(source.length, Math.max(0, Number(session.contextCompaction.compactedUpToIndex) || 0))
192
+ let tailStart = compactedUpToIndex
193
+ while (tailStart < source.length && isToolResultMessage(source[tailStart])) tailStart += 1
194
+
195
+ const compactedAt = Date.parse(session.contextCompaction.compactedAt || '')
196
+ if (Number.isFinite(compactedAt)) {
197
+ const firstNewMessageIndex = source.findIndex((message) => messageTimestampMs(message) > compactedAt)
198
+ if (firstNewMessageIndex >= 0) return Math.max(1, firstNewMessageIndex - tailStart + 1)
199
+ }
200
+
201
+ const sourceMessageCount = Number(session.contextCompaction.sourceMessageCount)
202
+ if (Number.isFinite(sourceMessageCount) && sourceMessageCount > 0 && source.length >= sourceMessageCount) {
203
+ return Math.max(1, sourceMessageCount - tailStart + 1)
204
+ }
205
+ return source.length - tailStart + 1
206
+ }
207
+
208
+ export function hasNewMessagesSinceCompaction(session, messages) {
209
+ if (!session?.contextCompaction?.summaryMessage) return true
210
+ const source = Array.isArray(messages) ? messages : []
211
+ const sourceMessageCount = Number(session.contextCompaction.sourceMessageCount)
212
+ if (Number.isFinite(sourceMessageCount) && sourceMessageCount > 0 && source.length > sourceMessageCount) return true
213
+
214
+ const compactedAt = Date.parse(session.contextCompaction.compactedAt || '')
215
+ return Number.isFinite(compactedAt) && source.some((message) => messageTimestampMs(message) > compactedAt)
216
+ }
217
+
178
218
  export async function compactSessionInPlace({
179
219
  session,
180
220
  messages,
@@ -308,6 +348,7 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
308
348
  messages: loopMessages,
309
349
  tools: session.agent.state.tools,
310
350
  model: session.model,
351
+ minimumProviderUsageIndex: minimumProviderUsageIndex(session, sourceMessages),
311
352
  })
312
353
  value.isCompacted = loopMessages !== sourceMessages
313
354
  value.originalMessageCount = sourceMessages.length
@@ -332,6 +373,7 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
332
373
  messages: loopMessages,
333
374
  tools: session.agent.state.tools,
334
375
  model: session.model,
376
+ minimumProviderUsageIndex: minimumProviderUsageIndex(session, messages),
335
377
  })
336
378
  if (!usage.contextWindow) return { compacted: false, usage, reason: 'missing_context_window' }
337
379
  if (!shouldCompactContextByPercent(usage, settings.thresholdPercent)) return { compacted: false, usage, reason: 'below_threshold' }
@@ -341,7 +383,7 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
341
383
  if (session.lastAutoCompactAt && now - session.lastAutoCompactAt < AUTO_COMPACT_MIN_INTERVAL_MS) {
342
384
  return { compacted: false, usage, reason: 'recently_compacted' }
343
385
  }
344
- if (session.contextCompaction?.sourceMessageCount && messages.length <= session.contextCompaction.sourceMessageCount + 2) {
386
+ if (!hasNewMessagesSinceCompaction(session, messages)) {
345
387
  return { compacted: false, usage, reason: 'not_enough_new_messages' }
346
388
  }
347
389
 
@@ -0,0 +1,155 @@
1
+ // 短时、一次性的远程 Agent 自动批准意图(仅内存态)。
2
+ //
3
+ // 安全边界:
4
+ // - 意图(arm)仅在两种情况下创建:① 本机用户在 Cloud 设置页显式将服务从
5
+ // disabled 切换为 enabled 时由 routes/cloud.mjs 创建;② qf-agent 首次进入
6
+ // authorizing 且当前无有效意图(none/expired)时,由 qf-agent-process 在
7
+ // “本机生命周期启动 + 本机存在有效 desktop 云会话”时自动创建
8
+ // (见 beginAgentAutoApprovalWithDesktopSession)。
9
+ // - 认证远程客户端(非本机请求)触发的云服务开关切换/配置变更以
10
+ // autoApprovalPolicy: 'manual' 启动 agent,该生命周期内(含其自动重启)
11
+ // 不会自动创建意图,也不会自动批准,仍需本机操作。
12
+ // - 意图绝不落盘:不写 agent identity,也不触碰 storage/security/cloud-identity.json。
13
+ // - user_code 只在 Node 内存中流转,用于调用云端 authorize API,不进入公开状态;
14
+ // desktop Access Token 仅由 CloudIdentityManager 内存持有并注入请求,不复制、不暴露。
15
+ // - 一次性:意图只批准第一个捕获的 user_code;成功后即消耗,失败需显式重试。
16
+ import { CloudApiError } from './client.mjs'
17
+ import { getCloudRuntime } from './runtime.mjs'
18
+
19
+ export const AGENT_AUTO_APPROVAL_TTL_MS = 10 * 60_000
20
+
21
+ let intent = null // { expiresAt, status: 'armed'|'pending'|'consumed'|'failed', userCode, error }
22
+ let inflight = null
23
+
24
+ function safeErrorText(value) {
25
+ const text = String(value?.message || value || '').trim()
26
+ if (!text) return null
27
+ return text
28
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
29
+ .replace(/\b(token|secret|password|api[_-]?key|private[_-]?key)\s*[:=]\s*\S+/gi, '$1=[redacted]')
30
+ .replace(/https?:\/\/\S+/gi, '[url]')
31
+ .slice(0, 300)
32
+ }
33
+
34
+ // 返回公开可展示的意图状态;不包含 user_code、token 或身份信息。
35
+ export function getAgentAutoApprovalState({ now = Date.now() } = {}) {
36
+ if (!intent) return { status: 'none' }
37
+ if (Number(intent.expiresAt) <= now) return { status: 'expired' }
38
+ if (intent.status === 'failed') return { status: 'failed', error: intent.error }
39
+ return { status: intent.status }
40
+ }
41
+
42
+ export function armAgentAutoApproval({ ttlMs = AGENT_AUTO_APPROVAL_TTL_MS, now = Date.now() } = {}) {
43
+ intent = {
44
+ expiresAt: now + Math.max(1_000, Number(ttlMs) || AGENT_AUTO_APPROVAL_TTL_MS),
45
+ status: 'armed',
46
+ userCode: null,
47
+ error: null,
48
+ }
49
+ inflight = null
50
+ return getAgentAutoApprovalState({ now })
51
+ }
52
+
53
+ export function clearAgentAutoApproval() {
54
+ intent = null
55
+ inflight = null
56
+ }
57
+
58
+ // 默认执行器:复用桌面端 CloudIdentityManager.withAccessToken 调云端固定接口
59
+ // POST /v1/remote/agents/authorize,Bearer desktop access token,body { userCode }。
60
+ export async function defaultAuthorizeAgent(userCode, { signal } = {}) {
61
+ const runtime = await getCloudRuntime()
62
+ if (!runtime?.identity || !runtime?.client) {
63
+ throw new CloudApiError('QuickForge Cloud is not connected.', { status: 401, code: 'cloud_not_connected' })
64
+ }
65
+ return runtime.identity.withAccessToken((token) => runtime.client.authorizeRemoteAgent(token, userCode, signal), { signal })
66
+ }
67
+
68
+ // qf-agent 进入 authorizing 并捕获 user_code 时调用;意图未 armed/已过期/已处理时跳过。
69
+ // 并发去重:同一意图在途调用合并为同一个 Promise;一次性消费由状态机保证。
70
+ export function beginAgentAutoApproval(userCode, { now = Date.now(), authorize = defaultAuthorizeAgent } = {}) {
71
+ const state = getAgentAutoApprovalState({ now })
72
+ if (state.status === 'none' || state.status === 'expired' || state.status === 'consumed' || state.status === 'failed') {
73
+ return Promise.resolve({ status: state.status, error: state.error })
74
+ }
75
+ if (intent.status === 'armed') {
76
+ const code = String(userCode || '').trim()
77
+ if (!code) return Promise.resolve({ status: 'skipped' })
78
+ intent.status = 'pending'
79
+ intent.userCode = code
80
+ }
81
+ if (inflight) return inflight
82
+ const activeIntent = intent
83
+ inflight = (async () => {
84
+ try {
85
+ const response = await authorize(activeIntent.userCode)
86
+ if (response && typeof response === 'object' && response.ok !== true) {
87
+ throw new CloudApiError('QuickForge Cloud did not confirm the agent authorization.', { code: 'invalid_authorize_response' })
88
+ }
89
+ if (intent === activeIntent) activeIntent.status = 'consumed'
90
+ return { status: 'consumed' }
91
+ } catch (error) {
92
+ const errorText = safeErrorText(error)
93
+ if (intent === activeIntent) {
94
+ activeIntent.status = 'failed'
95
+ activeIntent.error = errorText
96
+ }
97
+ return { status: 'failed', error: errorText }
98
+ } finally {
99
+ if (intent === activeIntent) inflight = null
100
+ }
101
+ })()
102
+ return inflight
103
+ }
104
+
105
+ // 仅允许对 failed 意图重试(保留原 user_code,用户无需打开授权页/输入码)。
106
+ export function retryAgentAutoApproval({ now = Date.now(), authorize = defaultAuthorizeAgent } = {}) {
107
+ if (!intent || intent.status !== 'failed') {
108
+ return Promise.resolve(getAgentAutoApprovalState({ now }))
109
+ }
110
+ intent.status = 'pending'
111
+ return beginAgentAutoApproval(intent.userCode, { now, authorize })
112
+ }
113
+
114
+ // 默认 desktop 会话检查:runtime 已配置 identity,且本地凭据公开状态含有效
115
+ // Session(hasSession)并未绑定到其他 Cloud URL(无 sessionServiceMismatch)。
116
+ // 只读公开状态,不触发 refresh,也不发起网络请求。
117
+ async function defaultHasDesktopSession() {
118
+ try {
119
+ const runtime = await getCloudRuntime()
120
+ if (!runtime?.identity) return false
121
+ const status = await runtime.identity.status()
122
+ return Boolean(status?.hasSession) && status?.sessionServiceMismatch !== true
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ // qf-agent 首次进入 authorizing 且无有效意图(begin 返回 none/expired)时调用:
129
+ // 由本机生命周期启动(policy !== 'manual')且存在有效 desktop 云会话时,自动
130
+ // 创建同样短时、一次性的意图并立即尝试批准;否则保持原有状态。已有
131
+ // armed/pending/consumed/failed 意图时直接返回现有结果,不重复 arm。
132
+ export async function beginAgentAutoApprovalWithDesktopSession(userCode, {
133
+ policy = 'auto',
134
+ now = Date.now(),
135
+ authorize = defaultAuthorizeAgent,
136
+ hasDesktopSession = defaultHasDesktopSession,
137
+ } = {}) {
138
+ const first = await beginAgentAutoApproval(userCode, { now, authorize })
139
+ if (first.status !== 'none' && first.status !== 'expired') return first
140
+ if (policy === 'manual') return first
141
+ let eligible = false
142
+ try {
143
+ eligible = (await hasDesktopSession()) === true
144
+ } catch {
145
+ // 会话检查失败视为无有效 desktop 会话,不自动 arm
146
+ }
147
+ if (!eligible) return first
148
+ armAgentAutoApproval({ now })
149
+ return beginAgentAutoApproval(userCode, { now, authorize })
150
+ }
151
+
152
+ export function resetAgentAutoApprovalForTests() {
153
+ intent = null
154
+ inflight = null
155
+ }
@@ -90,6 +90,11 @@ export class CloudClient {
90
90
  revokeInstallation(token, installationId, signal) {
91
91
  return this.request(`v1/installations/${encodeURIComponent(installationId)}`, { method: 'DELETE', token, signal })
92
92
  }
93
+ authorizeRemoteAgent(token, userCode, signal) {
94
+ return this.request('v1/remote/agents/authorize', {
95
+ method: 'POST', token, body: { userCode }, signal,
96
+ })
97
+ }
93
98
  chat(token, payload, idempotencyKey, signal) {
94
99
  return this.request('v1/chat/completions', {
95
100
  method: 'POST', token, body: payload,
@@ -1,14 +1,9 @@
1
- import net from 'node:net'
2
-
3
1
  const DEFAULT_TIMEOUT_MS = 10_000
4
2
 
5
- function isLoopbackHost(hostname) {
6
- const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
7
- if (host === 'localhost' || host.endsWith('.localhost')) return true
8
- const ip = net.isIP(host)
9
- if (ip === 4) return host.startsWith('127.')
10
- if (ip === 6) return host === '::1' || host === '0:0:0:0:0:0:0:1'
11
- return false
3
+ function invalidUrl(message) {
4
+ const error = new Error(message)
5
+ error.statusCode = 400
6
+ return error
12
7
  }
13
8
 
14
9
  export function parseCloudBaseUrl(value, _options = {}) {
@@ -19,19 +14,16 @@ export function parseCloudBaseUrl(value, _options = {}) {
19
14
  try {
20
15
  url = new URL(raw)
21
16
  } catch {
22
- throw new Error('QUICKFORGE_CLOUD_URL must be a valid URL.')
17
+ throw invalidUrl('QuickForge Cloud URL must be a valid URL.')
23
18
  }
24
19
 
25
20
  if (url.username || url.password || url.search || url.hash) {
26
- throw new Error('QUICKFORGE_CLOUD_URL must not contain credentials, query parameters, or fragments.')
21
+ throw invalidUrl('QuickForge Cloud URL must not contain credentials, query parameters, or fragments.')
27
22
  }
28
23
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
29
- throw new Error('QUICKFORGE_CLOUD_URL must use http or https.')
30
- }
31
- if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
32
- throw new Error('QUICKFORGE_CLOUD_URL over HTTP is only allowed for loopback hosts.')
24
+ throw invalidUrl('QuickForge Cloud URL must use http or https.')
33
25
  }
34
- if (url.pathname.includes('..')) throw new Error('QUICKFORGE_CLOUD_URL contains an unsafe path.')
26
+ if (url.pathname.includes('..')) throw invalidUrl('QuickForge Cloud URL contains an unsafe path.')
35
27
  url.pathname = `${url.pathname.replace(/\/+$/, '')}/`
36
28
  return url
37
29
  }
@@ -3,6 +3,8 @@ import { chmodSync, existsSync, promises as fs, statSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { dataDir } from '../storage.mjs'
6
+ import { getNetworkProxyConfig } from '../network-proxy.mjs'
7
+ import { beginAgentAutoApprovalWithDesktopSession, clearAgentAutoApproval, getAgentAutoApprovalState } from './auto-approval.mjs'
6
8
 
7
9
  const __filename = fileURLToPath(import.meta.url)
8
10
  const __dirname = path.dirname(__filename)
@@ -26,6 +28,7 @@ let stopPromise = null
26
28
  let lifecycleGeneration = 0
27
29
  let versionCheckChild = null
28
30
  let launchOptions = null
31
+ let identityInvalidation = null
29
32
  let status = createStatus()
30
33
 
31
34
  function createStatus(overrides = {}) {
@@ -239,6 +242,27 @@ function safeVerificationUrl(value) {
239
242
  }
240
243
  }
241
244
 
245
+ // 从 qf-agent 结构化日志安全提取一次性授权 user_code:优先 record.userCode/user_code,
246
+ // 否则从 verificationUriComplete(兼容 verificationUri)的 query 参数提取。
247
+ // 仅用于服务端自动批准流程,绝不进入公开 qf-agent 状态。
248
+ export function extractUserCodeFromVerification(record) {
249
+ if (!record || typeof record !== 'object') return null
250
+ const direct = record.userCode ?? record.user_code
251
+ if (typeof direct === 'string' && direct.trim()) return direct.trim().slice(0, 64)
252
+ const raw = record.verificationUriComplete ?? record.verificationUri
253
+ if (typeof raw !== 'string' || !raw.trim()) return null
254
+ try {
255
+ const url = new URL(raw)
256
+ for (const key of ['user_code', 'userCode', 'code']) {
257
+ const value = url.searchParams.get(key)
258
+ if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 64)
259
+ }
260
+ } catch {
261
+ // 非 URL 视为无效,不自动批准
262
+ }
263
+ return null
264
+ }
265
+
242
266
  export function parseQfAgentLogLine(line) {
243
267
  const text = String(line || '').trim()
244
268
  if (!text) return null
@@ -255,6 +279,8 @@ export function parseQfAgentLogLine(line) {
255
279
  if (message.includes('等待设备授权')) {
256
280
  result.status = 'authorizing'
257
281
  result.verificationUriComplete = safeVerificationUrl(record.verificationUriComplete)
282
+ const userCode = extractUserCodeFromVerification(record)
283
+ if (userCode) result.userCode = userCode
258
284
  } else if (message.includes('信令已连接')) {
259
285
  result.status = 'running'
260
286
  result.verificationUriComplete = null
@@ -264,9 +290,112 @@ export function parseQfAgentLogLine(line) {
264
290
 
265
291
  const level = String(record.level || record.severity || '').toLowerCase()
266
292
  if (['error', 'fatal', 'panic'].includes(level)) result.error = safeErrorMessage(message) || 'qf-agent reported an error.'
293
+ if (isIdentityInvalidationRecord(record)) result.identityInvalidated = true
267
294
  return Object.keys(result).length ? result : null
268
295
  }
269
296
 
297
+ const IDENTITY_INVALIDATION_MARKERS = [
298
+ 'invalid_refresh_token',
299
+ 'refresh_token_reused',
300
+ 'installation_revoked',
301
+ 'refresh token 已失效',
302
+ ]
303
+
304
+ // 仅在结构化 warn/error/fatal/panic 日志中识别身份失效错误码与既有终态提示;
305
+ // 普通文本、info 日志或无关 warning/error 不会触发。
306
+ export function isIdentityInvalidationRecord(record) {
307
+ if (!record || typeof record !== 'object') return false
308
+ const level = String(record.level || record.severity || '').toLowerCase()
309
+ if (!['warn', 'error', 'fatal', 'panic'].includes(level)) return false
310
+ const fields = [record.msg, record.message, record.error, record.err]
311
+ if (!fields.some((value) => typeof value === 'string')) return false
312
+ const haystack = fields.filter((value) => typeof value === 'string').join('\n')
313
+ return IDENTITY_INVALIDATION_MARKERS.some((marker) => haystack.includes(marker))
314
+ }
315
+
316
+ // 仅当 QuickForge 配置为 manual 且 proxyUrl 非空时返回注入给 qf-agent 的代理 env;
317
+ // NO_PROXY 保留父进程已有条目并至少加入回环地址与 serverUrl 主机名。
318
+ export function buildQfAgentProxyEnv(serverUrl, config, env = process.env) {
319
+ if (!config || config.mode !== 'manual' || !config.proxyUrl) return null
320
+ let hostname = null
321
+ try {
322
+ hostname = new URL(serverUrl).hostname
323
+ } catch {
324
+ // serverUrl 非法时保持 null,不向 NO_PROXY 追加主机名
325
+ }
326
+ const existing = new Set()
327
+ for (const key of ['NO_PROXY', 'no_proxy']) {
328
+ const value = typeof env?.[key] === 'string' ? env[key] : ''
329
+ for (const item of value.split(',')) {
330
+ const trimmed = item.trim()
331
+ if (trimmed) existing.add(trimmed)
332
+ }
333
+ }
334
+ const parts = ['localhost', '127.0.0.1', '::1']
335
+ if (hostname) parts.push(hostname)
336
+ for (const item of existing) {
337
+ if (!parts.includes(item)) parts.push(item)
338
+ }
339
+ return {
340
+ HTTP_PROXY: config.proxyUrl,
341
+ HTTPS_PROXY: config.proxyUrl,
342
+ ALL_PROXY: config.proxyUrl,
343
+ NO_PROXY: parts.join(','),
344
+ }
345
+ }
346
+
347
+ async function resolveProxyEnvironment(serverUrl) {
348
+ try {
349
+ const { config } = await getNetworkProxyConfig()
350
+ return buildQfAgentProxyEnv(serverUrl, config, process.env)
351
+ } catch {
352
+ return null
353
+ }
354
+ }
355
+
356
+ // 仅清理当前 runtime 专属 identityDir/identity.json,绝不触碰
357
+ // storage/security/cloud-identity.json。优先 rename 到同目录临时 invalid 文件并尽快删除;
358
+ // Windows EPERM 安全 fallback unlink;ENOENT 幂等;只处理普通文件,不跟随 symlink。
359
+ async function isolateInvalidIdentity(identityDir) {
360
+ const file = path.join(identityDir, 'identity.json')
361
+ let stat
362
+ try {
363
+ stat = await fs.lstat(file)
364
+ } catch (error) {
365
+ if (error?.code === 'ENOENT') return
366
+ throw error
367
+ }
368
+ if (!stat.isFile()) return
369
+ const invalidFile = `${file}.invalid-${process.pid}-${Date.now()}`
370
+ try {
371
+ await fs.rename(file, invalidFile)
372
+ } catch (error) {
373
+ if (error?.code === 'ENOENT') return
374
+ if (error?.code !== 'EPERM') throw error
375
+ try {
376
+ await fs.unlink(file)
377
+ } catch (unlinkError) {
378
+ if (unlinkError?.code !== 'ENOENT') throw unlinkError
379
+ }
380
+ return
381
+ }
382
+ try {
383
+ await fs.unlink(invalidFile)
384
+ } catch (error) {
385
+ if (error?.code !== 'ENOENT') throw error
386
+ }
387
+ }
388
+
389
+ // 命中身份失效后每 lifecycle 至多一次:标记身份重置、设置可操作状态并主动终止子进程。
390
+ function handleIdentityInvalidation(activeChild, generation, identityDir) {
391
+ if (!isCurrentLifecycle(generation) || identityInvalidation?.generation === generation) return
392
+ identityInvalidation = { generation, identityDir }
393
+ setStatus({ status: 'authorizing', verificationUriComplete: null, error: null })
394
+ if (activeChild && child === activeChild) {
395
+ void terminateChild(activeChild).catch(() => {})
396
+ }
397
+ }
398
+
270
399
  function consumeLines(stream, onLine) {
271
400
  let buffered = ''
272
401
  stream?.setEncoding('utf8')
@@ -410,6 +539,7 @@ async function launchQfAgent(generation) {
410
539
 
411
540
  let spawned
412
541
  try {
542
+ const proxyEnv = await resolveProxyEnvironment(serverUrl)
413
543
  spawned = spawn(executable, [], {
414
544
  windowsHide: true,
415
545
  shell: false,
@@ -420,6 +550,7 @@ async function launchQfAgent(generation) {
420
550
  QF_CLOUD_AGENT_DATA_DIR: identityDir,
421
551
  QF_CLOUD_AGENT_QF_URL: serverUrl,
422
552
  QF_CLOUD_AGENT_OWNER_PID: String(ownerPid),
553
+ ...(proxyEnv || {}),
423
554
  },
424
555
  })
425
556
  await new Promise((resolve, reject) => {
@@ -439,11 +570,24 @@ async function launchQfAgent(generation) {
439
570
  child = null
440
571
  childStartedAt = 0
441
572
  if (stableRun) restartCount = 0
442
- void clearOwnLock().then(() => {
573
+ const invalid = identityInvalidation?.generation === generation ? identityInvalidation : null
574
+ if (invalid) identityInvalidation = null
575
+ void clearOwnLock().then(async () => {
443
576
  if (!isCurrentLifecycle(generation)) {
444
577
  setStatus({ status: 'stopped', pid: null, verificationUriComplete: null })
445
578
  return
446
579
  }
580
+ if (invalid) {
581
+ try {
582
+ await isolateInvalidIdentity(invalid.identityDir)
583
+ } catch {
584
+ // 隔离失败:保留重启计数,由 MAX_CONSECUTIVE_RESTARTS 兜底约束重试
585
+ }
586
+ if (!isCurrentLifecycle(generation)) return
587
+ // 无论隔离是否成功都不重置 restartCount:若新身份仍被云端拒绝,
588
+ // 重启预算逐次消耗至 MAX_CONSECUTIVE_RESTARTS 停止,避免隔离清零导致无界循环;
589
+ // 稳定运行(STABLE_RUN_MS)仍是唯一自然重置边界。
590
+ }
447
591
  scheduleRestart(generation)
448
592
  }).catch(() => {
449
593
  if (isCurrentLifecycle(generation)) scheduleRestart(generation)
@@ -453,7 +597,15 @@ async function launchQfAgent(generation) {
453
597
  const onLine = (line) => {
454
598
  if (!isCurrentLifecycle(generation) || child !== spawned) return
455
599
  const update = parseQfAgentLogLine(line)
456
- if (update) setStatus(update)
600
+ if (update) {
601
+ const { identityInvalidated, userCode, ...statusUpdate } = update
602
+ if (Object.keys(statusUpdate).length > 0) setStatus(statusUpdate)
603
+ if (identityInvalidated) handleIdentityInvalidation(spawned, generation, identityDir)
604
+ // userCode 仅供服务端自动批准使用,绝不进入公开状态。无有效意图(none/expired)
605
+ // 时由本机 desktop 云会话自动 arm+代批;'manual'(认证远程客户端触发的生命周期)
606
+ // 不自动批准。
607
+ if (userCode) void beginAgentAutoApprovalWithDesktopSession(userCode, { policy: launchOptions?.autoApprovalPolicy }).catch(() => {})
608
+ }
457
609
  }
458
610
  consumeLines(spawned.stdout, onLine)
459
611
  consumeLines(spawned.stderr, onLine)
@@ -487,7 +639,7 @@ function beginLaunch(generation) {
487
639
  return pending
488
640
  }
489
641
 
490
- export async function startQfAgent({ serverUrl, ownerPid, cloudUrl }) {
642
+ export async function startQfAgent({ serverUrl, ownerPid, cloudUrl, autoApprovalPolicy }) {
491
643
  if (process.env.QUICKFORGE_QF_AGENT_ENABLED === '0') {
492
644
  setStatus({ enabled: false, status: 'disabled', serverUrl: null, pid: null, verificationUriComplete: null, error: null })
493
645
  return getQfAgentStatus()
@@ -500,6 +652,10 @@ export async function startQfAgent({ serverUrl, ownerPid, cloudUrl }) {
500
652
  serverUrl: new URL(serverUrl).href,
501
653
  ownerPid: Number(ownerPid),
502
654
  cloudUrl: new URL(cloudUrl).href,
655
+ // 'manual':由认证远程客户端触发的配置变更启动,禁止自动创建批准意图;
656
+ // 默认 'auto':本机生命周期事件(启动恢复、本机 URL 切换、普通/隔离重启)
657
+ // 允许在首次 authorizing 时依据本机 desktop 会话自动 arm。
658
+ autoApprovalPolicy: autoApprovalPolicy === 'manual' ? 'manual' : 'auto',
503
659
  }
504
660
  if (!Number.isInteger(options.ownerPid) || options.ownerPid <= 0) {
505
661
  setStatus({ status: 'error', error: 'qf-agent owner PID is invalid.' })
@@ -511,6 +667,7 @@ export async function startQfAgent({ serverUrl, ownerPid, cloudUrl }) {
511
667
  stopRequested = false
512
668
  stopPromise = null
513
669
  restartCount = 0
670
+ identityInvalidation = null
514
671
  setStatus({ enabled: true, error: null })
515
672
  try {
516
673
  return await beginLaunch(generation)
@@ -523,6 +680,7 @@ export async function startQfAgent({ serverUrl, ownerPid, cloudUrl }) {
523
680
  }
524
681
 
525
682
  export async function stopQfAgent({ disabled = false } = {}) {
683
+ if (disabled) clearAgentAutoApproval()
526
684
  if (stopPromise) {
527
685
  const result = await stopPromise
528
686
  if (disabled && result.status !== 'disabled') {
@@ -534,6 +692,7 @@ export async function stopQfAgent({ disabled = false } = {}) {
534
692
  stopRequested = true
535
693
  lifecycleGeneration += 1
536
694
  launchOptions = null
695
+ identityInvalidation = null
537
696
  if (restartTimer) {
538
697
  clearTimeout(restartTimer)
539
698
  restartTimer = null
@@ -566,6 +725,7 @@ export function getQfAgentStatus() {
566
725
  pid: status.pid,
567
726
  verificationUriComplete: status.verificationUriComplete,
568
727
  error: status.error,
728
+ autoApproval: getAgentAutoApprovalState(),
569
729
  updatedAt: status.updatedAt,
570
730
  }
571
731
  }
@@ -4,7 +4,7 @@ import { parseCloudBaseUrl } from './config.mjs'
4
4
  export const CLOUD_SERVICE_SETTINGS_KEY = 'quickforge-cloud-service'
5
5
  export const CLOUD_SERVICE_SCHEMA_VERSION = 1
6
6
  export const CLOUD_SERVICE_TYPE = 'quickforge-cloud'
7
- export const DEFAULT_CLOUD_URL = 'http://127.0.0.1:8082/'
7
+ export const DEFAULT_CLOUD_URL = 'https://qf.shawnstack.com/'
8
8
 
9
9
  function savedServiceRecord(settings) {
10
10
  const value = settings?.[CLOUD_SERVICE_SETTINGS_KEY]