@shawnstack/quickforge 1.2.4 → 1.2.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 (47) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/{anthropic-BiTRBcug.js → anthropic-g5RJMS9O.js} +1 -1
  3. package/dist/assets/{azure-openai-responses-MuqXmHEI.js → azure-openai-responses-a7afzGwC.js} +1 -1
  4. package/dist/assets/{google-Bp1Z3Se-.js → google-DZsPNex3.js} +1 -1
  5. package/dist/assets/{google-gemini-cli-6iC3v7Mu.js → google-gemini-cli-DnAdZ-9e.js} +1 -1
  6. package/dist/assets/{google-vertex-DzmUuLVp.js → google-vertex-BDuS0bO0.js} +1 -1
  7. package/dist/assets/{icons-BzvJv-Bv.js → icons-DjhMV6OE.js} +1 -1
  8. package/dist/assets/index-B094j8RZ.css +3 -0
  9. package/dist/assets/{index-VdWqU8e1.js → index-CC71Wiy2.js} +660 -487
  10. package/dist/assets/{mistral-BSlX93lo.js → mistral-CuUpINR3.js} +1 -1
  11. package/dist/assets/{openai-codex-responses-CvN5Iwy4.js → openai-codex-responses-DchchAd8.js} +1 -1
  12. package/dist/assets/{openai-completions-CteXgyGA.js → openai-completions-CV15qkLX.js} +1 -1
  13. package/dist/assets/{openai-responses-Chg1ZGwU.js → openai-responses-BYlHDVWf.js} +1 -1
  14. package/dist/assets/{openai-responses-shared-C8jnJ317.js → openai-responses-shared-CIztTfIF.js} +1 -1
  15. package/dist/assets/{react-vendor-CdZo8gqc.js → react-vendor-BK8yG_FK.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/node_modules/protobufjs/dist/light/protobuf.js +36 -12
  18. package/node_modules/protobufjs/dist/light/protobuf.js.map +1 -1
  19. package/node_modules/protobufjs/dist/light/protobuf.min.js +3 -3
  20. package/node_modules/protobufjs/dist/light/protobuf.min.js.map +1 -1
  21. package/node_modules/protobufjs/dist/minimal/protobuf.js +2 -2
  22. package/node_modules/protobufjs/dist/minimal/protobuf.min.js +2 -2
  23. package/node_modules/protobufjs/dist/protobuf.js +71 -42
  24. package/node_modules/protobufjs/dist/protobuf.js.map +1 -1
  25. package/node_modules/protobufjs/dist/protobuf.min.js +3 -3
  26. package/node_modules/protobufjs/dist/protobuf.min.js.map +1 -1
  27. package/node_modules/protobufjs/index.d.ts +18 -5
  28. package/node_modules/protobufjs/package.json +1 -1
  29. package/node_modules/protobufjs/src/namespace.js +8 -4
  30. package/node_modules/protobufjs/src/parse.js +35 -30
  31. package/node_modules/protobufjs/src/root.js +4 -2
  32. package/node_modules/protobufjs/src/service.js +4 -2
  33. package/node_modules/protobufjs/src/type.js +4 -2
  34. package/node_modules/protobufjs/src/util.js +14 -0
  35. package/node_modules/ws/lib/sender.js +6 -1
  36. package/node_modules/ws/package.json +1 -1
  37. package/package.json +1 -1
  38. package/server/agent-manager.mjs +49 -6
  39. package/server/index.mjs +49 -4
  40. package/server/lan-access-store.mjs +215 -0
  41. package/server/routes/backup.mjs +184 -39
  42. package/server/routes/lan-access.mjs +201 -0
  43. package/server/share-store.mjs +7 -29
  44. package/server/system-prompt.mjs +2 -0
  45. package/server/tools/index.mjs +39 -13
  46. package/server/utils/password-auth.mjs +44 -0
  47. package/dist/assets/index-B9f6WrD6.css +0 -3
@@ -0,0 +1,201 @@
1
+ import { sendJson, readJsonBody } from '../utils/response.mjs'
2
+ import { getLanUrls } from '../utils/network.mjs'
3
+ import { logger } from '../utils/logger.mjs'
4
+ import {
5
+ issueLanAccessToken,
6
+ lanAccessCookieName,
7
+ readLanAccessStatus,
8
+ revokeLanAccessTokens,
9
+ updateLanAccessSettings,
10
+ } from '../lan-access-store.mjs'
11
+
12
+ const MAX_FAILED_ATTEMPTS = 5
13
+ const ATTEMPT_WINDOW_MS = 5 * 60 * 1000
14
+ const LOCK_MS = 5 * 60 * 1000
15
+ const attempts = new Map()
16
+
17
+ function remoteKey(req) {
18
+ return String(req.socket.remoteAddress || 'unknown')
19
+ }
20
+
21
+ function attemptState(req) {
22
+ const key = remoteKey(req)
23
+ const now = Date.now()
24
+ const state = attempts.get(key)
25
+ if (!state || state.resetAt <= now) {
26
+ const fresh = { count: 0, resetAt: now + ATTEMPT_WINDOW_MS, lockedUntil: 0 }
27
+ attempts.set(key, fresh)
28
+ return fresh
29
+ }
30
+ return state
31
+ }
32
+
33
+ function assertNotLocked(req) {
34
+ const state = attemptState(req)
35
+ if (state.lockedUntil > Date.now()) {
36
+ const error = new Error('Too many failed attempts. Please try again later.')
37
+ error.statusCode = 429
38
+ throw error
39
+ }
40
+ }
41
+
42
+ function recordFailure(req) {
43
+ const state = attemptState(req)
44
+ state.count += 1
45
+ if (state.count >= MAX_FAILED_ATTEMPTS) {
46
+ state.lockedUntil = Date.now() + LOCK_MS
47
+ state.count = 0
48
+ state.resetAt = Date.now() + ATTEMPT_WINDOW_MS
49
+ }
50
+ }
51
+
52
+ function clearFailures(req) {
53
+ attempts.delete(remoteKey(req))
54
+ }
55
+
56
+ function setLanCookie(res, token, maxAge) {
57
+ const cookie = [
58
+ `${lanAccessCookieName()}=${encodeURIComponent(token)}`,
59
+ 'HttpOnly',
60
+ 'SameSite=Lax',
61
+ `Max-Age=${Math.max(1, Number(maxAge) || 1)}`,
62
+ 'Path=/',
63
+ ].join('; ')
64
+ res.setHeader('Set-Cookie', cookie)
65
+ }
66
+
67
+ function clearLanCookie(res) {
68
+ res.setHeader('Set-Cookie', `${lanAccessCookieName()}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/`)
69
+ }
70
+
71
+ function requireLocal(context) {
72
+ if (!context.isLocalRequest) {
73
+ const error = new Error('LAN access settings can only be changed from this machine.')
74
+ error.statusCode = 403
75
+ throw error
76
+ }
77
+ }
78
+
79
+ export function renderLanUnlockPage(res) {
80
+ const html = `<!doctype html>
81
+ <html lang="zh-CN">
82
+ <head>
83
+ <meta charset="utf-8" />
84
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
85
+ <title>QuickForge 局域网访问</title>
86
+ <style>
87
+ :root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
88
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e5e7eb; }
89
+ main { width: min(420px, calc(100vw - 32px)); border: 1px solid rgba(148,163,184,.3); border-radius: 20px; padding: 28px; background: rgba(15,23,42,.92); box-shadow: 0 24px 80px rgba(0,0,0,.35); }
90
+ h1 { margin: 0 0 8px; font-size: 22px; }
91
+ p { margin: 0 0 18px; color: #94a3b8; line-height: 1.6; }
92
+ label { display: block; margin-bottom: 8px; font-size: 14px; font-weight: 600; }
93
+ input { width: 100%; box-sizing: border-box; height: 42px; border-radius: 10px; border: 1px solid #334155; background: #020617; color: #f8fafc; padding: 0 12px; font-size: 15px; }
94
+ button { width: 100%; height: 42px; margin-top: 14px; border: 0; border-radius: 10px; background: #2563eb; color: white; font-weight: 700; cursor: pointer; }
95
+ button:disabled { opacity: .6; cursor: default; }
96
+ .error { min-height: 20px; margin-top: 12px; color: #fca5a5; font-size: 13px; }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <main>
101
+ <h1>QuickForge 局域网访问</h1>
102
+ <p>请输入本机设置中配置的局域网访问密码。</p>
103
+ <label for="password">访问密码</label>
104
+ <input id="password" type="password" autocomplete="current-password" autofocus />
105
+ <button id="submit" type="button">进入 QuickForge</button>
106
+ <div id="error" class="error" role="alert"></div>
107
+ </main>
108
+ <script>
109
+ const password = document.getElementById('password')
110
+ const button = document.getElementById('submit')
111
+ const error = document.getElementById('error')
112
+ async function unlock() {
113
+ error.textContent = ''
114
+ button.disabled = true
115
+ try {
116
+ const response = await fetch('/api/lan-access/unlock', {
117
+ method: 'POST',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify({ password: password.value })
120
+ })
121
+ const payload = await response.json().catch(() => null)
122
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : '密码错误')
123
+ window.location.reload()
124
+ } catch (err) {
125
+ error.textContent = err instanceof Error ? err.message : '密码错误'
126
+ } finally {
127
+ button.disabled = false
128
+ }
129
+ }
130
+ button.addEventListener('click', unlock)
131
+ password.addEventListener('keydown', (event) => { if (event.key === 'Enter') unlock() })
132
+ </script>
133
+ </body>
134
+ </html>`
135
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
136
+ res.end(html)
137
+ }
138
+
139
+ export async function handleLanAccessApi(req, res, url, context = {}) {
140
+ const pathname = url.pathname
141
+
142
+ if (req.method === 'GET' && pathname === '/api/lan-access/status') {
143
+ const status = await readLanAccessStatus()
144
+ if (context.isLocalRequest) {
145
+ sendJson(res, 200, { ...status, lanUrls: getLanUrls(context.port) })
146
+ } else {
147
+ sendJson(res, 200, { enabled: status.enabled, requiresPassword: status.enabled && status.hasPassword })
148
+ }
149
+ return
150
+ }
151
+
152
+ if (req.method === 'PUT' && pathname === '/api/lan-access/settings') {
153
+ requireLocal(context)
154
+ const body = await readJsonBody(req)
155
+ const status = await updateLanAccessSettings({
156
+ enabled: Boolean(body?.enabled),
157
+ password: typeof body?.password === 'string' ? body.password : undefined,
158
+ sessionTtlHours: body?.sessionTtlHours,
159
+ })
160
+ logger.info('LAN access settings updated.', { enabled: status.enabled })
161
+ sendJson(res, 200, { ok: true, ...status, lanUrls: getLanUrls(context.port) })
162
+ return
163
+ }
164
+
165
+ if (req.method === 'POST' && pathname === '/api/lan-access/unlock') {
166
+ assertNotLocked(req)
167
+ const body = await readJsonBody(req, 1024)
168
+ try {
169
+ const result = await issueLanAccessToken(body?.password)
170
+ setLanCookie(res, result.token, result.maxAge)
171
+ clearFailures(req)
172
+ logger.info('LAN access unlock succeeded.', { remoteAddress: req.socket.remoteAddress })
173
+ sendJson(res, 200, { ok: true, expiresAt: result.expiresAt })
174
+ } catch (error) {
175
+ if (error?.statusCode === 401) {
176
+ recordFailure(req)
177
+ logger.warn('LAN access unlock failed.', { remoteAddress: req.socket.remoteAddress })
178
+ }
179
+ throw error
180
+ }
181
+ return
182
+ }
183
+
184
+ if (req.method === 'POST' && pathname === '/api/lan-access/logout') {
185
+ clearLanCookie(res)
186
+ sendJson(res, 200, { ok: true })
187
+ return
188
+ }
189
+
190
+ if (req.method === 'POST' && pathname === '/api/lan-access/revoke-all') {
191
+ requireLocal(context)
192
+ const status = await revokeLanAccessTokens()
193
+ logger.info('LAN access tokens revoked.')
194
+ sendJson(res, 200, { ok: true, ...status })
195
+ return
196
+ }
197
+
198
+ const error = new Error('Not found')
199
+ error.statusCode = 404
200
+ throw error
201
+ }
@@ -1,12 +1,9 @@
1
- import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
1
+ import { randomBytes } from 'node:crypto'
2
2
  import { promises as fs } from 'node:fs'
3
3
  import path from 'node:path'
4
- import { promisify } from 'node:util'
5
4
  import { ensureStorage, storageDir } from './storage.mjs'
6
-
7
- const scrypt = promisify(scryptCallback)
5
+ import { hashPassword, safeHashEqual, sha256Base64Url, verifyPassword } from './utils/password-auth.mjs'
8
6
  const SHARE_ID_PREFIX = 'qfs_'
9
- const PASSWORD_VERSION = 1
10
7
  const SHARE_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
11
8
  const MAX_SHARE_TOKENS = 50
12
9
  const SHARES_DIR = path.join(storageDir, 'shares')
@@ -98,32 +95,21 @@ export function generateSharePassword() {
98
95
  return `${randomToken(4).slice(0, 6).toUpperCase()}-${randomToken(4).slice(0, 6).toUpperCase()}`
99
96
  }
100
97
 
101
- export async function hashSharePassword(password, salt = randomToken(16)) {
98
+ export async function hashSharePassword(password, salt) {
102
99
  if (password === undefined || password === null || typeof password !== 'string') return {}
103
100
  if (!password) return { passwordHash: undefined, passwordSalt: undefined, passwordVersion: undefined }
104
-
105
- const derived = await scrypt(password, salt, 32)
106
- return {
107
- passwordHash: derived.toString('base64url'),
108
- passwordSalt: salt,
109
- passwordVersion: PASSWORD_VERSION,
110
- }
101
+ return hashPassword(password, salt)
111
102
  }
112
103
 
113
104
  export async function verifySharePassword(record, password) {
114
105
  if (!record?.passwordHash || !record?.passwordSalt) return !password
115
- if (!password) return false
116
- const { passwordHash } = await hashSharePassword(password, record.passwordSalt)
117
- const expected = Buffer.from(record.passwordHash, 'base64url')
118
- const actual = Buffer.from(passwordHash, 'base64url')
119
- if (expected.length !== actual.length) return false
120
- return timingSafeEqual(expected, actual)
106
+ return verifyPassword(record, password)
121
107
  }
122
108
 
123
109
  export function createShareToken(shareId) {
124
110
  assertSafeShareId(shareId)
125
111
  const secret = randomToken(32)
126
- const secretHash = createHash('sha256').update(secret).digest('base64url')
112
+ const secretHash = sha256Base64Url(secret)
127
113
  return {
128
114
  token: `${shareId}.${secret}`,
129
115
  tokenHash: secretHash,
@@ -140,19 +126,11 @@ function pruneShareTokens(tokens, now = Date.now()) {
140
126
  .slice(-MAX_SHARE_TOKENS)
141
127
  }
142
128
 
143
- function safeHashEqual(expectedHash, actualHash) {
144
- if (!expectedHash || !actualHash) return false
145
- const expected = Buffer.from(expectedHash)
146
- const actual = Buffer.from(actualHash)
147
- if (expected.length !== actual.length) return false
148
- return timingSafeEqual(expected, actual)
149
- }
150
-
151
129
  export function verifyShareToken(record, token) {
152
130
  if (!record || !token || typeof token !== 'string') return false
153
131
  const [tokenShareId, secret] = token.split('.')
154
132
  if (tokenShareId !== record.id || !secret) return false
155
- const actualHash = createHash('sha256').update(secret).digest('base64url')
133
+ const actualHash = sha256Base64Url(secret)
156
134
  const authVersion = record.authVersion || 1
157
135
  const tokenRecords = pruneShareTokens(record.tokens)
158
136
 
@@ -6,6 +6,8 @@ For project tasks:
6
6
  - Prefer dedicated workspace tools for reading, editing, and searching files.
7
7
  - If dedicated tools are unavailable or insufficient, use the shell/command tool.
8
8
  - Use Python through the shell for reliable scripting, data processing, or file transformations.
9
+ - When falling back to shell for file edits, do not create temporary helper scripts such as modify.py, patch.py, edit.js, or update.sh. Use inline shell commands only, such as python -c, python - <<'PY', node -e, sed, awk, cat > target <<'EOF', or git apply <<'PATCH'. Never write a helper script to disk just to execute it for code modification.
10
+ - If a file edit tool fails, re-read the relevant file context and retry the dedicated edit tool when practical before using shell fallback.
9
11
  - Stay within the current workspace unless the user explicitly asks otherwise.
10
12
  - Verify changes with relevant tests, build, lint, or targeted checks.
11
13
  - If no suitable tool is available, say so clearly.`
@@ -304,7 +304,22 @@ export async function toolReadSkillResource(params, context) {
304
304
  }
305
305
 
306
306
  // --- run_command ---
307
- export async function toolRunCommand(params, context) {
307
+ function formatCommandOutput(command, stdout, stderr, meta = {}) {
308
+ return [
309
+ `Command: ${command}`,
310
+ meta.running
311
+ ? 'Status: running'
312
+ : `Exit code: ${meta.code ?? 'unknown'}${meta.signal ? `, signal: ${meta.signal}` : ''}${meta.timedOut ? ' (timed out)' : ''}`,
313
+ '',
314
+ 'STDOUT:',
315
+ stdout || '(empty)',
316
+ '',
317
+ 'STDERR:',
318
+ stderr || '(empty)',
319
+ ].join('\n')
320
+ }
321
+
322
+ export async function toolRunCommand(params, context, runtime = {}) {
308
323
  const command = String(params?.command || '')
309
324
  if (!command.trim()) {
310
325
  const error = new Error('command is required')
@@ -315,8 +330,9 @@ export async function toolRunCommand(params, context) {
315
330
  const timeoutMs = Math.min(10 * 60, Math.max(1, Number(params?.timeoutSeconds || 60))) * 1000
316
331
 
317
332
  return new Promise((resolve) => {
333
+ const cwd = getToolWorkspaceRoot(context)
318
334
  const child = spawn(command, {
319
- cwd: getToolWorkspaceRoot(context),
335
+ cwd,
320
336
  shell: true,
321
337
  stdio: ['ignore', 'pipe', 'pipe'],
322
338
  windowsHide: true,
@@ -325,6 +341,21 @@ export async function toolRunCommand(params, context) {
325
341
  let stdout = ''
326
342
  let stderr = ''
327
343
  let timedOut = false
344
+ let updateTimer = null
345
+ let updatePending = false
346
+ const emitUpdate = () => {
347
+ updateTimer = null
348
+ if (!updatePending) return
349
+ updatePending = false
350
+ runtime.onUpdate?.({
351
+ content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
352
+ details: { command, project: context?.project, cwd, running: true, stdout, stderr },
353
+ })
354
+ }
355
+ const scheduleUpdate = () => {
356
+ updatePending = true
357
+ if (!updateTimer) updateTimer = setTimeout(emitUpdate, 150)
358
+ }
328
359
  const timer = setTimeout(() => {
329
360
  timedOut = true
330
361
  child.kill('SIGTERM')
@@ -332,26 +363,21 @@ export async function toolRunCommand(params, context) {
332
363
 
333
364
  child.stdout.on('data', (chunk) => {
334
365
  stdout = truncateText(stdout + chunk.toString())
366
+ scheduleUpdate()
335
367
  })
336
368
  child.stderr.on('data', (chunk) => {
337
369
  stderr = truncateText(stderr + chunk.toString())
370
+ scheduleUpdate()
338
371
  })
339
372
  child.on('close', (code, signal) => {
340
373
  clearTimeout(timer)
341
- const content = [
342
- `Command: ${command}`,
343
- `Exit code: ${code ?? 'unknown'}${signal ? `, signal: ${signal}` : ''}${timedOut ? ' (timed out)' : ''}`,
344
- '',
345
- 'STDOUT:',
346
- stdout || '(empty)',
347
- '',
348
- 'STDERR:',
349
- stderr || '(empty)',
350
- ].join('\n')
351
- resolve({ content: truncateText(content), details: { command, project: context?.project, cwd: getToolWorkspaceRoot(context), code, signal, timedOut } })
374
+ if (updateTimer) clearTimeout(updateTimer)
375
+ const content = formatCommandOutput(command, stdout, stderr, { code, signal, timedOut })
376
+ resolve({ content: truncateText(content), details: { command, project: context?.project, cwd, code, signal, timedOut } })
352
377
  })
353
378
  child.on('error', (err) => {
354
379
  clearTimeout(timer)
380
+ if (updateTimer) clearTimeout(updateTimer)
355
381
  resolve({
356
382
  isError: true,
357
383
  content: truncateText(`Error running command: ${err.message}`),
@@ -0,0 +1,44 @@
1
+ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
2
+ import { promisify } from 'node:util'
3
+
4
+ const scrypt = promisify(scryptCallback)
5
+ const PASSWORD_VERSION = 1
6
+
7
+ function randomToken(bytes = 24) {
8
+ return randomBytes(bytes).toString('base64url')
9
+ }
10
+
11
+ export function createRandomToken(bytes = 24) {
12
+ return randomToken(bytes)
13
+ }
14
+
15
+ export async function hashPassword(password, salt = randomToken(16)) {
16
+ if (typeof password !== 'string' || !password) return {}
17
+ const derived = await scrypt(password, salt, 32)
18
+ return {
19
+ passwordHash: derived.toString('base64url'),
20
+ passwordSalt: salt,
21
+ passwordVersion: PASSWORD_VERSION,
22
+ }
23
+ }
24
+
25
+ export async function verifyPassword(record, password) {
26
+ if (!record?.passwordHash || !record?.passwordSalt || typeof password !== 'string' || !password) return false
27
+ const { passwordHash } = await hashPassword(password, record.passwordSalt)
28
+ const expected = Buffer.from(record.passwordHash, 'base64url')
29
+ const actual = Buffer.from(passwordHash, 'base64url')
30
+ if (expected.length !== actual.length) return false
31
+ return timingSafeEqual(expected, actual)
32
+ }
33
+
34
+ export function sha256Base64Url(value) {
35
+ return createHash('sha256').update(String(value)).digest('base64url')
36
+ }
37
+
38
+ export function safeHashEqual(expectedHash, actualHash) {
39
+ if (!expectedHash || !actualHash) return false
40
+ const expected = Buffer.from(expectedHash)
41
+ const actual = Buffer.from(actualHash)
42
+ if (expected.length !== actual.length) return false
43
+ return timingSafeEqual(expected, actual)
44
+ }