@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.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/index-D-J_8Smf.css +3 -0
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/auto-compaction.mjs +43 -1
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/config.mjs +8 -16
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/context-usage.mjs +11 -4
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
- package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
- package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
- package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
- package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
- package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
- package/dist/assets/index-1ei6nhs-.css +0 -3
- package/dist/assets/index-BivLuZvX.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
package/server/auto-archive.mjs
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
+
}
|
package/server/cloud/client.mjs
CHANGED
|
@@ -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,
|
package/server/cloud/config.mjs
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import net from 'node:net'
|
|
2
|
-
|
|
3
1
|
const DEFAULT_TIMEOUT_MS = 10_000
|
|
4
2
|
|
|
5
|
-
function
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 = '
|
|
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]
|