feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+579
View File
@@ -0,0 +1,579 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
AutomationDefinition,
AutomationDefinitionCreateInput,
AutomationRecurringSchedule,
AutomationRunClaimInput,
AutomationSchedule
} from '../src/shared/automation'
import { AutomationRepository, AutomationRepositoryError } from '../src/main/automations/repository'
import {
automationFrequency,
getNextOccurrence,
getOccurrencePlan,
validateAutomationSchedule
} from '../src/main/automations/schedule'
import {
AutomationScheduler,
AutomationSchedulerError
} from '../src/main/automations/scheduler'
import {
automationFormAt,
describeSchedule,
formToInput,
formToPatch,
formToSchedule,
type AutomationFormState
} from '../src/renderer/src/automations/automation-model'
let passed = 0
async function test(name: string, operation: () => Promise<void> | void): Promise<void> {
await operation()
passed += 1
console.log(`PASS ${name}`)
}
function sequenceIds(): () => string {
let value = 0
return () => `test-${++value}`
}
function onceSchedule(scheduledAt: string, timeZone = 'UTC'): AutomationSchedule {
return { type: 'once', scheduledAt, timeZone }
}
function recurring(
frequency: AutomationRecurringSchedule['frequency'],
rrule: string,
anchorAt: string,
timeZone = 'UTC'
): AutomationRecurringSchedule {
return { type: 'recurring', frequency, rrule, anchorAt, timeZone }
}
function input(
schedule: AutomationSchedule,
overrides: Partial<AutomationDefinitionCreateInput> = {}
): AutomationDefinitionCreateInput {
return {
name: 'Automation test',
prompt: 'Run the regression task.',
schedule,
execution: { modelId: 'model-test', permissionMode: 'default-sandbox', thinkingLevel: 'medium' },
...overrides
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
const root = await mkdtemp(join(tmpdir(), 'ycode-automation-regression-'))
try {
await test('validates once plus six recurring frequency classes', () => {
const schedules: Array<[AutomationSchedule, string]> = [
[onceSchedule('2026-01-01T10:00:00.000Z'), 'once'],
[recurring('interval', 'FREQ=HOURLY;INTERVAL=3', '2026-01-01T10:00:00.000Z'), 'interval'],
[recurring('daily', 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', '2026-01-01T14:00:00.000Z'), 'daily'],
[recurring('weekly', 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE;BYHOUR=9;BYMINUTE=0', '2026-01-05T14:00:00.000Z'), 'weekly'],
[recurring('biweekly', 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=9;BYMINUTE=0', '2026-01-05T14:00:00.000Z'), 'biweekly'],
[recurring('monthly', 'FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0', '2026-01-31T14:00:00.000Z'), 'monthly'],
[recurring('yearly', 'FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0', '2026-12-31T14:00:00.000Z'), 'yearly']
]
for (const [schedule, expected] of schedules) {
validateAutomationSchedule(schedule)
assert.equal(automationFrequency(schedule), expected)
}
assert.throws(
() => validateAutomationSchedule(recurring('biweekly', 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO', '2026-01-05T14:00:00.000Z')),
/INTERVAL=2/
)
assert.throws(
() => validateAutomationSchedule(onceSchedule('2026-01-01T10:00:00.000Z', 'Mars/Olympus')),
/IANA/
)
})
await test('serializes all seven editor frequency classes into valid schedules', () => {
const now = new Date('2026-01-01T00:00:00.000Z')
const base = automationFormAt(undefined, now)
const forms: AutomationFormState[] = [
{ ...base, frequency: 'once', onceAt: '2026-01-02T10:00' },
{ ...base, frequency: 'interval', intervalHours: '3', weekdays: [] },
{ ...base, frequency: 'daily', hour: '9', minute: '15' },
{ ...base, frequency: 'weekly', weekdays: ['MO', 'FR'], hour: '9', minute: '15' },
{ ...base, frequency: 'biweekly', weekdays: ['WE'], hour: '9', minute: '15' },
{ ...base, frequency: 'monthly', monthDays: '1,15,31', hour: '9', minute: '15' },
{ ...base, frequency: 'yearly', month: '2', day: '29', hour: '9', minute: '15' }
]
const expected = ['once', 'interval', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly']
for (const [index, form] of forms.entries()) {
const schedule = formToSchedule(form, now)
validateAutomationSchedule(schedule)
assert.equal(automationFrequency(schedule), expected[index])
assert.match(describeSchedule(schedule), /每|单次/)
const input = formToInput(form, now)
validateAutomationSchedule(input.schedule)
const patch = formToPatch(form, now)
assert.deepEqual(patch.schedule, input.schedule)
}
})
await test('editing preserves a recurring anchor and explicit validity clearing maps to null', () => {
const schedule = recurring(
'biweekly',
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=9;BYMINUTE=0',
'2026-01-05T14:00:00.000Z'
)
const definition: AutomationDefinition = {
id: 'anchor-task',
name: 'Anchor task',
prompt: 'Keep the recurrence phase.',
status: 'ACTIVE',
schedule,
execution: {},
validFrom: '2026-01-05T14:00:00.000Z',
revision: 2,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z'
}
const form = automationFormAt(definition, new Date('2026-06-01T00:00:00.000Z'))
assert.equal(form.anchorAt, schedule.anchorAt)
const editedSchedule = formToSchedule(form)
assert.equal(editedSchedule.type, 'recurring')
assert.equal(editedSchedule.type === 'recurring' ? editedSchedule.anchorAt : undefined, schedule.anchorAt)
const patch = formToPatch({ ...form, validFrom: '', validUntil: '' })
assert.equal(patch.validFrom, null)
assert.equal(patch.validUntil, null)
})
await test('keeps daily wall time across a DST transition', () => {
const schedule = recurring(
'daily',
'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
'2026-03-07T14:00:00.000Z',
'America/New_York'
)
assert.equal(
getNextOccurrence(schedule, new Date('2026-03-07T14:00:00.000Z')),
'2026-03-08T13:00:00.000Z'
)
})
await test('skips nonexistent month days and preserves leap-day cadence', () => {
const monthly = recurring(
'monthly',
'FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0',
'2026-01-31T09:00:00.000Z'
)
assert.equal(
getNextOccurrence(monthly, new Date('2026-01-31T09:00:00.000Z')),
'2026-03-31T09:00:00.000Z'
)
const yearly = recurring(
'yearly',
'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;BYHOUR=9;BYMINUTE=0',
'2024-02-29T09:00:00.000Z'
)
assert.equal(
getNextOccurrence(yearly, new Date('2024-02-29T09:00:00.000Z')),
'2028-02-29T09:00:00.000Z'
)
})
await test('returns only the latest due occurrence and the next future occurrence', () => {
const schedule = recurring('interval', 'FREQ=HOURLY;INTERVAL=1', '2026-01-01T00:00:00.000Z')
assert.deepEqual(
getOccurrencePlan(schedule, null, new Date('2026-01-01T03:30:00.000Z')),
{ dueAt: '2026-01-01T03:00:00.000Z', nextAt: '2026-01-01T04:00:00.000Z' }
)
assert.equal(
getOccurrencePlan(schedule, '2026-01-01T03:00:00.000Z', new Date('2026-01-01T03:30:00.000Z')).dueAt,
null
)
})
await test('persists definitions and runs through one reloadable store', async () => {
const file = join(root, 'repository.json')
let now = new Date('2026-01-01T09:00:00.000Z')
const repository = new AutomationRepository(file, {
now: () => now,
createId: sequenceIds()
})
const created = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
now = new Date('2026-01-01T09:01:00.000Z')
const updated = await repository.updateDefinition(created.id, { name: 'Updated automation' })
assert.equal(updated.revision, 2)
await repository.setEnabled(created.id, false)
const reloaded = new AutomationRepository(file)
const definitions = await reloaded.listDefinitions()
assert.equal(definitions.length, 1)
assert.equal(definitions[0]?.name, 'Updated automation')
assert.equal(definitions[0]?.status, 'PAUSED')
assert.equal(definitions[0]?.revision, 3)
})
await test('deduplicates scheduled claims and rejects overlapping runs', async () => {
const file = join(root, 'claims.json')
const repository = new AutomationRepository(file, {
now: () => new Date('2026-01-01T09:00:00.000Z'),
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
const first = await repository.claimRun({
automationId: definition.id,
trigger: 'scheduled',
scheduledFor: '2026-01-01T10:00:00.000Z'
})
const duplicate = await repository.claimRun({
automationId: definition.id,
trigger: 'scheduled',
scheduledFor: '2026-01-01T10:00:00.000Z'
})
assert.equal(first.created, true)
assert.equal(duplicate.created, false)
assert.equal(duplicate.run.id, first.run.id)
await assert.rejects(
repository.claimRun({
automationId: definition.id,
trigger: 'manual',
scheduledFor: '2026-01-01T10:01:00.000Z'
}),
(error: unknown) => error instanceof AutomationRepositoryError && error.code === 'ACTIVE_RUN'
)
await repository.markRunRunning(first.run.id)
const attached = await repository.attachRunSession(first.run.id, {
sessionId: 'session-attached',
runId: 'chat-attached'
})
assert.equal(attached.sessionId, 'session-attached')
assert.equal(attached.runId, 'chat-attached')
await assert.rejects(
repository.deleteDefinition(definition.id),
(error: unknown) => error instanceof AutomationRepositoryError && error.code === 'ACTIVE_RUN'
)
const finished = await repository.finishRun(first.run.id, { status: 'succeeded' })
assert.equal(finished.status, 'succeeded')
assert.equal(finished.sessionId, 'session-attached')
assert.equal(finished.runId, 'chat-attached')
})
await test('serializes concurrent manual claims to one winner', async () => {
const repository = new AutomationRepository(join(root, 'concurrent.json'), {
now: () => new Date('2026-01-01T09:00:00.000Z'),
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T09:00:00.000Z')))
const settled = await Promise.allSettled([
repository.claimRun({ automationId: definition.id, trigger: 'manual', scheduledFor: '2026-01-01T09:01:00.000Z' }),
repository.claimRun({ automationId: definition.id, trigger: 'manual', scheduledFor: '2026-01-01T09:02:00.000Z' })
])
assert.equal(settled.filter((result) => result.status === 'fulfilled').length, 1)
const rejected = settled.find((result) => result.status === 'rejected')
assert.ok(rejected?.status === 'rejected')
assert.equal((rejected.reason as AutomationRepositoryError).code, 'ACTIVE_RUN')
})
await test('marks an active run failed when a new repository instance recovers the file', async () => {
const file = join(root, 'recovery.json')
const repository = new AutomationRepository(file, {
now: () => new Date('2026-01-01T09:00:00.000Z'),
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
const claim = await repository.claimRun({
automationId: definition.id,
trigger: 'scheduled',
scheduledFor: '2026-01-01T10:00:00.000Z'
})
await repository.markRunRunning(claim.run.id)
const recovered = new AutomationRepository(file, {
now: () => new Date('2026-01-01T10:01:00.000Z')
})
const runs = await recovered.listRuns({ automationId: definition.id })
assert.equal(runs[0]?.status, 'failed')
assert.match(runs[0]?.error ?? '', /应用重启/)
})
await test('does not cache recovered state when the repair write fails', async () => {
const file = join(root, 'recovery-write-failure.json')
const repository = new AutomationRepository(file, {
now: () => new Date('2026-01-01T09:00:00.000Z'),
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
const claim = await repository.claimRun({
automationId: definition.id,
trigger: 'scheduled',
scheduledFor: '2026-01-01T10:00:00.000Z'
})
await repository.markRunRunning(claim.run.id)
class FailingRecoveryRepository extends AutomationRepository {
protected override async persist(): Promise<void> {
throw new Error('模拟磁盘写入失败')
}
}
const failing = new FailingRecoveryRepository(file, {
now: () => new Date('2026-01-01T10:01:00.000Z')
})
await assert.rejects(failing.listRuns({ automationId: definition.id }), /模拟磁盘写入失败/)
await assert.rejects(failing.listRuns({ automationId: definition.id }), /模拟磁盘写入失败/)
const recovered = new AutomationRepository(file, {
now: () => new Date('2026-01-01T10:02:00.000Z')
})
const runs = await recovered.listRuns({ automationId: definition.id })
assert.equal(runs[0]?.status, 'failed')
assert.match(runs[0]?.error ?? '', /应用重启/)
})
await test('does not overwrite a corrupt store', async () => {
const file = join(root, 'corrupt.json')
await writeFile(file, '{not-json', 'utf8')
const repository = new AutomationRepository(file)
await assert.rejects(
repository.listDefinitions(),
(error: unknown) => error instanceof AutomationRepositoryError && error.code === 'CORRUPT_STORE'
)
assert.equal(await readFile(file, 'utf8'), '{not-json')
})
await test('scheduler returns a running record before the background execution finishes', async () => {
const file = join(root, 'scheduler-once.json')
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
const repository = new AutomationRepository(file, {
now: clock.now,
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
const calls: AutomationDefinition[] = []
let changed = 0
const scheduler = new AutomationScheduler(repository, async ({ definition: frozen, run }) => {
calls.push(frozen)
await repository.attachRunSession(run.id, { sessionId: 'session-created', runId: 'chat-created' })
return { status: 'succeeded', sessionId: 'session-created', runId: 'chat-created' }
}, { clock, lateToleranceMs: 60_000, onChanged: () => { changed += 1 } })
const results = await scheduler.tick()
assert.equal(calls.length, 1)
assert.equal(calls[0]?.prompt, definition.prompt)
assert.deepEqual(results.map(({ status, missed }) => ({ status, missed })), [{ status: 'running', missed: false }])
const finished = await scheduler.waitForRun(results[0]!.runId)
assert.equal(finished.status, 'succeeded')
assert.equal(finished.sessionId, 'session-created')
assert.equal(finished.runId, 'chat-created')
assert.ok(changed >= 3, 'queued, running and terminal transitions must notify')
})
await test('scheduler records stale recurring occurrences as missed without executing', async () => {
const file = join(root, 'scheduler-missed.json')
const clock = { now: () => new Date('2026-01-01T03:30:00.000Z') }
const repository = new AutomationRepository(file, {
now: clock.now,
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(
recurring('interval', 'FREQ=HOURLY;INTERVAL=1', '2026-01-01T00:00:00.000Z')
))
let executed = 0
const scheduler = new AutomationScheduler(repository, async () => {
executed += 1
return { status: 'succeeded' }
}, { clock, lateToleranceMs: 60_000 })
const results = await scheduler.tick()
assert.equal(executed, 0)
assert.equal(results[0]?.status, 'missed')
assert.equal(results[0]?.missed, true)
})
await test('manual scheduler runs reject overlap while the first execution is active', async () => {
const file = join(root, 'scheduler-manual.json')
const clock = { now: () => new Date('2026-01-01T09:00:00.000Z') }
const repository = new AutomationRepository(file, {
now: clock.now,
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T10:00:00.000Z')))
let release!: (value: { status: 'succeeded' }) => void
const entered = deferred<void>()
const scheduler = new AutomationScheduler(repository, async () => {
entered.resolve()
return new Promise((resolve) => {
release = resolve
})
}, { clock })
const first = await scheduler.runNow(definition.id)
assert.equal(first.status, 'running')
await entered.promise
await assert.rejects(
scheduler.runNow(definition.id),
(error: unknown) => error instanceof AutomationSchedulerError && error.code === 'ACTIVE_RUN'
)
release({ status: 'succeeded' })
assert.equal((await scheduler.waitForRun(first.id)).status, 'succeeded')
})
await test('scheduler background pump claims a due task without a manual tick', async () => {
const file = join(root, 'scheduler-background.json')
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
const repository = new AutomationRepository(file, {
now: clock.now,
createId: sequenceIds()
})
await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
const executed = deferred<void>()
const releaseExecution = deferred<void>()
const scheduler = new AutomationScheduler(repository, async ({ run }) => {
await repository.attachRunSession(run.id, { sessionId: 'session-background' })
executed.resolve()
await releaseExecution.promise
return { status: 'succeeded' }
}, { clock, pollIntervalMs: 5, lateToleranceMs: 60_000 })
scheduler.start()
await executed.promise
const running = await repository.listRuns()
assert.equal(running[0]?.status, 'running')
releaseExecution.resolve()
const completed = await scheduler.waitForRun(running[0]!.id)
assert.equal(completed.status, 'succeeded')
await scheduler.stop()
const runs = await repository.listRuns()
assert.equal(runs.length, 1)
assert.equal(runs[0]?.status, 'succeeded')
assert.equal(runs[0]?.sessionId, 'session-background')
})
await test('a long run does not block later due definitions and identity is attached before terminal', async () => {
const file = join(root, 'scheduler-multi.json')
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
const repository = new AutomationRepository(file, {
now: clock.now,
createId: sequenceIds()
})
const longDefinition = await repository.createDefinition(input(
onceSchedule('2026-01-01T10:00:00.000Z'),
{ name: 'Long A' }
))
await repository.createDefinition(input(
onceSchedule('2026-01-01T10:00:00.000Z'),
{ name: 'Quick B' }
))
const longEntered = deferred<void>()
const releaseLong = deferred<void>()
const scheduler = new AutomationScheduler(repository, async ({ definition, run }) => {
await repository.attachRunSession(run.id, {
sessionId: `session-${definition.name}`,
runId: `chat-${run.id}`
})
if (definition.name === 'Long A') {
longEntered.resolve()
await releaseLong.promise
}
return { status: 'succeeded' }
}, { clock, lateToleranceMs: 60_000 })
const startedAt = Date.now()
const started = await scheduler.tick()
assert.ok(Date.now() - startedAt < 500, 'tick must not wait for the long executor')
assert.equal(started.length, 2)
assert.deepEqual(started.map((run) => run.status), ['running', 'running'])
await longEntered.promise
const longRunId = started.find((run) => run.automationId === longDefinition.id)!.runId
const attachedWhileRunning = await repository.getRun(longRunId)
assert.equal(attachedWhileRunning?.status, 'running')
assert.equal(attachedWhileRunning?.sessionId, 'session-Long A')
releaseLong.resolve()
await Promise.all(started.map((run) => scheduler.waitForRun(run.runId)))
})
await test('scheduler shutdown aborts active execution and persists cancelled state', async () => {
const file = join(root, 'scheduler-shutdown.json')
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
const repository = new AutomationRepository(file, {
now: clock.now,
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T10:00:00.000Z')))
const entered = deferred<void>()
const scheduler = new AutomationScheduler(repository, async ({ run, signal }) => {
await repository.attachRunSession(run.id, { sessionId: 'session-shutdown' })
entered.resolve()
return new Promise((_, reject) => {
signal.addEventListener('abort', () => {
const error = new Error('aborted')
error.name = 'AbortError'
reject(error)
}, { once: true })
})
}, { clock })
const started = await scheduler.runNow(definition.id)
assert.equal(started.status, 'running')
await entered.promise
await scheduler.stop()
const cancelled = await scheduler.waitForRun(started.id)
assert.equal(cancelled.status, 'cancelled')
assert.equal(cancelled.sessionId, 'session-shutdown')
assert.match(cancelled.error ?? '', /停止/)
})
await test('scheduler shutdown waits for an in-flight manual claim and cancels it before returning', async () => {
const file = join(root, 'scheduler-pending-manual.json')
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
const claimPersisted = deferred<void>()
const releaseClaim = deferred<void>()
class DelayedClaimRepository extends AutomationRepository {
override async claimRun(claimInput: AutomationRunClaimInput) {
const claimed = await super.claimRun(claimInput)
claimPersisted.resolve()
await releaseClaim.promise
return claimed
}
}
const repository = new DelayedClaimRepository(file, {
now: clock.now,
createId: sequenceIds()
})
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T10:00:00.000Z')))
let executed = 0
const scheduler = new AutomationScheduler(repository, async () => {
executed += 1
return { status: 'succeeded' }
}, { clock })
const runOutcome = assert.rejects(
scheduler.runNow(definition.id),
(error: unknown) => error instanceof AutomationSchedulerError && error.code === 'STOPPING'
)
await claimPersisted.promise
let stopped = false
const shutdown = scheduler.stop().then(() => { stopped = true })
await new Promise(resolve => setTimeout(resolve, 10))
assert.equal(stopped, false, 'stop must wait for an in-flight manual start')
releaseClaim.resolve()
await runOutcome
await shutdown
assert.equal(executed, 0)
const runs = await repository.listRuns({ automationId: definition.id, triggers: ['manual'] })
assert.equal(runs.length, 1)
assert.equal(runs[0]?.status, 'cancelled')
assert.match(runs[0]?.error ?? '', /停止/)
})
} finally {
await rm(root, { recursive: true, force: true })
}
console.log(`PASS automation regression (${passed} suites)`)
+252
View File
@@ -0,0 +1,252 @@
// Real hidden Electron Chromium + host tools + HTTP gateway and installed OMP, all against loopback fixtures.
const fs = require('node:fs/promises')
const path = require('node:path')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/browser-automation-'))
const modulePath = path.join(directory, 'host.cjs')
require('esbuild').buildSync({ stdin: { resolveDir: process.cwd(), contents: [
"export * from './src/main/browser-automation';", "export * from './src/main/agent/tools';",
"export * from './src/main/agent/tools/browser';", "export * from './src/main/agent/conversation-tools';",
"export * from './src/main/agent/loop';", "export * from './src/main/agent/omp-turn';",
"export * from './src/main/agent/history';", "export * from './src/shared/message-parts';",
"export * from './src/main/gateway';", "export * from './src/main/gateway/responses';",
"export * from './src/main/gateway/native-protocols';", "export * from './src/main/omp';", "export * from './src/main/omp-model';"
].join('\n') }, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], outfile: modulePath, logLevel: 'warning' })
const env = { ...process.env, YCODE_BROWSER_TEST_DIRECTORY: directory, YCODE_OMP_HOME: path.join(directory, 'omp-home'),
YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_DISABLE_SYSTEM_INTEGRATION: '1', YCODE_DISABLE_GPU: '1' }
delete env.ELECTRON_RUN_AS_NODE
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output)
const report = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8').catch(() => '{"result":"MISSING"}'))
if (code || report.result !== 'PASS') console.error(output.slice(-6000))
console.log(JSON.stringify({ directory, exitCode: code, result: report.result })); process.exitCode = code || report.result !== 'PASS' ? 1 : 0
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const assert = require('node:assert/strict')
const http = require('node:http')
const directory = process.env.YCODE_BROWSER_TEST_DIRECTORY
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
app.on('window-all-closed', () => {})
const servers = [], checks = [], apiRequests = []
let host, kernel, phase = 'boot'
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
function check(label, condition) { assert.ok(condition, label); checks.push(label); console.log('BROWSER_CHECK ' + label) }
async function listen(handler) {
const server = http.createServer(handler); servers.push(server)
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
return { server, base: 'http://127.0.0.1:' + server.address().port }
}
function page(next = false) {
return `<!doctype html><html><head><title>${next ? 'Next fixture' : 'Browser fixture'}</title></head>
<body style="background:#edf9f2;font:20px Arial;margin:32px;min-height:2400px">
<h1>${next ? 'NEXT_PAGE' : 'BROWSER_FIXTURE'}</h1><label for="query">Query</label><input id="query" value="old value">
<button id="apply">Apply</button><button id="later">Delayed status</button>
<output id="result">Waiting</output><a id="next" href="/next">Next page</a>
<div id="cookies"></div><div id="node-access"></div><input type="password" value="never-expose-password">
<script>document.querySelector('#apply').addEventListener('click',event=>{document.querySelector('#result').textContent='APPLIED:'+document.querySelector('#query').value+':trusted='+event.isTrusted});
document.querySelector('#later').onclick=()=>setTimeout(()=>document.querySelector('#result').textContent='DELAYED_READY',150);
document.querySelector('#cookies').textContent='cookie:'+document.cookie;
document.querySelector('#node-access').textContent='node:'+typeof require+'/'+typeof process;</script></body></html>`
}
const ctx = (sessionId = 'browser-a', extra = {}) => ({ workspaceRoot: directory, sessionId, runId: 'test-' + sessionId,
toolCallId: 'browser-test', permissionMode: 'full-access', signal: new AbortController().signal, ...extra })
async function call(args, context = ctx()) { return host.executeTool('browser', args, context) }
async function ok(args, context = ctx()) { const result = await call(args, context); assert.equal(result.ok, true, result.content); return result }
async function rejected(args, pattern, context = ctx()) { const result = await call(args, context); assert.equal(result.ok, false); assert.match(result.content, pattern); return result }
function sse(res, call, final) {
res.writeHead(200, { 'content-type': 'text/event-stream' })
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: call.id, type: 'function', function: { name: 'browser', arguments: JSON.stringify(call.args) } }] }
: { role: 'assistant', content: final }
res.end([{ id: 'controlled-browser', object: 'chat.completion.chunk', model: 'browser-controlled', choices: [{ index: 0, delta, finish_reason: null }] },
{ id: 'controlled-browser', object: 'chat.completion.chunk', model: 'browser-controlled', choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }]
.map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
}
async function conversation(viaOmp, fixture, production = false, supportsImages = true) {
const mode = production ? 'APP' : (viaOmp ? 'OMP' : 'HTTP') + (supportsImages ? '' : '_TEXT_ONLY')
let stage = 0, observedImage = false, observedApplied = false
const expected = mode + '_BROWSER_VALUE'
const api = await listen(async (req, res) => {
try {
let source = ''; for await (const chunk of req) source += chunk
const body = JSON.parse(source || '{}'); apiRequests.push({ mode, path: req.url, body })
if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end('{}'); return }
assert.ok(body.tools.some(tool => tool.function.name === 'browser'), 'model actually receives browser tool')
const serialized = JSON.stringify(body.messages)
observedApplied ||= serialized.includes('APPLIED:' + expected + ':trusted=true')
observedImage ||= serialized.includes('data:image/jpeg;base64,')
const actions = [{ action: 'open', url: fixture + '/page' }, { action: 'type', selector: '#query', text: expected },
{ action: 'click', selector: '#apply' }, { action: 'screenshot' }]
if (stage < actions.length) sse(res, { id: mode + '-call-' + stage, args: actions[stage++] })
else { assert.ok(observedApplied, 'page changes returned to model'); assert.equal(observedImage, supportsImages, 'screenshot follows model image capabilities'); sse(res, undefined, mode + '_BROWSER_COMPLETE') }
} catch (error) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: String(error) } })) }
})
const events = [], common = { runId: mode + '-run', sessionId: mode + '-session', workspaceRoot: directory,
userText: 'Use the browser to fill the fixture and inspect its screenshot.', signal: new AbortController().signal,
permissionMode: 'full-access', supportsImages, emit: event => events.push(event) }
const config = { baseUrl: api.base + '/v1', api: 'openai-completions', model: 'browser-controlled', apiKey: 'loopback-only', maxOutputTokens: 1024, maxToolRounds: 8 }
let result
if (production) {
// The harness already disabled acceleration before app readiness.
delete process.env.YCODE_DISABLE_GPU
delete process.env.YCODE_DISABLE_GPU_SANDBOX
await import(require('node:url').pathToFileURL(path.resolve('out/main/index.mjs')).href)
let main
for (let index = 0; index < 300; index++) {
main = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'production main window')
const read = source => main.webContents.executeJavaScript(source)
for (let index = 0; index < 200 && !await read('!!window.ycode'); index++) await pause(25)
await read('window.__browserEvents=[];window.ycode.chat.onEvent(event=>window.__browserEvents.push(event));true')
const profile = { id: 'browser-controlled', name: 'Browser controlled', providerId: 'custom', model: 'browser-controlled', protocol: 'openai',
apiKey: 'loopback-only', baseUrl: config.baseUrl, endpoint: { api: 'openai-completions', baseUrl: config.baseUrl, checkedAt: Date.now() },
supportsToolCall: true, supportsImages: true, supportsReasoning: false, contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 }
await read(`window.ycode.models.save(${JSON.stringify(profile)})`)
const workspace = await read(`window.ycode.workspace.attach(${JSON.stringify(directory)})`)
const session = await read(`window.ycode.session.create('Browser APP',{modelId:'browser-controlled',workspaceId:${JSON.stringify(workspace.attachedWorkspaceId)}})`)
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(session.id)},text:'Control the browser fixture.',permissionMode:'full-access'})`)
let assistant
for (let index = 0; index < 1200; index++) {
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`)
assistant = messages.filter(message => message.role === 'assistant').at(-1)
if (assistant && ['done', 'error', 'cancelled'].includes(assistant.status) && !await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`)) break
await pause(40)
}
assert.equal(assistant?.status, 'done', JSON.stringify({ status: assistant?.status, error: assistant?.error,
tools: assistant?.tools?.map(tool => ({ name: tool.name, status: tool.status })) }))
result = { content: assistant.content, tools: assistant.tools }
events.push(...await read('window.__browserEvents'))
check('APP: production IPC persists the screenshot as an assistant image part', assistant.parts.some(part => part.type === 'image' && part.url.startsWith('data:image/jpeg;base64,')))
const persisted = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
check('APP: cold history contains successful browser actions and the real screenshot', persisted.sessions.some(item => item.id === session.id && item.messages.some(message => message.id === assistant.id && message.parts?.some(part => part.type === 'image'))))
check('APP: the production host creates a hidden browser through its own registration', BrowserWindow.getAllWindows().some(window => window !== main && !window.isVisible()))
await read(`window.ycode.session.remove(${JSON.stringify(session.id)})`)
check('APP: deleting a session disposes its browser and native kernel', BrowserWindow.getAllWindows().length === 1)
} else if (viaOmp) {
const executable = host.detectOmpPath(); assert.ok(executable, 'installed OMP is available')
const prepared = await host.prepareOmpModel({ config, profile: { id: 'browser-controlled', name: 'Browser controlled', providerId: 'custom', model: 'browser-controlled',
baseUrl: config.baseUrl, hasApiKey: true, keyStorage: 'none', supportsToolCall: true, supportsImages, supportsReasoning: false,
contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 } })
kernel = new host.OmpKernel(executable, directory, prepared.profile, path.join(directory, 'omp-browser-session.jsonl'), prepared.model, prepared.env)
result = await host.runOmpTurn({ ...common, kernel })
await kernel.dispose(); kernel = undefined
} else result = await host.runTurn({ ...common, history: [], gateway: new host.Gateway(config), model: config.model, maxToolRounds: 8 })
check(mode + ': controlled model completes a real browser open/type/click/screenshot round trip', result.content.includes(mode + '_BROWSER_COMPLETE'))
check(mode + ': tool events contain four successful browser actions', result.tools.filter(tool => tool.name === 'browser' && tool.status === 'ok').length === 4)
check(mode + ': screenshot is visible in conversation image events', events.some(event => event.type === 'part' && event.part.type === 'image' && event.part.url.startsWith('data:image/jpeg;base64,')))
check(mode + ': page content and image capability are respected', observedApplied && observedImage === supportsImages)
const final = apiRequests.filter(request => request.mode === mode).at(-1)
if (!viaOmp && supportsImages) {
const wire = { messages: final.body.messages }
check('Responses fallback keeps browser screenshots as input_image', JSON.stringify(host.buildResponsesRequest(config, wire)).includes('input_image'))
check('Anthropic fallback keeps browser screenshots as image blocks', JSON.stringify(host.buildAnthropicRequest(config, wire)).includes('"type":"image"'))
check('Gemini fallback keeps browser screenshots as inlineData', JSON.stringify(host.buildGeminiRequest(config, wire)).includes('inlineData'))
}
if (!production) {
const message = events.reduce((message, event) => host.applyAssistantEvent(message, event),
{ id: mode + '-assistant', requestId: common.runId, role: 'assistant', content: '', createdAt: 0 })
check(mode + ': compatible history replays screenshot images in subsequent turns', JSON.stringify(host.assistantWireHistory(message)).includes('data:image/jpeg;base64,'))
check(mode + ': text-only history excludes screenshot input without losing tool results', !JSON.stringify(host.assistantWireHistory(message, { supportsImages: false })).includes('data:image/jpeg;base64,') && host.assistantWireHistory(message, { supportsImages: false }).some(row => row.role === 'tool'))
}
host.disposeSessionBrowsers(common.sessionId)
}
app.whenReady().then(async () => {
host = require(path.join(directory, 'host.cjs')); host.configureBrowserTool(host.executeBrowserTool)
const fixture = await listen((req, res) => {
if (req.url === '/hang') return
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page(req.url === '/next'))
})
phase = 'direct-browser-actions'
check('browser is registered in the host tool schema', host.toolSpecs().some(tool => tool.function.name === 'browser'))
await rejected({ action: 'open', url: 'file:///C:/Windows/win.ini' }, /HTTP/)
await rejected({ action: 'open', url: 'javascript:alert(1)' }, /HTTP/)
await rejected({ action: 'open', url: 'http://user:password@localhost/' }, /HTTP/)
check('file, script and credential URLs are refused', BrowserWindow.getAllWindows().length === 0)
await rejected({ action: 'open', url: fixture.base + '/page' }, /批准/, ctx('denied', { permissionMode: 'default-sandbox', approve: async () => false }))
check('default permission refusal creates no browser', BrowserWindow.getAllWindows().length === 0)
const blocked = await host.executeTurnTool('browser', { action: 'open', url: fixture.base + '/page' },
{ ...ctx('mode-denied'), agentMode: 'plan', emit() {} })
check('plan mode cannot bypass the shared tool gate', !blocked.ok && BrowserWindow.getAllWindows().length === 0)
const first = await ok({ action: 'open', url: fixture.base + '/page' })
const id = first.data.tabId
check('real hidden Chromium returns the rendered page and interactive refs', first.data.text.includes('BROWSER_FIXTURE') && first.data.elements.some(element => element.name === 'Apply'))
check('browser remains hidden and has no renderer Node access', BrowserWindow.getAllWindows().every(window => !window.isVisible()) && first.data.text.includes('node:undefined/undefined'))
check('password values do not appear in browser snapshots', !first.content.includes('never-expose-password'))
const managed = BrowserWindow.getAllWindows()[0], prefs = managed.webContents.getLastWebPreferences()
check('browser renderer uses isolation and a sandbox without preload', prefs.sandbox && prefs.contextIsolation && !prefs.nodeIntegration && !prefs.preload)
await rejected({ action: 'snapshot', tabId: id }, /不属于/, ctx('browser-b'))
check('another conversation cannot use the browser id', (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 0)
await managed.webContents.session.cookies.set({ url: fixture.base, name: 'isolation', value: 'private-a' })
const second = await ok({ action: 'open', url: fixture.base + '/page' }, ctx('browser-b'))
check('browser cookies are isolated between conversations', !second.data.text.includes('private-a'))
await rejected({ action: 'click', tabId: id, selector: 'button' }, /唯一匹配/)
await ok({ action: 'type', tabId: id, selector: '#query', text: '' })
check('type can clear a text field', (await ok({ action: 'snapshot', tabId: id })).data.elements.find(element => element.tag === 'input' && element.type === 'text').value === '')
await ok({ action: 'type', tabId: id, selector: '#query', text: 'typed real text' })
const applied = await ok({ action: 'click', tabId: id, ref: first.data.elements.find(element => element.name === 'Apply').ref })
check('typing and clicking produce actual trusted Chromium events', applied.data.text.includes('APPLIED:typed real text:trusted=true'))
await ok({ action: 'type', tabId: id, selector: '#query', text: '+append', replace: false })
const appended = await ok({ action: 'snapshot', tabId: id })
check('type supports append without erasing existing input', appended.data.elements.find(element => element.tag === 'input' && element.type === 'text').value === 'typed real text+append')
await ok({ action: 'press_key', tabId: id, key: 'Tab' })
await ok({ action: 'press_key', tabId: id, key: 'Enter' })
check('keyboard events can activate the focused button', (await ok({ action: 'snapshot', tabId: id })).data.text.includes('APPLIED:typed real text+append:trusted=true'))
await ok({ action: 'click', tabId: id, selector: '#later' })
await ok({ action: 'wait', tabId: id, text: 'DELAYED_READY', timeoutMs: 1500 })
check('wait observes asynchronous rendered page changes', true)
await rejected({ action: 'wait', tabId: id, text: 'never-present', timeoutMs: 100 }, /超时/)
const scrolled = await ok({ action: 'scroll', tabId: id, y: 700 })
check('scroll changes the real page viewport', scrolled.data.scroll.y > 0)
await ok({ action: 'scroll', tabId: id, y: -700 })
const capture = await ok({ action: 'screenshot', tabId: id })
const bytes = Buffer.from(capture.images[0].data, 'base64'); await fs.writeFile(path.join(directory, 'browser-actual.jpg'), bytes)
check('screenshot returns a bounded real JPEG image', bytes[0] === 0xff && bytes[1] === 0xd8 && bytes.length > 2000 && bytes.length < 500 * 1024)
check('taking a screenshot never opens a visible window', BrowserWindow.getAllWindows().every(window => !window.isVisible()))
const oldRef = applied.data.elements.find(element => element.name === 'Apply').ref
const next = await ok({ action: 'navigate', tabId: id, url: fixture.base + '/next' })
check('navigate reads the new document', next.data.text.includes('NEXT_PAGE'))
await rejected({ action: 'click', tabId: id, ref: oldRef }, /ref/)
check('refs from previous documents cannot trigger new-page controls', true)
let approvals = 0
await ok({ action: 'open', url: fixture.base + '/page' }, ctx('approved', { permissionMode: 'default-sandbox', approve: async () => { approvals++; return true } }))
check('default permission supports explicit approval', approvals === 1)
const cancellation = new AbortController(), started = Date.now()
setTimeout(() => cancellation.abort(), 150)
await rejected({ action: 'open', url: fixture.base + '/hang' }, /取消/, ctx('cancelled-open', { signal: cancellation.signal }))
check('cancelled navigation stops promptly without leaving an orphan tab', Date.now() - started < 2000 && (await ok({ action: 'list' }, ctx('cancelled-open'))).data.length === 0)
host.disposeSessionBrowsers('browser-a')
check('session cleanup closes only its own browsers', (await ok({ action: 'list' })).data.length === 0 && (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 1)
await ok({ action: 'close', tabId: second.data.tabId }, ctx('browser-b'))
check('close releases a browser through the public tool action', (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 0)
host.disposeBrowserAutomation()
check('all browser contents are released', BrowserWindow.getAllWindows().length === 0)
phase = 'http-conversation'; await conversation(false, fixture.base)
phase = 'omp-conversation'; await conversation(true, fixture.base)
phase = 'text-only-http-conversation'; await conversation(false, fixture.base, false, false)
phase = 'text-only-omp-conversation'; await conversation(true, fixture.base, false, false)
phase = 'production-conversation'; await conversation(true, fixture.base, true)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, apiRequests, phase }, null, 2))
console.log('BROWSER_AUTOMATION_PASS ' + directory)
}).catch(async error => {
console.error(error)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', phase, checks, error: String(error.stack || error), apiRequests }, null, 2))
process.exitCode = 1
}).finally(async () => {
if (kernel) await kernel.dispose().catch(() => undefined)
host?.disposeBrowserAutomation()
for (const server of servers) { server.closeAllConnections(); server.close() }
await pause(100); app.exit(process.exitCode || 0)
})
}
+65
View File
@@ -0,0 +1,65 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { spawn, execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { verifyBundledGit } from './vendor-sandbox-git.mjs'
export async function buildSandbox({ outputDirectory = path.resolve('resources/sandbox/win32-x64'), probe = false } = {}) {
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('The sandbox native build currently requires Windows x64')
const bundledGit = !probe ? await verifyBundledGit(path.join(outputDirectory, 'git')).catch(error => { throw new Error(`Bundled Git is missing or invalid; run npm run sandbox:vendor-git. ${error.message}`) }) : undefined
const programFiles = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
const vswhere = path.join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe')
const installation = execFileSync(vswhere, ['-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], { encoding: 'utf8', windowsHide: true }).trim()
if (!installation) throw new Error('Visual Studio C++ Build Tools are required; see native/sandbox/README.md')
const versions = async directory => (await fs.readdir(directory)).filter(name => /^\d+(?:\.\d+)+$/.test(name)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
const msvcRoot = path.join(installation, 'VC/Tools/MSVC')
const msvc = path.join(msvcRoot, (await versions(msvcRoot))[0])
const sdkRoot = path.join(programFiles, 'Windows Kits/10')
const sdk = (await versions(path.join(sdkRoot, 'Include')))[0]
if (!sdk) throw new Error('Windows 10/11 SDK is required')
const compilerDirectory = path.join(msvc, 'bin/Hostx64/x64')
const environment = { ...process.env,
PATH: `${compilerDirectory};${process.env.PATH || ''}`,
INCLUDE: [path.join(msvc, 'include'), ...['ucrt', 'shared', 'um', 'winrt'].map(part => path.join(sdkRoot, 'Include', sdk, part))].join(';'),
LIB: [path.join(msvc, 'lib/x64'), ...['ucrt', 'um'].map(part => path.join(sdkRoot, 'Lib', sdk, part, 'x64'))].join(';') }
await fs.mkdir(outputDirectory, { recursive: true })
const objects = await fs.mkdtemp(path.resolve('.tmp/sandbox-native-build-'))
const source = path.resolve(probe ? 'native/sandbox/probe.cpp' : 'native/sandbox/runner.cpp')
const binary = path.join(outputDirectory, probe ? 'sandbox-probe.exe' : 'ycode-sandbox.exe')
const args = ['/nologo', '/std:c++17', '/EHsc', '/W4', '/WX', '/MT', '/O2', '/Brepro', '/utf-8', '/DUNICODE', '/D_UNICODE', '/D_WIN32_WINNT=0x0A00', `/Fo${objects}${path.sep}`, `/Fe${binary}`, source, '/link', '/INCREMENTAL:NO', 'advapi32.lib', 'userenv.lib', 'ws2_32.lib', 'bcrypt.lib']
await new Promise((resolve, reject) => {
const child = spawn(path.join(compilerDirectory, 'cl.exe'), args, { env: environment, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', bytes => { log += bytes })
child.stderr.on('data', bytes => { log += bytes })
child.once('error', reject)
child.once('close', code => code === 0 ? resolve() : reject(new Error(`Sandbox compiler exited ${code}\n${log}`)))
})
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
const manifest = { protocol: 1, backend: 'windows-appcontainer', version: '0.7.0', architecture: 'x64',
sourceSha256: hash(await fs.readFile(source)), binarySha256: hash(await fs.readFile(binary)), compiler: path.basename(msvc), sdk }
if (!probe) {
manifest.workspaceTransactions = 1
manifest.workspaceState = 1
manifest.gitMetadataTransactions = 1
manifest.bundledGit = bundledGit
manifest.sources = Object.fromEntries(await Promise.all(['runner.cpp', 'filesystem-read.h', 'filesystem-write.h', 'workspace-state.h', 'git-control.h', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py'].map(async name => [name, hash(await fs.readFile(path.resolve('native/sandbox', name)))])))
const adapter = await fs.readFile(path.resolve('native/sandbox/node-bridge.cjs'))
await fs.writeFile(path.join(outputDirectory, 'node-bridge.cjs'), adapter)
manifest.nodeBridgeSha256 = hash(adapter)
const processes = await fs.readFile(path.resolve('native/sandbox/node-process.cjs'))
await fs.writeFile(path.join(outputDirectory, 'node-process.cjs'), processes)
manifest.nodeProcessSha256 = hash(processes)
const pythonAdapter = await fs.readFile(path.resolve('native/sandbox/python-sitecustomize.py'))
await fs.writeFile(path.join(outputDirectory, 'python-sitecustomize.py'), pythonAdapter)
manifest.pythonAdapterSha256 = hash(pythonAdapter)
}
if (!probe) await fs.writeFile(path.join(outputDirectory, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
console.log(`SANDBOX_BUILD_PASS ${path.relative(process.cwd(), binary)}`)
return binary
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
await buildSandbox({ probe: process.argv.includes('--probe') })
}
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { Gateway, GatewayError, type GatewayDelta } from '../src/main/gateway'
const requests = new Map<string, Array<Record<string, unknown>>>()
const abortConnections = new Set<import('node:http').ServerResponse>()
function attempts(model: string): Array<Record<string, unknown>> {
return requests.get(model) ?? []
}
function recordAttempt(body: Record<string, unknown>): number {
const model = String(body.model)
const items = requests.get(model) ?? []
items.push(body)
requests.set(model, items)
return items.length
}
function successSse(response: import('node:http').ServerResponse, usage: Record<string, unknown>): void {
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
{ choices: [{ delta: { content: 'ok' }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
{ choices: [], usage }
].map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join('') + 'data: [DONE]\n\n')
}
const server = createServer(async (request, response) => {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk))
const body = JSON.parse(Buffer.concat(chunks).toString()) as Record<string, unknown>
const model = String(body.model)
const attempt = recordAttempt(body)
const validUsage = {
prompt_tokens: 128,
completion_tokens: 2,
total_tokens: 130,
prompt_tokens_details: { cached_tokens: 32 }
}
if (model === 'valid' || model === 'invalid-usage') {
successSse(response, model === 'invalid-usage'
? { prompt_tokens: -1, completion_tokens: 2, total_tokens: 1 }
: validUsage)
return
}
if (model === 'legacy-400') {
if (attempt === 1 && body.stream_options) {
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'Unsupported parameter: stream_options' } }))
return
}
successSse(response, validUsage)
return
}
if (model === 'legacy-422') {
if (attempt === 1 && body.stream_options) {
response.writeHead(422, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'include_usage is not supported by this server' } }))
return
}
successSse(response, validUsage)
return
}
if (model === 'ordinary-400') {
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({
error: { message: 'request validation failed while parsing stream_options' },
apiKey: 'UPSTREAM_SECRET_MUST_NOT_ECHO'
}))
return
}
if (model === 'auth-401') {
response.writeHead(401, { 'content-type': 'application/json' })
response.end(JSON.stringify({
error: { message: 'invalid api key' },
authorization: 'Bearer UPSTREAM_SECRET_MUST_NOT_ECHO'
}))
return
}
if (model === 'server-500-options') {
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'stream_options unsupported due to server failure' } }))
return
}
if (model === 'started-sse-error') {
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'before-error' }, finish_reason: null }] })}\n\n`)
response.write(`data: ${JSON.stringify({ error: { message: 'stream_options unsupported' } })}\n\n`)
response.end()
return
}
if (model === 'abort-retry') {
if (attempt === 1 && body.stream_options) {
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'unknown field: stream_options' } }))
return
}
abortConnections.add(response)
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
response.flushHeaders()
const keepalive = setInterval(() => {
if (!response.destroyed) response.write(': keepalive\n\n')
}, 100)
response.on('close', () => {
clearInterval(keepalive)
abortConnections.delete(response)
})
return
}
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: `unknown QA model: ${model}` } }))
})
function gatewayFor(model: string): Gateway {
const address = server.address()
assert.ok(address && typeof address === 'object')
return new Gateway({
baseUrl: `http://127.0.0.1:${address.port}/v1`,
apiKey: '',
authMode: 'none',
model,
temperature: 0,
maxToolRounds: 1
})
}
async function collect(model: string, signal?: AbortSignal): Promise<GatewayDelta[]> {
const events: GatewayDelta[] = []
for await (const event of gatewayFor(model).stream({
messages: [{ role: 'user', content: 'test' }],
signal
})) {
events.push(event)
}
return events
}
async function captureError(model: string): Promise<GatewayError> {
try {
await collect(model)
} catch (error) {
assert.ok(error instanceof GatewayError, `${model} must reject with GatewayError`)
return error
}
assert.fail(`${model} should reject`)
}
async function waitForAttempts(model: string, count: number): Promise<void> {
for (let index = 0; index < 250; index++) {
if (attempts(model).length === count) return
await new Promise((resolve) => setTimeout(resolve, 10))
}
assert.fail(`${model} did not reach ${count} requests`)
}
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
try {
const valid = await collect('valid')
assert.deepEqual(valid.filter((event) => event.type === 'text'), [{ type: 'text', text: 'ok' }])
assert.deepEqual(valid.filter((event) => event.type === 'finish'), [{ type: 'finish', reason: 'stop' }])
assert.deepEqual(valid.filter((event) => event.type === 'usage'), [{
type: 'usage',
usage: { inputTokens: 128, outputTokens: 2, totalTokens: 130, cachedTokens: 32 }
}])
assert.equal(attempts('valid').length, 1)
assert.deepEqual(attempts('valid')[0]?.stream_options, { include_usage: true })
const invalidUsage = await collect('invalid-usage')
assert.equal(invalidUsage.filter((event) => event.type === 'usage').length, 0,
'invalid usage must not emit a usage event')
for (const model of ['legacy-400', 'legacy-422']) {
const events = await collect(model)
assert.deepEqual(events.filter((event) => event.type === 'text'), [{ type: 'text', text: 'ok' }])
assert.deepEqual(events.filter((event) => event.type === 'usage'), [{
type: 'usage',
usage: { inputTokens: 128, outputTokens: 2, totalTokens: 130, cachedTokens: 32 }
}])
const tries = attempts(model)
assert.equal(tries.length, 2, `${model} must retry exactly once`)
assert.deepEqual(tries[0]?.stream_options, { include_usage: true })
assert.equal(Object.hasOwn(tries[1] ?? {}, 'stream_options'), false,
`${model} retry must omit stream_options`)
}
const ordinary = await captureError('ordinary-400')
assert.equal(ordinary.status, 400)
assert.equal(attempts('ordinary-400').length, 1, 'an ordinary 400 must not retry')
assert.equal(ordinary.message.includes('UPSTREAM_SECRET_MUST_NOT_ECHO'), false,
'upstream body content must not be copied into the renderer-facing message')
const auth = await captureError('auth-401')
assert.equal(auth.status, 401)
assert.equal(attempts('auth-401').length, 1, 'authentication failures must not retry')
assert.equal(auth.message.includes('UPSTREAM_SECRET_MUST_NOT_ECHO'), false,
'authorization material must not be copied into the renderer-facing message')
const serverFailure = await captureError('server-500-options')
assert.equal(serverFailure.status, 500)
assert.equal(attempts('server-500-options').length, 1,
'a 5xx response must not retry even when its body names stream_options')
const startedSse = await captureError('started-sse-error')
assert.equal(attempts('started-sse-error').length, 1,
'an error after SSE has started must not trigger another request')
assert.match(startedSse.message, /上游错误/)
const controller = new AbortController()
const aborted = collect('abort-retry', controller.signal)
await waitForAttempts('abort-retry', 2)
controller.abort()
await assert.rejects(aborted, (error: unknown) =>
error instanceof Error && error.name === 'AbortError')
assert.equal(attempts('abort-retry').length, 2,
'the shared AbortSignal must cancel the in-flight compatibility retry')
console.log('chat-completions-usage-regression: all checks passed')
} finally {
for (const response of abortConnections) response.destroy()
server.closeAllConnections?.()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
+24 -1
View File
@@ -15,6 +15,7 @@ interface PersistenceTestState {
cancelOutputEmitted: boolean
useOmp: boolean
fallbackHistory: ChatMessage[] | null
fallbackWireHistory?: Array<{ role: string; content: string }>
}
const TEST_STATE_KEY = '__YCODE_CHAT_PERSISTENCE_TEST__'
@@ -112,7 +113,11 @@ async function main(): Promise<void> {
export const app = {
getPath: (name) => name === 'userData' ? state.userData : state.userData,
getName: () => 'YCode',
getVersion: () => '0.1.0'
getVersion: () => '0.1.0',
isPackaged: false, isReady: () => true,
getAppPath: () => state.userData,
getLoginItemSettings: () => ({ openAtLogin: false }),
setLoginItemSettings: () => {}
};
export const safeStorage = {
isEncryptionAvailable: () => false,
@@ -124,6 +129,7 @@ async function main(): Promise<void> {
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => true, isStarted: () => false };
export class BrowserWindow {}
export class WebContentsView {}
`
@@ -139,6 +145,7 @@ async function main(): Promise<void> {
const emit = (kernel, frame) => kernel.emit('event', frame);
export class OmpKernel extends EventEmitter {
constructor() { super(); }
setThinkingLevel() { return Promise.resolve(); }
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
prompt(message) {
@@ -152,11 +159,13 @@ async function main(): Promise<void> {
});
} else {
setImmediate(() => {
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { input: 1200, output: 10, totalTokens: 1210 } } });
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'before' } });
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'read_file', args: { path: 'large.txt' } } });
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: fullOutput } });
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'after' } });
emit(this, { type: 'tool_execution_end', toolCall: { id: toolId, name: 'read_file', result: { content: [{ type: 'text', text: fullOutput }], isError: false }, success: true } });
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { contextTokens: 12800, input: 12000, cacheRead: 800, output: 200, totalTokens: 13000 } } });
emit(this, { type: 'agent_end', yielded: true });
});
}
@@ -180,6 +189,7 @@ async function main(): Promise<void> {
const state = globalThis.${TEST_STATE_KEY};
export async function runTurn(options) {
state.fallbackHistory = options.history;
state.fallbackWireHistory = options.wireHistory;
const content = 'fallback response';
options.emit({ runId: options.runId, type: 'text', delta: content });
options.emit({ runId: options.runId, type: 'done', content, tools: [] });
@@ -208,6 +218,9 @@ async function main(): Promise<void> {
return Promise.resolve(handler({}, ...args))
}
await invoke('models:save', { id: 'usage-snapshot-model', name: 'Usage snapshot model', providerId: 'custom',
model: 'usage-test', baseUrl: 'http://127.0.0.1:1/v1', authMode: 'none', maxInputTokens: 64000 })
await invoke('models:select', 'usage-snapshot-model')
const normalSession = (await invoke('session:create', 'ordered persistence')) as { id: string }
await invoke('chat:send', { sessionId: normalSession.id, text: 'persist-order' })
const normalMessages = await waitForMessages(invoke, normalSession.id, (messages) => assistant(messages).status === 'done')
@@ -231,6 +244,13 @@ async function main(): Promise<void> {
'the summary may be truncated while full output remains intact')
const diskNormalAssistant = assistant(await readDiskMessages(state.userData, normalSession.id))
assert.deepEqual(normalAssistant.contextUsage, {
usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 }, contextWindow: 64000,
modelId:'usage-snapshot-model',requestId:normalAssistant.requestId,source:'upstream',sampledAt:normalAssistant.contextUsage?.sampledAt
}, 'latest tool-round input is a sample, not the sum of all round usage')
assert.ok((normalAssistant.contextUsage?.sampledAt??0)>=normalAssistant.createdAt,'the persisted sample identifies its observation time')
assert.deepEqual(diskNormalAssistant.contextUsage, normalAssistant.contextUsage,
'the checkpoint whitelist must persist the upstream sample and frozen denominator together')
assert.deepEqual(diskNormalAssistant.parts, expectedParts, 'disk JSON should retain text/tool/text order')
assert.ok((diskNormalAssistant.tools?.[0].output?.length ?? 0) > 600,
'disk JSON should retain output beyond the summary limit')
@@ -246,6 +266,7 @@ async function main(): Promise<void> {
id: 'fallback-user',
role: 'user',
content: 'old request',
quotes: [{ id: 'saved-table', sourceId: 'table-source', kind: 'table', markdown: '| name | value |\n| --- | --- |\n| A | 42 |', preview: 'A 42' }],
createdAt: Date.now(),
status: 'done'
})
@@ -267,6 +288,8 @@ async function main(): Promise<void> {
assert.equal(assistant(fallbackMessages).content, 'fallback response')
assert.deepEqual(state.fallbackHistory?.map((message) => message.content), ['old request', 'old answer'],
'the fallback loop should receive previously persisted user and assistant messages')
assert.equal(state.fallbackWireHistory?.[0].content, 'old request\n\n| name | value |\n| --- | --- |\n| A | 42 |',
'the next fallback request must include the exact previously quoted table snapshot')
state.useOmp = true
const cancelSession = (await invoke('session:create', 'cancelled persistence')) as { id: string }
+645 -9
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import { useChatStore } from '../src/renderer/src/stores/chat'
import type { SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
import type { ActiveChatRun, SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, ToolCallRecord } from '../src/shared/types'
const session: SessionMeta = { id: 'session-1', title: 'Regression', createdAt: 1, updatedAt: 1 }
@@ -31,18 +31,64 @@ function sendResult(runId: string): SendResult {
}
}
function activeRunSnapshot(
runId: string,
content = 'snapshot',
sequence = 5,
sessionId = session.id,
contextUsage?: ContextUsageSnapshot
): ActiveChatRun {
const userMessage: ChatMessage = {
id: `${runId}-user`,
role: 'user',
content: 'question',
requestId: runId,
createdAt: 10,
status: 'done'
}
const assistant: ChatMessage = {
id: `${runId}-assistant`,
role: 'assistant',
content,
requestId: runId,
modelId: 'model-profile-1',
modelName: 'Frozen Model',
contextUsage,
tools: [],
parts: content ? [{ type: 'text', text: content }] : [],
createdAt: 11,
turnStartedAt: 10,
status: 'streaming'
}
return {
runId,
assistantMessageId: assistant.id,
modelId: 'model-profile-1',
modelName: 'Frozen Model',
userMessage,
messages: [userMessage, assistant],
sessionId,
sequence
}
}
function resetStore(): void {
useChatStore.setState({
sessions: [session],
currentSessionId: session.id,
messages: [],
sessionMessages: {},
sessionUsage: {},
sessionContextWindow: {},
runs: {},
runSequences: {},
sendingSessions: [],
running: null,
sending: false,
loading: false,
error: null
error: null,
draftContext: {},
draftId: 0
})
}
@@ -51,14 +97,38 @@ function installFakeWindow(send: (payload: { sessionId: string; text: string })
session: {
list: async () => [session],
create: async () => session,
messages: async () => [],
messages: async (id: string) => useChatStore.getState().sessionMessages[id] ?? [],
rename: async () => undefined,
remove: async () => undefined
remove: async () => undefined,
updateContext: async (_id: string, patch: object) => ({ ...session, ...patch })
},
chat: {
send,
cancel: async () => ({ cancelled: true }),
onEvent: () => () => undefined
revert: async () => ({ messages: [] }),
activeRun: async (sessionId: string) => {
const state = useChatStore.getState()
const run = Object.values(state.runs).find((item) => item.sessionId === sessionId)
if (!run) return null
const messages = state.sessionMessages[sessionId]
?? (state.currentSessionId === sessionId ? state.messages : [])
const userMessage = messages.find((message) => message.role === 'user')
const assistant = messages.find((message) => message.id === run.assistantId)
if (!userMessage || !assistant) return null
return {
runId: run.runId,
assistantMessageId: run.assistantId,
modelId: assistant.modelId,
modelName: assistant.modelName,
assistantDisplay: assistant.assistantDisplay,
userMessage,
messages,
sessionId,
sequence: state.runSequences[run.runId] ?? 0
}
},
onEvent: () => () => undefined,
onStarted: () => () => undefined
}
}
Object.defineProperty(globalThis, 'window', {
@@ -73,12 +143,546 @@ function assistantMessage(): ChatMessage {
return message
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void; reject(reason?: unknown): void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve }
return { promise, resolve, reject }
}
async function testFailedCreatedSessionIsRemoved(): Promise<void> {
resetStore()
const created = { ...session, id: 'created-empty', title: '' }
const switched = deferred<void>()
const sendRejected = deferred<SendResult>()
let removeCalls = 0
installFakeWindow(async () => {
switched.resolve()
return sendRejected.promise
})
window.ycode.session.create = async () => created
window.ycode.session.list = async () => [created]
window.ycode.session.messages = async () => []
window.ycode.session.remove = async (id, options) => {
assert.equal(id, created.id)
assert.deepEqual(options, { onlyIfEmpty: true }, 'automatic cleanup must use the atomic empty-session delete')
removeCalls++
}
useChatStore.setState({ currentSessionId: null, draftId: 7, draftContext: { workspaceId: 'space-draft' } })
const sending = useChatStore.getState().send('first draft')
await switched.promise
sendRejected.reject(new Error('send rejected after create'))
await assert.rejects(sending, /send rejected after create/)
const state = useChatStore.getState()
assert.equal(removeCalls, 1, 'an unstarted, message-free session created for this send must be deleted')
assert.equal(state.currentSessionId, null, 'the UI must return to a real new-task state')
assert.deepEqual(state.messages, [])
assert.equal(created.id in state.sessionMessages, false)
assert.equal(created.id in state.sessionUsage, false)
assert.equal(created.id in state.sessionContextWindow, false)
assert.equal(state.sessions.some((item) => item.id === created.id), false)
assert.equal(state.draftId, 7, 'failure recovery must preserve the draft owner id')
assert.deepEqual(state.draftContext, { workspaceId: 'space-draft' }, 'failure recovery must preserve draft context')
assert.equal(state.sending, false)
assert.match(state.error ?? '', /send rejected after create/)
}
async function testFailedCreatedSessionWithMessagesIsRetained(): Promise<void> {
resetStore()
const created = { ...session, id: 'created-with-message', title: 'Persisted' }
const persisted: ChatMessage = { id: 'persisted-user', role: 'user', content: 'persisted prompt', createdAt: 20, status: 'done' }
let removeCalls = 0
installFakeWindow(async () => { throw new Error('start turn failed') })
window.ycode.session.create = async () => created
window.ycode.session.list = async () => [created]
window.ycode.session.messages = async () => [persisted]
window.ycode.session.remove = async () => { removeCalls++ }
useChatStore.setState({ currentSessionId: null, draftId: 8 })
await assert.rejects(useChatStore.getState().send('must persist'), /start turn failed/)
const state = useChatStore.getState()
assert.equal(removeCalls, 0, 'a session with persisted messages must never be deleted')
assert.equal(state.currentSessionId, created.id, 'the real failed session must stay selected')
assert.deepEqual(state.messages, [persisted])
assert.equal(state.sessions.some((item) => item.id === created.id), true)
assert.equal(state.sending, false)
assert.match(state.error ?? '', /start turn failed.*已有持久化消息/)
}
async function testUnsafeCleanupDoesNotMasqueradeAsHome(): Promise<void> {
resetStore()
const created = { ...session, id: 'created-cleanup-failed', title: '' }
installFakeWindow(async () => { throw new Error('runtime unavailable') })
window.ycode.session.create = async () => created
window.ycode.session.list = async () => [created]
window.ycode.session.messages = async () => []
window.ycode.session.remove = async () => { throw new Error('remove denied') }
useChatStore.setState({ currentSessionId: null, draftId: 9 })
await assert.rejects(useChatStore.getState().send('keep failure visible'), /runtime unavailable/)
const state = useChatStore.getState()
assert.equal(state.currentSessionId, created.id, 'failed cleanup must retain the real session')
assert.equal(state.sessions.some((item) => item.id === created.id), true)
assert.equal(state.messages.length, 2, 'the failed user/assistant pair must remain visible')
assert.equal(state.messages[1].status, 'error')
assert.match(state.error ?? '', /空会话清理失败.*remove denied/)
}
async function testBufferedBackgroundEventPreventsDeletion(): Promise<void> {
resetStore()
const created = { ...session, id: 'created-with-early-event', title: '' }
let removeCalls = 0
installFakeWindow(async () => {
useChatStore.getState().applyEvent({
sessionId: created.id,
runId: 'background-before-response',
type: 'text',
delta: 'background started'
})
throw new Error('response lost')
})
window.ycode.session.create = async () => created
window.ycode.session.list = async () => [created]
window.ycode.session.messages = async () => []
window.ycode.session.remove = async () => { removeCalls++ }
useChatStore.setState({ currentSessionId: null, draftId: 11 })
await assert.rejects(useChatStore.getState().send('race event'), /response lost/)
const state = useChatStore.getState()
assert.equal(removeCalls, 0, 'an early run event proves deletion is unsafe')
assert.equal(state.currentSessionId, created.id)
assert.equal(state.sessions.some((item) => item.id === created.id), true)
assert.equal(state.messages.length, 2, 'the retained failed send remains visible')
assert.match(state.error ?? '', /检测到后台回合/)
}
async function testLateSendFailureCannotOverrideAnotherTask(): Promise<void> {
resetStore()
const created = { ...session, id: 'created-race', title: '' }
const sendStarted = deferred<void>()
const sendResponse = deferred<SendResult>()
let removeCalls = 0
installFakeWindow(async () => {
sendStarted.resolve()
return sendResponse.promise
})
window.ycode.session.create = async () => created
window.ycode.session.list = async () => [created]
window.ycode.session.messages = async () => []
window.ycode.session.remove = async () => { removeCalls++ }
useChatStore.setState({ currentSessionId: null, draftId: 10 })
const sending = useChatStore.getState().send('race prompt')
await sendStarted.promise
useChatStore.getState().startTask({ projectId: 'newer-task' })
const switchedDraftId = useChatStore.getState().draftId
sendResponse.reject(new Error('late failure'))
await assert.rejects(sending, /late failure/)
const state = useChatStore.getState()
assert.equal(removeCalls, 1)
assert.equal(state.currentSessionId, null, 'a late failure must not replace the newer task selection')
assert.equal(state.draftId, switchedDraftId, 'a late failure must not advance or rewind the draft owner')
assert.deepEqual(state.draftContext, { projectId: 'newer-task' })
assert.equal(state.sessions.some((item) => item.id === created.id), false)
}
async function testModelChangePreservesFrozenUsage(): Promise<void> {
resetStore()
const used = { inputTokens: 120, outputTokens: 30, totalTokens: 150 }
const stale = { inputTokens: 900, outputTokens: 50, totalTokens: 950 }
useChatStore.setState({
messages: [{
id: 'model-history-assistant',
role: 'assistant',
content: 'history',
createdAt: 1,
contextUsage: { usage: used, contextWindow: 64_000 }
}],
sessionUsage: { [session.id]: stale },
sessionContextWindow: { [session.id]: 128_000 }
})
installFakeWindow(async () => sendResult('unused'))
window.ycode.session.updateContext = async (_id, patch) => ({ ...session, ...patch })
await useChatStore.getState().selectModel('model-next')
assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used,
'changing the selected model must restore the latest sample from retained history')
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000,
'changing the selected model must retain that sample’s frozen denominator')
useChatStore.setState({ sessionUsage: { [session.id]: used } })
window.ycode.session.updateContext = async () => { throw new Error('model update failed') }
await assert.rejects(useChatStore.getState().selectModel('model-rejected'), /model update failed/)
assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used,
'a rejected model change must leave the existing usage sample intact')
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000)
}
async function testRevertRestoresBranchUsage(): Promise<void> {
resetStore()
let received: { sessionId: string; messageId: string; options: unknown } | null = null
installFakeWindow(async () => sendResult('unused'))
window.ycode.chat.revert = async (sessionId, messageId, options) => {
received = { sessionId, messageId, options }
return {
messages: [{
id: 'kept-assistant',
role: 'assistant',
content: 'kept',
createdAt: 1,
contextUsage: {
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
contextWindow: 64_000
}
}]
}
}
useChatStore.setState({
sessionUsage: { [session.id]: { inputTokens: 900, outputTokens: 50, totalTokens: 950 } },
sessionContextWindow: { [session.id]: 128_000 }
})
await useChatStore.getState().revert('user-message', { rollbackArtifacts: false })
assert.deepEqual(received, {
sessionId: session.id,
messageId: 'user-message',
options: { rollbackArtifacts: false }
}, 'revert options must be forwarded without changing the default artifact behavior')
assert.deepEqual(useChatStore.getState().sessionUsage[session.id],
{ inputTokens: 120, outputTokens: 30, totalTokens: 150 },
'rewind must restore the last real usage sample on the retained branch')
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000,
'rewind must restore the denominator frozen into that sample')
window.ycode.chat.revert = async () => ({ messages: [] })
await useChatStore.getState().revert('older-message')
assert.equal(session.id in useChatStore.getState().sessionUsage, false,
'rewind to a branch without usage must clear the abandoned sample')
assert.equal(session.id in useChatStore.getState().sessionContextWindow, false)
}
async function testStoredUsageRestoresPerSessionAndKeepsLegacyUnknown(): Promise<void> {
resetStore()
installFakeWindow(async () => sendResult('unused'))
const branchA: ChatMessage[] = [
{ id: 'a-user-1', role: 'user', content: 'a1', createdAt: 1 },
{
id: 'a-assistant-1',
role: 'assistant',
content: 'a1',
createdAt: 2,
contextUsage: {
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
contextWindow: 64_000
}
},
{ id: 'a-user-2', role: 'user', content: 'a2', createdAt: 3 },
{ id: 'a-streaming', role: 'assistant', content: '', createdAt: 4, status: 'streaming' }
]
const branchB: ChatMessage[] = [{
id: 'b-assistant',
role: 'assistant',
content: 'b',
createdAt: 5,
contextUsage: {
usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 },
contextWindow: 128_000
}
}]
const legacy: ChatMessage[] = [{
id: 'legacy-assistant',
role: 'assistant',
content: 'legacy',
createdAt: 6,
contextUsage: { usage: { inputTokens: 90, outputTokens: 10, totalTokens: 100 } }
}]
window.ycode.session.messages = async (id) => id === 'session-a' ? branchA : id === 'session-b' ? branchB : legacy
await useChatStore.getState().selectSession('session-a')
assert.equal(useChatStore.getState().sessionUsage['session-a'],undefined,
'a new assistant without a sample must not masquerade as the previous request')
assert.equal(useChatStore.getState().sessionContextWindow['session-a'],undefined)
await useChatStore.getState().selectSession('session-b')
assert.deepEqual(useChatStore.getState().sessionUsage['session-b'],
{ inputTokens: 300, outputTokens: 40, totalTokens: 340 })
assert.equal(useChatStore.getState().sessionContextWindow['session-b'], 128_000)
assert.equal(useChatStore.getState().sessionContextWindow['session-a'], undefined,
'selecting another task must not resurrect a superseded sample')
await useChatStore.getState().selectSession('legacy-session')
assert.deepEqual(useChatStore.getState().sessionUsage['legacy-session'],
{ inputTokens: 90, outputTokens: 10, totalTokens: 100 })
assert.equal('legacy-session' in useChatStore.getState().sessionContextWindow, false,
'historical samples without a denominator must stay unknown instead of borrowing the selected model window')
}
async function testUsageEventsKeepLatestSampleAndProjectIntoAssistant(): Promise<void> {
resetStore()
const result = sendResult('run-context-usage')
installFakeWindow(async () => result)
await useChatStore.getState().send('question')
useChatStore.getState().applyEvent({
runId: result.runId,
type: 'usage',
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
contextWindow: 64_000
})
useChatStore.getState().applyEvent({
runId: result.runId,
type: 'usage',
usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 },
contextWindow: 128_000
})
assert.deepEqual(useChatStore.getState().sessionUsage[session.id],
{ inputTokens: 300, outputTokens: 40, totalTokens: 340 },
'multiple usage events must use the latest sample, never a sum')
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 128_000)
assert.deepEqual(assistantMessage().contextUsage, {
requestId:result.runId,
usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 },
contextWindow: 128_000
}, 'the live assistant sample must match the recovered session sample')
useChatStore.getState().applyEvent({
runId: result.runId,
type: 'usage',
usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 }
})
assert.equal(useChatStore.getState().sessionContextWindow[session.id], undefined,
'a newer usage event without a real denominator must clear the previous denominator')
assert.deepEqual(assistantMessage().contextUsage, {
requestId:result.runId,
usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 }
})
}
async function testReopenedActiveRunUsesSnapshotSequence(): Promise<void> {
resetStore()
const active = deferred<ActiveChatRun | null>()
const contextUsage: ContextUsageSnapshot = {
usage: { inputTokens: 128, outputTokens: 8, totalTokens: 136 },
contextWindow: 64_000
}
installFakeWindow(async () => sendResult('unused'))
window.ycode.chat.activeRun = async (sessionId) => {
assert.equal(sessionId, 'reopened-session')
return active.promise
}
window.ycode.session.messages = async () => {
throw new Error('active run hydration must not fall back to disk')
}
const selection = useChatStore.getState().selectSession('reopened-session')
useChatStore.getState().applyEvent({
sessionId: 'reopened-session',
runId: 'run-reopen',
sequence: 6,
type: 'text',
delta: ' queued'
})
active.resolve(activeRunSnapshot('run-reopen', 'snapshot', 5, 'reopened-session', contextUsage))
await selection
const state = useChatStore.getState()
assert.equal(assistantMessage().content, 'snapshot queued',
'events after the active snapshot watermark must be replayed exactly once')
assert.equal(assistantMessage().status, 'streaming')
assert.equal(state.running?.runId, 'run-reopen')
assert.equal(state.runSequences['run-reopen'], 6)
assert.deepEqual(state.sessionUsage['reopened-session'], contextUsage.usage,
'an active snapshot must restore its persisted latest usage sample')
assert.equal(state.sessionContextWindow['reopened-session'], 64_000,
'an active snapshot must restore the turn’s frozen context window')
useChatStore.getState().applyEvent({
sessionId: 'reopened-session',
runId: 'run-reopen',
sequence: 6,
type: 'text',
delta: ' duplicate'
})
assert.equal(assistantMessage().content, 'snapshot queued', 'a duplicate sequence must be ignored')
}
async function testStartedSnapshotAdoptionAcrossWindows(): Promise<void> {
resetStore()
const localMessage: ChatMessage = {
id: 'local-other',
role: 'user',
content: 'other task',
createdAt: 1,
status: 'done'
}
useChatStore.setState({
currentSessionId: 'other-session',
messages: [localMessage],
sessionMessages: { 'other-session': [localMessage] },
runs: {},
runSequences: {},
sendingSessions: []
})
const background = activeRunSnapshot('run-background', 'background', 0)
useChatStore.getState().observeRun(background)
assert.deepEqual(useChatStore.getState().messages, [localMessage],
'a started snapshot for another task must not steal focus')
assert.equal(useChatStore.getState().sessionMessages[session.id]?.[1].content, 'background')
assert.equal(useChatStore.getState().runs['run-background']?.sessionId, session.id)
assert.equal(useChatStore.getState().running, null,
'a background started snapshot must update runs without changing the current running task')
useChatStore.setState({
currentSessionId: session.id,
messages: [],
sessionMessages: {},
runs: {},
runSequences: {},
sendingSessions: [session.id]
})
const local = activeRunSnapshot('run-local-send', 'local result', 0)
useChatStore.getState().observeRun(local)
assert.deepEqual(useChatStore.getState().messages, [],
'the window owning a pending local send must ignore its started broadcast')
assert.deepEqual(useChatStore.getState().runs, {})
useChatStore.setState({ sendingSessions: [] })
useChatStore.getState().observeRun(local)
const adopted = useChatStore.getState()
assert.equal(adopted.messages[1].content, 'local result')
assert.equal(adopted.running?.runId, 'run-local-send')
assert.equal(adopted.sending, false)
}
async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise<void> {
resetStore()
const initial = activeRunSnapshot('run-watermark', 'old', 2)
useChatStore.setState({
currentSessionId: session.id,
messages: initial.messages,
sessionMessages: { [session.id]: initial.messages },
runs: { 'run-watermark': { runId: 'run-watermark', assistantId: 'run-watermark-assistant', sessionId: session.id } },
runSequences: { 'run-watermark': 2 }
})
const active = deferred<ActiveChatRun | null>()
installFakeWindow(async () => sendResult('unused'))
window.ycode.chat.activeRun = async () => active.promise
const selection = useChatStore.getState().selectSession(session.id)
useChatStore.getState().applyEvent({
sessionId: session.id,
runId: 'run-watermark',
sequence: 2,
type: 'text',
delta: ' duplicate'
})
useChatStore.getState().applyEvent({
sessionId: session.id,
runId: 'run-watermark',
sequence: 8,
type: 'text',
delta: ' after'
})
active.resolve(activeRunSnapshot('run-watermark', 'fresh', 7))
await selection
assert.equal(assistantMessage().content, 'fresh after',
'hydration must buffer events for a known run and replay only sequence greater than the snapshot')
assert.equal(useChatStore.getState().runSequences['run-watermark'], 8)
useChatStore.getState().applyEvent({
sessionId: session.id,
runId: 'run-watermark',
sequence: 8,
type: 'text',
delta: ' duplicate'
})
assert.equal(assistantMessage().content, 'fresh after')
}
async function testLateTerminalPreventsHydratedRunResurrection(): Promise<void> {
resetStore()
const active = deferred<ActiveChatRun | null>()
installFakeWindow(async () => sendResult('unused'))
window.ycode.chat.activeRun = async () => active.promise
const selection = useChatStore.getState().selectSession('terminal-session')
useChatStore.getState().applyEvent({
sessionId: 'terminal-session',
runId: 'run-late-terminal',
sequence: 4,
type: 'done',
content: 'finished',
tools: []
})
active.resolve(activeRunSnapshot('run-late-terminal', 'snapshot', 3, 'terminal-session'))
await selection
const state = useChatStore.getState()
assert.equal(assistantMessage().content, 'finished')
assert.equal(assistantMessage().status, 'done')
assert.equal(state.runs['run-late-terminal'], undefined, 'a late active snapshot must not revive a completed run')
assert.equal(state.running, null)
assert.equal(state.runSequences['run-late-terminal'], 4)
}
async function testTerminalActiveSnapshotDoesNotCreateRunning(): Promise<void> {
for (const status of ['done', 'cancelled', 'error'] as const) {
resetStore()
installFakeWindow(async () => sendResult('unused'))
const snapshot = activeRunSnapshot(`run-terminal-snapshot-${status}`, 'finished', 9)
const assistant = snapshot.messages?.find((message) => message.id === snapshot.assistantMessageId)
assert.ok(assistant)
assistant.status = status
if (status === 'error') assistant.error = 'snapshot failed'
window.ycode.chat.activeRun = async () => snapshot
await useChatStore.getState().selectSession(session.id)
const state = useChatStore.getState()
assert.equal(state.messages.find((message) => message.id === snapshot.assistantMessageId)?.status, status)
assert.equal(state.running, null, `${status}: terminal activeRun snapshot must not create a running task`)
assert.equal(state.runs[snapshot.runId], undefined, `${status}: terminal snapshot must not enter runs`)
assert.equal(state.runSequences[snapshot.runId], snapshot.sequence)
assert.equal(state.loading, false)
if (status === 'error') assert.equal(state.error, 'snapshot failed')
}
}
async function testLateStartedCannotReviveCompletedRun(): Promise<void> {
resetStore()
installFakeWindow(async () => sendResult('unused'))
const started = activeRunSnapshot('run-completed-watermark', 'started', 0)
useChatStore.getState().observeRun(started)
useChatStore.getState().applyEvent({
sessionId: session.id,
runId: started.runId,
sequence: 5,
type: 'done',
content: 'completed',
tools: []
})
assert.equal(useChatStore.getState().runs[started.runId], undefined)
assert.equal(useChatStore.getState().runSequences[started.runId], 5)
useChatStore.getState().observeRun(activeRunSnapshot(started.runId, 'late started', 0))
const state = useChatStore.getState()
assert.equal(state.runs[started.runId], undefined,
'a late started snapshot at or below the completed watermark must not recreate the run')
assert.equal(state.running, null)
assert.equal(assistantMessage().content, 'completed')
assert.equal(assistantMessage().status, 'done')
}
async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
@@ -325,6 +929,38 @@ async function testParallelSessionsAndStaleLoads(): Promise<void> {
}
async function main(): Promise<void> {
resetStore()
let inlinePayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') })
await useChatStore.getState().send(' 前后 ', { quotes: [{ id: 'q', kind: 'table', sourceId: 'source', markdown: '| x |', preview: '| x |' }],
userContent: [{ type: 'text', text: ' 前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后 ' }] })
assert.equal(inlinePayload?.text, '前后')
assert.deepEqual(inlinePayload?.userContent, [{ type: 'text', text: '前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后' }],
'trimmed edit and retry submissions must keep the inline reference order')
resetStore()
let referenceOnlySends = 0
installFakeWindow(async () => { referenceOnlySends++; return sendResult('reference-only') })
await useChatStore.getState().send('', { resources: ['C:/workspace/reference.md'] })
assert.equal(referenceOnlySends, 1, 'a resource-only draft must reach IPC instead of being silently discarded')
resetStore()
installFakeWindow(async () => { referenceOnlySends++; return sendResult('skill-only') })
await useChatStore.getState().send('', { skillIds: ['skill'] })
assert.equal(referenceOnlySends, 2, 'a skill-only draft must reach IPC')
await testFailedCreatedSessionIsRemoved()
await testFailedCreatedSessionWithMessagesIsRetained()
await testUnsafeCleanupDoesNotMasqueradeAsHome()
await testBufferedBackgroundEventPreventsDeletion()
await testLateSendFailureCannotOverrideAnotherTask()
await testModelChangePreservesFrozenUsage()
await testRevertRestoresBranchUsage()
await testStoredUsageRestoresPerSessionAndKeepsLegacyUnknown()
await testUsageEventsKeepLatestSampleAndProjectIntoAssistant()
await testReopenedActiveRunUsesSnapshotSequence()
await testStartedSnapshotAdoptionAcrossWindows()
await testHydrationBuffersKnownRunAndFiltersByWatermark()
await testLateTerminalPreventsHydratedRunResurrection()
await testTerminalActiveSnapshotDoesNotCreateRunning()
await testLateStartedCannotReviveCompletedRun()
await testEarlyOrderedEventsAndDuplicateGuard()
await testLiveTurnStartTimestamp()
await testEarlyTerminalEvents()
+3 -3
View File
@@ -167,13 +167,13 @@ const composerMarkup = renderToStaticMarkup(
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
)
assert.match(composerMarkup, /语音转写服务不可用/, 'without an ASR callback, voice input reports unavailable')
assert.match(composerMarkup, /默认沙箱/)
assert.match(composerMarkup, /默认权限/)
assert.match(composerMarkup, /添加/)
assert.match(composerMarkup, /Model slot/)
assert.match(composerMarkup, /Workspace slot/)
assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语音转写服务不可用'), true)
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('默认沙箱'))
assert.ok(composerMarkup.indexOf('默认沙箱') < composerMarkup.indexOf('cr-input-toolbar__right'), 'permission control belongs in the left toolbar')
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('默认权限'))
assert.ok(composerMarkup.indexOf('默认权限') < composerMarkup.indexOf('cr-input-toolbar__right'), 'permission control belongs in the left toolbar')
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(activeRunMarkup, /aria-label="停止"/)
+171
View File
@@ -0,0 +1,171 @@
/** Reconfigure the user's YCode expert packages through the production expert API. Never reads WorkBuddy. */
import fs from 'node:fs/promises'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { build } from 'esbuild'
const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export const definitions = JSON.parse(await fs.readFile(path.join(project, 'resources/agents/general-development.json'), 'utf8'))
const hash = value => createHash('sha256').update(value).digest('hex')
export function packageFiles(agent) {
const manifest = {
name: agent.id, plugin: agent.id, agentName: agent.agentName, version: '2.1.0', expertType: 'agent',
description: agent.description, displayName: agent.displayName, profession: agent.profession,
displayDescription: agent.displayDescription, categoryId: '02-Engineering', author: { name: 'YCode' },
agents: [`./agents/${agent.agentName}.md`], skills: [], tags: agent.tags,
defaultInitPrompt: agent.quickPrompts[0], quickPrompts: agent.quickPrompts, avatar: agent.avatar,
ycode: { generalDevelopmentRole: agent.role, definitionVersion: definitions.version }
}
const prompt = [`# ${agent.profession.zh}`, '', ...definitions.commonInstructions, '', '## 工作职责', '', ...agent.instructions].join('\n\n')
return {
'.codebuddy-plugin/plugin.json': JSON.stringify(manifest, null, 2) + '\n',
[`agents/${agent.agentName}.md`]: `---\nname: ${agent.agentName}\ndescription: ${JSON.stringify(agent.description)}\nmaxTurns: 50\nskills: []\n---\n\n${prompt}\n`,
'README.md': `# ${agent.profession.zh}\n\n${agent.displayDescription.zh}\n\n这是用户定制的通用开发角色,职责、提示词和快捷提问来自 YCode 的 general-development.json。\n角色配置不授予额外工具权限。\n`
}
}
async function ordinaryDirectory(target) {
const absolute = path.resolve(target)
let current = path.parse(absolute).root
for (const part of path.relative(current, absolute).split(path.sep)) {
current = path.join(current, part)
const stat = await fs.lstat(current)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('Ordinary directory required: ' + current)
}
}
async function inventory(root) {
const items = []
async function walk(directory) {
await ordinaryDirectory(directory)
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isSymbolicLink()) throw Error('Links are not supported: ' + file)
if (entry.isDirectory()) await walk(file)
else if (entry.isFile()) items.push([path.relative(root, file), hash(await fs.readFile(file))])
else throw Error('Ordinary file required: ' + file)
}
}
await walk(root)
return items.sort(([left], [right]) => left.localeCompare(right))
}
async function avatarResource(agent) {
if (!/^[a-z-]+$/.test(agent.role) || agent.avatar !== `./avatars/${agent.role}.png`) throw Error('Invalid general role avatar: ' + agent.id)
const bytes = await fs.readFile(path.join(project, 'resources/agents', agent.avatar))
if (bytes.length > 500 * 1024 || bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
|| bytes.readUInt32BE(16) !== 256 || bytes.readUInt32BE(20) !== 256) throw Error('A 256px PNG avatar below 500KiB is required: ' + agent.id)
return { bytes, url: 'data:image/png;base64,' + bytes.toString('base64'), sha256: hash(bytes) }
}
async function installAvatar(packageDirectory, agent, avatar) {
await ordinaryDirectory(packageDirectory)
const directory = path.join(packageDirectory, 'avatars')
await fs.mkdir(directory, { recursive: true })
await ordinaryDirectory(directory)
const target = path.join(directory, `${agent.role}.png`)
try { const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('Avatar must be an ordinary file: ' + target) }
catch (error) { if (error.code !== 'ENOENT') throw error }
const temporary = target + '.' + randomUUID() + '.tmp'
await fs.writeFile(temporary, avatar.bytes, { flag: 'wx' })
try { await fs.rename(temporary, target) } finally { await fs.rm(temporary, { force: true }) }
if (hash(await fs.readFile(target)) !== avatar.sha256) throw Error('Installed avatar differs: ' + agent.id)
}
async function loadService(userData, directory) {
const modulePath = path.join(directory, 'expert-service.mjs')
await build({ stdin: { contents: "export * from './src/main/capabilities/experts'", resolveDir: project, loader: 'ts' },
outfile: modulePath, bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning',
plugins: [{ name: 'explicit-ycode-data', setup(builder) {
builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'explicit-ycode-data' }))
builder.onLoad({ filter: /.*/, namespace: 'explicit-ycode-data' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(userData)}};export const safeStorage={isEncryptionAvailable:()=>false}` }))
} }] })
return import(pathToFileURL(modulePath).href)
}
export async function configure(userData, { checkOnly = false } = {}) {
userData = path.resolve(userData)
if (path.basename(userData).toLowerCase() !== 'ycode') throw Error('An explicit YCode user-data folder is required')
await ordinaryDirectory(userData)
const reports = await fs.mkdtemp(path.join(project, '.tmp/general-development-agents-'))
const service = await loadService(userData, reports)
const marketRoot = path.join(userData, 'plugins/marketplaces/my-experts')
const packageRoot = path.join(marketRoot, 'plugins')
const marketFile = path.join(marketRoot, '.codebuddy-plugin/marketplace.json')
const beforeMarket = JSON.parse(await fs.readFile(marketFile, 'utf8'))
const ids = new Set(definitions.agents.map(agent => agent.id))
const unrelated = beforeMarket.plugins.filter(entry => !ids.has(entry.name))
const plans = []
for (const agent of definitions.agents) {
const files = packageFiles(agent), validation = service.validateExpertFiles(files, agent.id)
const avatar = await avatarResource(agent)
if (validation.errors.length) throw Error(validation.errors.join('\n'))
let previous
try { previous = await service.readExpert(agent.id) } catch (error) { if (error.code !== 'ENOENT') throw error }
if (previous && previous.expert.agentName !== agent.agentName) throw Error('Agent identity changed: ' + agent.id)
const alreadyConfigured = previous && Object.entries(files).every(([name, content]) => previous.files[name] === content)
&& Object.keys(previous.files).every(name => name in files || name === '.created-by-session')
&& previous.expert.avatar === avatar.url
plans.push({ agent, files, avatar, previous, alreadyConfigured, warnings: validation.warnings })
}
let backup
const changed = plans.filter(plan => !plan.alreadyConfigured)
if (checkOnly && changed.length) throw Error('Installed definitions differ: ' + changed.map(plan => plan.agent.role).join(', '))
if (changed.length) {
backup = path.join(userData, 'capabilities/expert-backups', 'general-development-' + randomUUID())
const backupRelative = path.relative(userData, backup)
if (path.isAbsolute(backupRelative) || backupRelative.startsWith('..') || !backupRelative) throw Error('Backup path escaped YCode')
await fs.mkdir(backup, { recursive: true })
await ordinaryDirectory(backup)
await fs.writeFile(path.join(backup, 'marketplace.before.json'), JSON.stringify(beforeMarket, null, 2), { flag: 'wx' })
const snapshots = []
for (const { agent, previous } of changed) {
if (!previous) { snapshots.push({ id: agent.id, existed: false }); continue }
const source = path.join(packageRoot, agent.id), target = path.join(backup, agent.id)
const before = await inventory(source)
await fs.cp(source, target, { recursive: true, errorOnExist: true, force: false })
if (JSON.stringify(before) !== JSON.stringify(await inventory(target)) || JSON.stringify(before) !== JSON.stringify(await inventory(source))) throw Error('Expert changed while backing up: ' + agent.id)
snapshots.push({ id: agent.id, existed: true, files: before })
}
await fs.writeFile(path.join(backup, 'backup.json'), JSON.stringify({ source: userData, snapshots }, null, 2), { flag: 'wx' })
const completed = []
for (const { agent, files, avatar, previous } of changed) {
const owner = 'general-development-' + randomUUID()
const draft = await service.expertPackageOperation(owner, { id: agent.id, action: 'read', refresh: true })
if (JSON.stringify(draft.files) !== JSON.stringify(previous?.files ?? {})) throw Error('Expert changed after backup: ' + agent.id)
const edits = Object.fromEntries(Object.keys(draft.files).filter(name => !(name in files) && !['.created-by-session', '.pending-preview-session'].includes(name)).map(name => [name, null]))
await service.expertPackageOperation(owner, { id: agent.id, action: 'write', files: { ...edits, ...files } })
const validation = await service.expertPackageOperation(owner, { id: agent.id, action: 'validate' })
if (!validation.valid) throw Error(validation.errors.join('\n'))
const result = await service.expertPackageOperation(owner, { id: agent.id, action: 'register' })
if (!result.success) throw Error('Expert registration failed: ' + agent.id)
// The text-only editing API preserves binaries but cannot add them; install our verified PNG separately.
await installAvatar(path.join(packageRoot, agent.id), agent, avatar)
completed.push(agent.id)
await fs.writeFile(path.join(backup, 'applied.json'), JSON.stringify({ completed }, null, 2))
}
}
const checks = []
const listed = await service.listExperts()
for (const { agent, files, avatar } of plans) {
const document = await service.readExpert(agent.id)
const binding = await service.activateExpert(agent.id)
const turn = await service.expertTurnContext(agent.id)
if (!listed.some(item => item.id === agent.id && !item.error) || binding.profession !== agent.profession.zh
|| binding.name !== agent.displayName.zh || turn.display.profession !== agent.profession.zh) throw Error('Role activation mismatch: ' + agent.id)
for (const [name, content] of Object.entries(files)) if (document.files[name] !== content) throw Error('Role text mismatch: ' + name)
if (document.expert.skills?.length || turn.context.includes('<expert-skill path=') || turn.context.includes('<expert-skill-directory>')) throw Error('Specialist skill remains loaded: ' + agent.id)
if (Object.keys(document.files).some(name => !(name in files) && name !== '.created-by-session')) throw Error('Unexpected specialist text remains: ' + agent.id)
if (!turn.context.includes(document.prompt) || !agent.instructions.every(instruction => turn.context.includes(instruction))) throw Error('Role instructions did not reach turn context')
if (document.expert.avatar !== avatar.url || binding.avatarUrl !== avatar.url) throw Error('Avatar did not reach the list/detail/activation data: ' + agent.id)
checks.push({ id: agent.id, role: agent.role, profession: binding.profession, activated: true, filesMatched: Object.keys(files).length, startupSkills: 0,
avatarBytes: avatar.bytes.length, avatarSha256: avatar.sha256 })
}
const afterMarket = JSON.parse(await fs.readFile(marketFile, 'utf8'))
if (JSON.stringify(unrelated) !== JSON.stringify(afterMarket.plugins.filter(entry => !ids.has(entry.name)))) throw Error('Unrelated expert catalog entries changed')
const report = { result: 'PASS', checkOnly, userData, backup, changed: changed.map(plan => plan.agent.id), checks,
warnings: plans.flatMap(plan => plan.warnings.map(warning => ({ id: plan.agent.id, warning }))), messagesSent: 0 }
await fs.writeFile(path.join(reports, 'report.json'), JSON.stringify(report, null, 2))
return { ...report, reportDirectory: reports }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const index = process.argv.indexOf('--user-data')
if (index < 0 || !process.argv[index + 1]) throw Error('Pass --user-data <absolute YCode user-data path>')
const report = await configure(process.argv[index + 1], { checkOnly: process.argv.includes('--check') })
console.log(JSON.stringify(report, null, 2))
}
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { contextObserverSource, observeOmpContextSamples, prepareContextObserver } from '../src/main/omp-context-observer'
import type { OmpKernel } from '../src/main/omp'
const directory = await mkdtemp(join(tmpdir(), 'ycode-observer-'))
const previous = { config: process.env.YCODE_CONTEXT_REQUEST_FILE, sample: process.env.YCODE_CONTEXT_SAMPLE_FILE }
let stop: (() => void) | undefined
try {
const paths = await prepareContextObserver(directory, 'session')
process.env.YCODE_CONTEXT_REQUEST_FILE = paths.configPath
process.env.YCODE_CONTEXT_SAMPLE_FILE = paths.samplePath
await writeFile(paths.configPath, JSON.stringify({ runId: 'run', sessionPath: '/fixture.jsonl', modelId: 'selected', modelName: 'fixture', origins: [], toolCategories: {} }))
const emitter = new EventEmitter(), frames: Array<{ type: string; breakdown?: { sequence: number } }> = []
stop = observeOmpContextSamples(emitter as OmpKernel, paths.samplePath)
emitter.on('event', frame => frames.push(frame))
const extension = await import(pathToFileURL(paths.extensionPath).href)
let hook: (event: unknown, ctx: unknown) => void = () => { throw Error('hook missing') }
extension.default({ on(name: string, callback: typeof hook) { assert.equal(name, 'before_provider_request'); hook = callback } })
const event = { payload: { messages: [{ role: 'user', content: 'POISON_NEVER_PERSIST_PROMPT' }], tools: [] } }
const context = { sessionManager: { getSessionFile: () => '/fixture.jsonl' }, model: { id: 'fixture' } }
const write = process.stdout.write
let stdoutCalls = 0
try { process.stdout.write = (() => { stdoutCalls++; return true }) as typeof process.stdout.write; hook(event, context) }
finally { process.stdout.write = write }
assert.equal(stdoutCalls, 0, 'observer must not write into the native JSONL channel')
assert.ok(!contextObserverSource().includes('process.stdout'), 'extension cannot interleave its data with an image frame')
const sample = await readFile(paths.samplePath, 'utf8')
assert.ok(!sample.includes('POISON_NEVER_PERSIST_PROMPT'), 'only counts and identity leave the request observer')
emitter.emit('event', { type: 'message_end' })
assert.equal(frames[0].type, 'ycode_context_breakdown', 'sample is observed before request-end usage')
assert.equal(frames[0].breakdown?.sequence, 1)
emitter.emit('event', { type: 'message_end' })
assert.equal(frames.filter(frame => frame.type === 'ycode_context_breakdown').length, 1, 'duplicate notification does not clear a newer provider total')
hook(event, context)
emitter.emit('event', { type: 'message_end' })
assert.equal(frames.at(-2)?.breakdown?.sequence, 2, 'new prepared input precedes the next provider usage frame')
stop(); stop = undefined
const count = frames.length
hook(event, context)
emitter.emit('event', { type: 'message_end' })
assert.equal(frames.length, count + 1, 'retired observers stop injecting sample frames')
console.log('CONTEXT_OBSERVER_TRANSPORT_PASS 7 checks')
} finally {
stop?.()
if (previous.config === undefined) delete process.env.YCODE_CONTEXT_REQUEST_FILE; else process.env.YCODE_CONTEXT_REQUEST_FILE = previous.config
if (previous.sample === undefined) delete process.env.YCODE_CONTEXT_SAMPLE_FILE; else process.env.YCODE_CONTEXT_SAMPLE_FILE = previous.sample
await rm(directory, { recursive: true, force: true })
}
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import { mkdtemp,writeFile,rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createContextUsageService,recoverNativeUsage } from '../src/main/context-usage-service'
import { estimateContextInput,contextInputOrigins } from '../src/main/context-usage-estimator'
import { applyAssistantEvent } from '../src/shared/message-parts'
import { contextInfoFromMessages } from '../src/shared/context-usage'
import type { ChatMessage,ContextUsageBreakdown } from '../src/shared/types'
import { Gateway } from '../src/main/gateway'
const lines=(entries:unknown[])=>entries.map(entry=>JSON.stringify(entry)).join('\n')
const user={id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'hello'}]}}
const first={id:'native-a1',parentId:'native-user',type:'message',message:{role:'assistant',model:'fixture',usage:{input:50,output:10},stopReason:'toolUse',timestamp:100}}
const last={id:'native-a2',parentId:'native-a1',type:'message',message:{role:'assistant',model:'fixture',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:200}}
const history=lines([user,first,last])
assert.equal(recoverNativeUsage(history,'native-user')?.sample.usage.inputTokens,5161,'cache input belongs to input occupancy, not output')
assert.equal(recoverNativeUsage(history,'native-user')?.sample.usage.totalTokens,5286)
assert.equal(recoverNativeUsage(history,'native-user')?.sample.nativeEntryId,'native-a2','use the latest request, never the sum of tool rounds')
assert.equal(recoverNativeUsage(lines([user,first,last,{...last,id:'sibling',parentId:'native-user'}]),'native-user'),undefined,'sibling histories cannot be guessed')
assert.equal(recoverNativeUsage(lines([user,first,{...last,message:{...last.message,stopReason:'error'}}]),'native-user'),undefined,'a failed final request does not inherit earlier tool usage')
const directory=await mkdtemp(join(tmpdir(),'ycode-context-'))
try {
const path=join(directory,'native.jsonl')
await writeFile(path,history)
let messages:ChatMessage[]=[{id:'user',role:'user',content:'hello',createdAt:1,requestId:'run',nativeEntryId:'native-user',nativeSessionPath:path},
{id:'assistant',role:'assistant',content:'done',createdAt:2,requestId:'run',modelId:'model',status:'done'}]
let persisted=0,running=false
let live:(()=>Promise<unknown>)|undefined
const service=createContextUsageService({messages:async()=>messages,modelId:async()=> 'model',nativePath:()=>path,running:()=>running,
liveState:()=>live?.(),persistIfCurrent:async(_id,assistantId,requestId,sample)=>{
const current=messages.findLast(message=>message.role==='assistant')
if(!running&&current?.id===assistantId&&current.requestId===requestId){current.contextUsage=sample;persisted++}
}})
const restored=await service.get('session')
assert.equal(restored.sample?.usage.inputTokens,5161)
assert.equal(restored.sample?.source,'native-history')
assert.equal(restored.sample?.contextWindow,undefined,'do not invent a historical denominator from current settings')
assert.equal(persisted,1)
await service.get('session');assert.equal(persisted,1,'reading a cached sample does not rewrite history repeatedly')
messages[1].contextUsage=undefined
live=async()=>({success:true,data:{sessionFile:path,model:{id:'fixture'},contextUsage:{tokens:5161,contextWindow:64000}}})
assert.equal((await service.get('session')).sample?.contextWindow,64000,'matching live native state can supply its window')
messages[1].contextUsage=undefined
messages[0].nativeSessionPath=join(directory,'outside.jsonl')
assert.equal((await service.get('session')).sample,undefined,'a message cannot redirect the reader to an arbitrary file')
messages[0].nativeSessionPath=path
running=true
assert.equal((await service.get('session')).sample,undefined,'querying a running turn must not recover a previous round')
running=false
let release!:(value:unknown)=>void
live=()=>new Promise(resolve=>{release=resolve})
const stale=service.get('session')
while(!release)await new Promise(resolve=>setTimeout(resolve,1))
messages=[...messages,{id:'new-user',role:'user',content:'next',createdAt:3},{id:'new-assistant',role:'assistant',content:'',createdAt:4,status:'streaming',requestId:'next'}]
release({data:{sessionFile:path,model:{id:'fixture'},contextUsage:{tokens:5161,contextWindow:64000}}})
assert.equal((await stale).sample,undefined,'a delayed query cannot overwrite a newer turn')
assert.equal(persisted,2)
assert.equal(contextInfoFromMessages(messages).sample,undefined,'no traversal past the latest assistant')
}finally{await rm(directory,{recursive:true,force:true})}
const skill='<skill id="skill:test">skill instructions</skill>'
const injected=`Project rules\n\n${skill}`
const input={systemPrompt:'Base system instructions',origins:contextInputOrigins([injected]),
tools:[{type:'function',function:{name:'unused_tool',description:'This declaration is present without a call',parameters:{type:'object'}}},
{name:'mcp_fixture_lookup',description:'MCP lookup',parameters:{type:'object'}}],
messages:[{role:'user',content:`Question\n\n${injected}`},
{role:'assistant',tool_calls:[{id:'call',function:{name:'mcp_fixture_lookup',arguments:'{}'}}]},
{role:'tool',tool_call_id:'call',content:'returned MCP contents'}]}
const counted=estimateContextInput(input)
assert.ok(counted.counts.system>0&&counted.counts.skills>0&&counted.counts.tools>0&&counted.counts.conversation>0&&counted.counts.mcp>0)
assert.equal(Object.values(counted.counts).reduce((a,b)=>a+b,0),counted.inputTokens)
assert.equal(estimateContextInput({messages:[{role:'user',content:skill}]}).counts.skills,0,'ordinary user text is not relabeled by a tag pattern')
assert.equal(estimateContextInput({messages:[{role:'user',content:[{type:'image_url',image_url:{url:'data:image/png;base64,secret'}}]}]}).partial,true,'images are partial, not base64 tokens')
const breakdown:ContextUsageBreakdown={...counted,requestId:'run',sequence:1,sampledAt:1,method:'utf8-estimate',scope:'provider-request'}
let assistant:ChatMessage={id:'a',role:'assistant',content:'',createdAt:0,requestId:'run'}
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown})
assistant=applyAssistantEvent(assistant,{runId:'run',type:'usage',usage:{inputTokens:9999,outputTokens:1,totalTokens:10000}})
assert.equal(assistant.contextUsage?.usage.inputTokens,9999)
assert.equal(assistant.contextBreakdown?.inputTokens,counted.inputTokens,'classification is not rescaled to provider usage')
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown})
assert.equal(assistant.contextUsage?.usage.inputTokens,9999,'a repeated old observer frame cannot clear newer usage')
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown:{...breakdown,sequence:2}})
assert.equal(assistant.contextUsage,undefined,'a new prepared request invalidates the previous provider total')
assert.equal(contextInfoFromMessages([{...assistant,contextBreakdown:breakdown},{id:'pending',role:'user',content:'next',createdAt:5}]).status,'previous',
'an unmatched latest user message marks any retained statistic as previous')
assert.equal(contextInfoFromMessages([{...assistant,requestId:'different',contextBreakdown:breakdown}]).breakdown,undefined,
'a stored classification for a different request is not reused')
// The fallback observes the final body for both supported provider protocols.
const originalFetch=globalThis.fetch
try {
for(const responses of [false,true]) {
let observed:Record<string,unknown>|undefined,transmitted:Record<string,unknown>|undefined
globalThis.fetch=(async(_url,options)=>{
transmitted=JSON.parse(String(options?.body))
const payload=responses
? 'data: '+JSON.stringify({type:'response.completed',response:{id:'reply',status:'completed',output:[],usage:{input_tokens:10,output_tokens:1,total_tokens:11}}})+'\n\n'
: 'data: '+JSON.stringify({choices:[{index:0,delta:{content:'done'},finish_reason:'stop'}],usage:{prompt_tokens:10,completion_tokens:1,total_tokens:11}})+'\n\ndata: [DONE]\n\n'
return new Response(payload,{status:200,headers:{'content-type':'text/event-stream'}})
}) as typeof fetch
const gateway=new Gateway({baseUrl:responses?'http://127.0.0.1:1/v1/responses':'http://127.0.0.1:1/v1',apiKey:'',authMode:'none',model:'fixture',temperature:0,maxToolRounds:1},body=>{observed=structuredClone(body)})
for await(const _delta of gateway.stream({messages:[{role:'system',content:'system'},{role:'user',content:'hello'}],tools:[{type:'function',function:{name:'unused',description:'definition',parameters:{type:'object'}}}]})) {/* fully consume */}
assert.deepEqual(observed,transmitted,'observer sees the exact transmitted body without changing it')
const body=observed as any
assert.ok(estimateContextInput({messages:body.messages??body.input,systemPrompt:body.instructions,tools:body.tools}).counts.tools>0)
}
}finally{globalThis.fetch=originalFetch}
console.log('context-usage-regression: all checks passed')
+162 -6
View File
@@ -29,6 +29,9 @@ interface TestState {
maxActivePrompts: number
nextNativeId: number
speechStatus: number
openPathError?: string
failStartTurn?: boolean
nativeFailurePath?: string
speechRequests: Array<{ path: string; authorization: string | undefined; contentType: string | undefined; body: string }>
events: unknown[]
}
@@ -69,7 +72,9 @@ async function waitForAssistant(
for (let attempt = 0; attempt < 250; attempt++) {
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
const last = messages.findLast((message) => message.role === 'assistant')
if (last && predicate(last)) return messages
// In-memory messages can reach the terminal status before the atomic write
// and terminal broadcast finish. Wait for the real turn barrier as well.
if (last && predicate(last) && !await invoke('chat:active-run', sessionId)) return messages
await wait(10)
}
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
@@ -131,6 +136,23 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
{
name: 'conversation-ipc-electron-and-omp-mocks',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^\.\/sessions$/ }, () => ({ path: 'sessions', namespace: 'mock-session-start' }))
pluginBuild.onResolve({ filter: /sessions\.ts$/, namespace: 'mock-session-start' }, () => ({ path: sessionsEntry, namespace: 'file' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-session-start' }, () => ({
loader: 'js', contents: `
import * as real from ${JSON.stringify(sessionsEntry)};
export * from ${JSON.stringify(sessionsEntry)};
export async function startTurn(...args) {
const state = globalThis[${stateKey}];
if (state.failStartTurn) {
state.failStartTurn = false;
if (state.nativeFailurePath) await (await import('node:fs/promises')).writeFile(state.nativeFailurePath, 'changed-native-leaf');
throw new Error('mock turn persistence failure');
}
return real.startTurn(...args);
}
`
}))
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
loader: 'js',
@@ -155,7 +177,7 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
showOpenDialog: async (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
};
export const shell = { openPath: async () => '', openExternal: async () => {} };
export const shell = { openPath: async () => state.openPathError || '', openExternal: async () => {} };
export const powerSaveBlocker = {
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
stop: () => true,
@@ -179,6 +201,7 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
if (!state.nativeMessages.has(this.sessionId)) state.nativeMessages.set(this.sessionId, []);
}
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
setThinkingLevel() { return Promise.resolve(); }
send(command) {
if (command.type === 'get_branch_messages') {
return Promise.resolve({ type: 'response', success: true, data: { messages: [...(state.nativeMessages.get(this.sessionId) || [])] } });
@@ -241,10 +264,11 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
function register(bundle: TestBundle, state: TestState): (channel: string, ...args: unknown[]) => Promise<unknown> {
state.handlers.clear()
bundle.registerIpc(() => ({
const mainWindow = {
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send: (_channel: string, event: unknown) => state.events.push(event) }
}))
webContents: { isDestroyed: () => false, send: (channel: string, event: unknown) => { if (channel === 'chat:event') state.events.push(event) } }
}
bundle.registerIpc(() => mainWindow)
return invokeFor(state)
}
@@ -417,6 +441,91 @@ async function testEditArtifactRollback(bundle: TestBundle, state: TestState, wo
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
'rejected edit must leave history untouched')
await invoke('chat:send', {
sessionId: blockedSession.id, text: 'edit without rolling files back', action: 'edit',
fromMessageId: 'edit-blocked-user', rollbackArtifacts: false
})
await waitForAssistant(invoke, blockedSession.id)
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'explicit skip must preserve current files even for non-reversible tools')
const keptPath = join(workspace, 'edit-keep-latest.txt')
const keptSession = await seedWriteHistory(bundle, state, 'edit-keep-latest', keptPath, 'keep-native', 'after')
await invoke('chat:send', { sessionId: keptSession, text: 'use latest files', action: 'edit',
fromMessageId: 'edit-keep-latest-user', rollbackArtifacts: false })
await waitForAssistant(invoke, keptSession)
assert.equal(await readFile(keptPath, 'utf8'), 'after', 'skipping artifact rollback retains the latest version')
const persistPath = join(workspace, 'edit-turn-persist-failed.txt')
const persistSession = await seedWriteHistory(bundle, state, 'edit-persist-failed', persistPath, 'edit-persist-native', 'after')
const persistHistory = structuredClone(await bundle.sessions.getMessages(persistSession))
await mkdir(join(state.userData, 'omp-sessions'), { recursive: true })
state.nativeFailurePath = nativePath(state.userData, persistSession)
await writeFile(state.nativeFailurePath, 'original-native-transcript', 'utf8')
state.failStartTurn = true
await assert.rejects(invoke('chat:send', { sessionId: persistSession, text: 'failed save', action: 'edit',
fromMessageId: 'edit-persist-failed-user' }), /turn persistence failure/)
assert.equal(await readFile(persistPath, 'utf8'), 'after', 'turn persistence failure must undo the already completed artifact rollback')
assert.equal(await readFile(state.nativeFailurePath, 'utf8'), 'original-native-transcript', 'failed edited turn must restore the native resume transcript too')
state.nativeFailurePath = undefined
assert.deepEqual(await bundle.sessions.getMessages(persistSession), persistHistory, 'failed edited turn persistence must preserve prior history')
}
async function testSenderRoutingAndPreflight(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
const invoke = invokeFor(state)
const session = await invoke('session:create', 'secondary window') as { id: string }
const events: Array<{ runId: string; type: string }> = []
let closed = false
const handler = state.handlers.get('chat:send')!
const sender = { isDestroyed: () => { if (closed) throw new Error('WebContents destroyed'); return false },
send: (channel: string, event: { runId: string; type: string }) => { if (channel === 'chat:event') events.push(event) } }
const sent = await handler({ sender }, { sessionId: session.id, text: 'secondary request' }) as { runId: string }
await waitForAssistant(invoke, session.id)
assert.ok(events.some((event) => event.type === 'text'), 'the requesting secondary renderer receives text deltas')
assert.ok(events.some((event) => event.type === 'done'), 'the requesting secondary renderer receives its terminal event')
assert.ok(state.events.some((event) => (event as { runId?: string }).runId === sent.runId), 'the main renderer also observes the shared turn')
const streamSequences = events.map((event) => (event as { sequence?: number }).sequence)
assert.ok(streamSequences.every((sequence, index) => typeof sequence === 'number' && (index === 0 || sequence > (streamSequences[index - 1] ?? -1))),
'shared events must carry a strictly increasing run sequence')
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /已有消息/,
'empty-session cleanup cannot remove a conversation populated by another renderer')
state.holdSessions.add(session.id)
await handler({ sender }, { sessionId: session.id, text: 'finish after window closes' })
await waitUntil(() => state.pendingPrompts.some((item) => item.sessionId === session.id), 'secondary held prompt')
const active = await invoke('chat:active-run', session.id) as { runId: string; messages: ChatMessage[]; sequence: number }
assert.ok(active?.runId && active.messages.at(-1)?.status === 'streaming', 'a reopened renderer can obtain the current run and exact live messages')
assert.equal(active.messages.filter((message) => message.role === 'user').length, 2, 'live snapshots include the complete history exactly once')
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /正在运行/,
'cleanup must reject a running session without cancelling its turn')
closed = true
const before = events.length
const held = state.pendingPrompts.find((item) => item.sessionId === session.id)!
state.pendingPrompts = state.pendingPrompts.filter((item) => item !== held)
state.holdSessions.delete(session.id)
held.complete()
await waitForAssistant(invoke, session.id)
assert.equal(events.length, before, 'closing the requester cannot interrupt persistence or send into a destroyed renderer')
const legacy = await invoke('session:create', 'legacy edit') as { id: string }
await bundle.sessions.appendMessage(legacy.id, message('legacy-user', 'user', 'imported input'))
await bundle.sessions.appendMessage(legacy.id, message('legacy-answer', 'assistant', 'imported answer'))
const denied = await invoke('chat:inspect-edit', legacy.id, 'legacy-user') as { editable: boolean; reason?: string }
assert.equal(denied.editable, false, 'legacy native branches must be checked before opening an editor')
assert.match(denied.reason ?? '', /原生分支/)
assert.deepEqual(await invoke('chat:inspect-edit', session.id, (await bundle.sessions.getMessages(session.id))[0].id), { editable: true })
const keepPath = join(workspace, 'rewind-keep.txt')
const keepSession = await seedWriteHistory(bundle, state, 'rewind-keep', keepPath, 'rewind-keep-native', 'after')
await invoke('chat:revert', keepSession, 'rewind-keep-user', { rollbackArtifacts: false })
assert.equal(await readFile(keepPath, 'utf8'), 'after', 'conversation-only rewind preserves artifact contents')
assert.deepEqual(await bundle.sessions.getMessages(keepSession), [], 'conversation-only rewind still persists the truncated history')
await invoke('session:delete', keepSession, { onlyIfEmpty: true })
assert.ok(!(await invoke('session:list') as Array<{ id: string }>).some((item) => item.id === keepSession), 'atomic cleanup removes a genuinely empty session')
state.openPathError = 'mock folder cannot be opened'
await assert.rejects(invoke('general:openFolder', 'workspace'), /mock folder cannot be opened/)
state.openPathError = undefined
}
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
@@ -558,12 +667,13 @@ async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: s
const pickedPath = join(tempRoot, 'picked.txt')
await writeFile(pickedPath, 'picked file content', 'utf8')
state.dialogPaths.push(pickedPath)
const picked = await invoke('attachments:pick') as Array<{ id: string; name: string; path?: string; size: number }>
const picked = await invoke('attachments:pick') as Array<{ id: string; name: string; path?: string; sourcePath?: string; size: number }>
assert.equal(picked.length, 1)
assert.equal(picked[0].name, 'picked.txt')
assert.equal(picked[0].size, Buffer.byteLength('picked file content'))
assert.ok(picked[0].path && (await readFile(picked[0].path, 'utf8')) === 'picked file content',
'picker should stage a real copy in user data')
assert.equal(picked[0].sourcePath, pickedPath, 'display source path stays separate from the managed attachment copy')
const staged = await invoke('attachments:stage', {
name: '../safe-note.txt', mimeType: 'text/plain', dataBase64: Buffer.from('attachment body').toString('base64')
@@ -572,6 +682,13 @@ async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: s
assert.equal(staged.size, Buffer.byteLength('attachment body'))
assert.equal((await bundle.attachments.getAttachment(staged.id)).name, 'safe-note.txt')
assert.equal(await readFile(staged.path!, 'utf8'), 'attachment body')
await assert.rejects(invoke('resources:read-text', staged.path), /超出工作目录/)
const preview = await invoke('attachments:preview', staged.id) as { kind: string; content: string; path: string }
assert.equal(preview.kind, 'text')
assert.equal(preview.content, 'attachment body', 'registered attachment preview must work outside the current workspace')
assert.equal(await invoke('resources:read-text', preview.path), 'attachment body', 'the same registered attachment can be read by the real Markdown/code viewer')
await assert.rejects(invoke('resources:read-text', pickedPath), /超出工作目录/)
await assert.rejects(invoke('attachments:preview', '../outside'), /标识无效/)
await assert.rejects(invoke('attachments:stage', {
name: 'metadata.json', mimeType: 'text/plain', dataBase64: Buffer.from('no').toString('base64')
}), /文件名无效/)
@@ -581,6 +698,8 @@ async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: s
const pathChecked = await bundle.attachments.getAttachment(staged.id)
assert.equal(pathChecked.path, staged.path, 'attachment metadata path must be recomputed inside the staged directory')
assert.notEqual(pathChecked.path, join(tempRoot, 'outside.txt'))
assert.equal((await invoke('attachments:preview', staged.id) as { path: string }).path, staged.path,
'attachment preview must use the registered directory, not a metadata path override')
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, name: '../outside.txt', path: join(tempRoot, 'outside.txt') }), 'utf8')
await assert.rejects(bundle.attachments.getAttachment(staged.id), /元数据无效/)
const sendSession = await invoke('session:create', 'invalid-attachment') as { id: string }
@@ -713,6 +832,23 @@ async function testColdRecovery(tempRoot: string, state: TestState, bundle: Test
assert.equal(diskRecovered?.tools?.[0].output, 'partial tool output')
}
async function testInlineReferencePersistence(state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<string> {
const session = await invoke('session:create', 'inline reference persistence') as { id: string }
const quote = { id: 'inline-q', sourceId: 'original:table:table-0', kind: 'table', markdown: '| 项目 |\n| --- |\n| alpha |', preview: '| old raw markdown |' }
const layout = [{ type: 'text', text: 'before' }, { type: 'quote', id: quote.id }, { type: 'text', text: 'after' }]
await invoke('chat:send', { sessionId: session.id, text: 'beforeafter', quotes: [quote], userContent: [
...layout, { type: 'attachment', id: 'untrusted-view-only' }, { type: 'quote', id: quote.id }
] })
const messages = await waitForAssistant(invoke, session.id)
const user = messages.find(item => item.role === 'user')!
assert.deepEqual(user.userContent, layout, 'main process must preserve mixed order, deduplicate references, and reject forged view identities')
assert.equal(user.quotes?.[0].markdown, quote.markdown, 'the real table snapshot must survive independently of the UI label')
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as { sessions: Array<{ id: string; messages: ChatMessage[] }> }
assert.deepEqual(stored.sessions.find(item => item.id === session.id)?.messages.find(item => item.role === 'user')?.userContent, layout,
'inline presentation must reach the actual atomic session file')
return session.id
}
async function main(): Promise<void> {
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-conversation-ipc-'))
const state: TestState = {
@@ -749,18 +885,38 @@ async function main(): Promise<void> {
state.dialogPaths.push(workspace)
assert.deepEqual(await invoke('workspace:choose'), { root: workspace, changed: true })
const project = await invoke('projects:create', { name: '指令项目', instructions: '项目回复必须包含验收标记 PROJECT-CONTEXT', experts: [], skills: [], connectors: [] }) as { projectId: string }
const projectTask = await invoke('session:create', '项目指令回归', { projectId: project.projectId }) as { id: string; projectId: string }
assert.equal(projectTask.projectId, project.projectId)
await invoke('chat:send', { sessionId: projectTask.id, text: '检查项目指令' })
const projectMessages = await waitForAssistant(invoke, projectTask.id)
const projectPrompt = state.timeline.find(item => item.kind === 'prompt' && item.sessionId === projectTask.id)?.text
assert.ok(projectPrompt?.includes('项目指令:\n项目回复必须包含验收标记 PROJECT-CONTEXT'), 'project instructions must reach the actual OMP adapter prompt')
assert.equal(projectMessages.find(item => item.role === 'user')?.content, '检查项目指令', 'project instructions must not overwrite the displayed user message')
await invoke('projects:update', project.projectId, { instructions: 'UPDATED-PROJECT-CONTEXT' })
await invoke('chat:send', { sessionId: projectTask.id, text: '检查更新指令' })
await waitForAssistant(invoke, projectTask.id)
const updatedProjectPrompt = state.timeline.filter(item => item.kind === 'prompt' && item.sessionId === projectTask.id).at(-1)?.text
assert.ok(updatedProjectPrompt?.includes('UPDATED-PROJECT-CONTEXT'), 'next project turn must use the updated persisted instructions')
await assert.rejects(invoke('session:update-context', projectTask.id, { projectId: 'another-project' }), /目标项目/)
await testConcurrency(bundle, state, invoke)
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
await testActionBranchFailure(bundle, state)
await testEditArtifactRollback(bundle, state, workspace)
await testSenderRoutingAndPreflight(bundle, state, workspace)
await testInterruptedResend(bundle, state)
await testAttachments(bundle, state, tempRoot)
await testSpeech(bundle, state)
const inlineSessionId = await testInlineReferencePersistence(state, invoke)
const { markerSessionId, markerEntryId } = await testFeedbackAndRevert(bundle, state, workspace)
const restarted = await testPendingReplay(tempRoot, bundle, state, markerSessionId, markerEntryId)
bundles.push(restarted)
await testColdRecovery(tempRoot, state, restarted)
const restoredInline = await invoke('session:messages', inlineSessionId) as ChatMessage[]
assert.deepEqual(restoredInline.find(item => item.role === 'user')?.userContent,
[{ type: 'text', text: 'before' }, { type: 'quote', id: 'inline-q' }, { type: 'text', text: 'after' }],
'a fresh IPC/session repository must restore the same inline order after restart')
console.log('conversation-ipc-regression: all checks passed')
} finally {
await Promise.all(bundles.map((bundle) => bundle.disposeOmpKernels().catch(() => undefined)))
+7 -2
View File
@@ -13,11 +13,13 @@ import type { ChatEvent, ChatMessage } from '../src/shared/types'
import type { ChatInteraction } from '../src/shared/conversation'
import type { OmpKernel } from '../src/main/omp'
import { assistantWireHistory } from '../src/main/agent/history'
import { configureSandbox } from '../src/main/sandbox/configuration'
async function main() {
const directory = await mkdtemp(join(tmpdir(), 'ycode-conversation-runtime-'))
const workspace = join(directory, 'workspace')
await mkdir(workspace)
configureSandbox({ stateRoot: join(directory, 'sandbox-state'), resourceDirectory: join(directory, 'missing-sandbox-backend') })
try {
const events: ChatEvent[] = []
const emitted = new EventEmitter()
@@ -56,7 +58,9 @@ async function main() {
await symlink(outside, join(workspace, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
assert.equal((await executeConversationTool('read_file', { path: 'linked/secret.txt' }, context)).ok, false)
assert.equal((await executeConversationTool('write_file', { path: '../outside/deep/new.txt', content: 'x' }, context)).ok, false)
assert.equal((await executeConversationTool('execute_command', { command: 'echo SHOULD_NOT_RUN' }, context)).ok, false)
const unavailableCommand = await executeConversationTool('execute_command', { command: 'echo SHOULD_NOT_RUN' }, context)
assert.equal(unavailableCommand.ok, false, 'a missing native backend must fail closed')
assert.match(unavailableCommand.content, /ENOENT|原生组件/)
const fullController = new AbortController()
const childScript = join(directory, 'child.cjs')
const parentScript = join(directory, 'parent.cjs')
@@ -155,7 +159,8 @@ async function main() {
const hostKernel = new HostKernel()
const native = await runOmpTurn({ runId: 'native', kernel: hostKernel as unknown as OmpKernel, workspaceRoot: workspace, userText: 'read', signal: controller.signal, emit })
assert.equal(native.content, 'Native host complete')
assert.equal(hostKernel.definitions.length, 8)
assert.equal(hostKernel.definitions.length, 9)
assert.ok(hostKernel.definitions.some(tool => tool.name === 'browser'), 'native host registers the browser tool')
assert.equal(native.tools[0].status, 'ok')
assert.equal((native.tools[0].result as { path: string }).path, join(workspace, 'a.txt'))
assert.equal(hostKernel.listenerCount('event'), 0)
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { once } from 'node:events'
import { Gateway, type GatewayDelta, type WireMessage } from '../src/main/gateway'
import { buildAnthropicRequest, buildGeminiRequest } from '../src/main/gateway/native-protocols'
import { discoverModelConnection } from '../src/main/model-connection'
import type { GatewayConfig } from '../src/shared/types'
const checks: string[] = []
let mode = 'tools', protocol: 'anthropic' | 'gemini' = 'anthropic', noKey = false
const observed: Array<{ path: string; body: any }> = []
const sse = (rows: unknown[]): string => rows.map(row => `data: ${JSON.stringify(row)}\n\n`).join('')
function anthropic(): unknown[] {
const rows: any[] = [
{ type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 0, cache_read_input_tokens: 4, cache_creation_input_tokens: 2 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '考虑步骤' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'controlled-signature' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'native-call', name: 'inspect', input: {} } },
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"path":' } },
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"src"}' } },
{ type: 'content_block_stop', index: 1 }
]
if (mode !== 'truncated') rows.push({ type: 'message_delta', delta: { stop_reason: mode === 'limit' ? 'max_tokens' : 'tool_use' }, usage: { output_tokens: 8 } }, { type: 'message_stop' })
if (mode === 'error') rows.push({ type: 'error', error: { message: 'private-key-that-must-not-leak' } })
return rows
}
function gemini(): unknown[] {
const rows: any[] = [{ candidates: [{ index: 0, content: { role: 'model', parts: [{ thought: true, text: '考虑步骤' }] } }] },
{ candidates: [{ index: 0, content: { role: 'model', parts: [{ functionCall: { id: 'native-call', name: 'inspect', args: { path: 'src' } }, thoughtSignature: 'controlled-signature' }] },
...(mode !== 'truncated' ? { finishReason: mode === 'limit' ? 'MAX_TOKENS' : 'STOP' } : {}) }],
usageMetadata: { promptTokenCount: 16, candidatesTokenCount: 5, thoughtsTokenCount: 3, totalTokenCount: 24, cachedContentTokenCount: 4 } }]
if (mode === 'error') rows.push({ error: { message: 'private-key-that-must-not-leak' } })
return rows
}
const server = createServer(async (req, res) => {
const url = new URL(req.url!, 'http://local')
if (url.pathname.startsWith('/root-test')) {
assert.equal(url.pathname, '/root-test/chat/completions')
let bytes = ''; for await (const chunk of req) bytes += chunk
const body = JSON.parse(bytes)
assert.equal(body.max_completion_tokens, 1024); assert.equal(body.max_tokens, undefined)
assert.equal(body.reasoning_effort, 'none'); assert.equal(body.temperature, undefined)
res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end(sse([{ choices: [{ delta: { content: 'OK' }, finish_reason: 'stop' }] }]) + 'data: [DONE]\n\n'); return
}
const authenticated = noKey ? !req.headers.authorization && !req.headers['x-api-key'] && !req.headers['x-goog-api-key']
: protocol === 'anthropic' ? req.headers['x-api-key'] === 'controlled-key' : req.headers['x-goog-api-key'] === 'controlled-key'
if (!authenticated) { res.writeHead(401); res.end('{}'); return }
if (req.method === 'GET') {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify(protocol === 'anthropic'
? url.searchParams.has('after_id') ? { data: [{ id: 'second' }, { id: 'first' }], has_more: false } : { data: [{ id: 'first' }], has_more: true, last_id: 'first' }
: url.searchParams.has('pageToken') ? { models: [{ name: 'models/second', supportedGenerationMethods: ['generateContent'] }, { name: 'models/embedding', supportedGenerationMethods: ['embedContent'] }] }
: { models: [{ name: 'models/first', supportedGenerationMethods: ['generateContent'] }], nextPageToken: 'next' })); return
}
let bytes = ''; for await (const chunk of req) bytes += chunk
observed.push({ path: req.url!, body: JSON.parse(bytes) })
res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end(sse(protocol === 'anthropic' ? anthropic() : gemini()))
})
server.listen(0, '127.0.0.1'); await once(server, 'listening')
const address = server.address(); assert(address && typeof address === 'object')
const root = `http://127.0.0.1:${address.port}`
const collect = async (config: GatewayConfig): Promise<GatewayDelta[]> => { const events: GatewayDelta[] = []; for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'inspect src' }] })) events.push(event); return events }
try {
const geminiAuto: GatewayConfig = { api: 'google-generative-ai', baseUrl: root, model: 'gemini-3.1-pro-preview', apiKey: 'controlled-key', temperature: 0.5, maxToolRounds: 3,
supportsReasoning: true, thinkingLevel: 'auto', reasoningOptions: [{ type: 'effort', values: ['low', 'medium', 'high'] }] }
assert.deepEqual(buildGeminiRequest(geminiAuto, { messages: [] }).generationConfig.thinkingConfig, { includeThoughts: true })
assert.deepEqual(buildGeminiRequest({ ...geminiAuto, thinkingLevel: 'low' }, { messages: [] }).generationConfig.thinkingConfig, { thinkingLevel: 'LOW', includeThoughts: true })
checks.push('Gemini effort-only models use native thinking levels without inventing a token budget for auto')
const rootConfig: GatewayConfig = { api: 'openai-completions', baseUrl: root + '/root-test', model: 'gpt-5.4', apiKey: 'controlled-key', temperature: 0.5, maxToolRounds: 3,
maxOutputTokens: 1024, supportsReasoning: true, thinkingLevel: 'off', reasoningOptions: [{ type: 'effort', values: ['none', 'low', 'high'] }], temperatureSupported: false }
assert((await collect(rootConfig)).some(event => event.type === 'text' && event.text === 'OK'))
checks.push('OpenAI fallback honors the negotiated root, modern completion budget and disabled reasoning/temperature')
for (protocol of ['anthropic', 'gemini'] as const) {
noKey = false; mode = 'tools'
const config: GatewayConfig = { api: protocol === 'anthropic' ? 'anthropic-messages' : 'google-generative-ai', baseUrl: root + (protocol === 'anthropic' ? '/v1' : '/v1beta'), model: 'controlled-model', apiKey: 'controlled-key', temperature: 0, maxToolRounds: 3 }
const discovery = await discoverModelConnection(root, protocol, 'controlled-key', true)
assert.deepEqual(discovery.models.map(model => model.id), ['first', 'second']); checks.push(protocol + ' lists every page, filters non-generative IDs and removes duplicates')
const events = await collect(config), native = events.find(event => event.type === 'native_content')!, calls = events.find(event => event.type === 'tool_calls')!
assert(native.type === 'native_content' && calls.type === 'tool_calls')
assert.deepEqual(calls.calls[0], { id: 'native-call', type: 'function', function: { name: 'inspect', arguments: '{"path":"src"}' } })
assert.equal(events.filter(event => event.type === 'reasoning').map(event => event.text).join(''), '考虑步骤')
const usage = events.filter(event => event.type === 'usage').at(-1)!
assert(usage.type === 'usage'); assert.deepEqual(usage.usage, { inputTokens: 16, outputTokens: 8, totalTokens: 24, cachedTokens: 4 })
const messages: WireMessage[] = [{ role: 'user', content: 'inspect src' }, { role: 'assistant', content: null, tool_calls: calls.calls, providerContent: { api: native.api, content: native.content } }, { role: 'tool', tool_call_id: 'native-call', content: 'contents' }]
const body = protocol === 'anthropic' ? buildAnthropicRequest(config, { messages }) : buildGeminiRequest(config, { messages })
assert(JSON.stringify(body).includes('controlled-signature'))
assert(JSON.stringify(body).includes('native-call')); assert(JSON.stringify(body).includes('contents'))
checks.push(protocol + ' native tool IDs, arguments, reasoning, signatures and usage survive tool continuation')
for (mode of ['truncated', 'limit', 'error']) {
const pending: GatewayDelta[] = []
await assert.rejects(async () => { for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'go' }] })) pending.push(event) }, error => error instanceof Error && !error.message.includes('private-key-that-must-not-leak'))
assert(!pending.some(event => event.type === 'tool_calls')); checks.push(protocol + ' ' + mode + ' never dispatches incomplete tools or leaks provider error content')
}
mode = 'tools'; noKey = true
assert.equal((await discoverModelConnection(root, protocol, '', true)).models.length, 2)
assert((await collect({ ...config, authMode: 'none', apiKey: '' })).some(event => event.type === 'tool_calls'))
checks.push(protocol + ' keyless custom endpoints send no authentication headers')
}
console.log('CUSTOM_MODEL_NATIVE_PASS ' + JSON.stringify({ checks }))
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { mkdtemp, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { once } from 'node:events'
import { normalizeModelAddress, endpointCandidates, endpointUrl, modelEndpoint } from '../src/shared/model-endpoint'
import { cheapestModelPrice, lookupModelMetadata, type ModelCatalog } from '../src/shared/model-metadata'
import { discoverModelConnection, modelFetch } from '../src/main/model-connection'
import { Gateway } from '../src/main/gateway'
import { buildAnthropicRequest, buildGeminiRequest } from '../src/main/gateway/native-protocols'
import { prepareOmpModel, supportsOmpModel } from '../src/main/omp-model'
import { OmpKernel, detectOmpPath } from '../src/main/omp'
import type { ModelApi, ResolvedModel } from '../src/shared/models'
const directory = await mkdtemp(resolve('.tmp/custom-model-regression-'))
const checks: string[] = []
function check(label: string, run: () => void): void { run(); checks.push(label) }
const catalog = JSON.parse(await readFile('resources/model-metadata/models.dev.json', 'utf8')) as ModelCatalog
const source = JSON.parse(await readFile('resources/model-metadata/manifest.json', 'utf8'))
check('domain-only, local origin and full routes normalize', () => {
assert.equal(normalizeModelAddress('api.example.com'), 'https://api.example.com')
assert.equal(normalizeModelAddress('127.0.0.1:9000'), 'http://127.0.0.1:9000')
assert.throws(() => normalizeModelAddress('https://user:password@example.com'))
assert.throws(() => normalizeModelAddress('https://example.com?key=secret'))
assert(endpointCandidates('https://example.com/proxy', 'anthropic').some(endpoint => endpointUrl(endpoint, 'generate') === 'https://example.com/proxy/v1/messages'))
})
check('metadata uses the canonical provider and cheapest context tier', () => {
const value = lookupModelMetadata(catalog, 'gpt-5.4', 'openai', source)!
assert.equal(value.providerId, 'openai'); assert.equal(value.contextWindowTokens, 272000)
assert.equal(value.maxInputTokens, 144000); assert(value.supportsImages); assert(value.supportsReasoning)
assert.equal(value.price?.input, 2.5); assert.equal(value.price?.output, 15)
assert.equal(lookupModelMetadata(catalog, 'openai/gpt-5.4', 'auto', source)?.modelId, 'gpt-5.4')
assert.equal(lookupModelMetadata(catalog, 'gpt-5.4-not-a-real-alias', 'auto', source), null)
assert.equal(cheapestModelPrice({ id: 'bad', cost: { input: -1, output: 1 } }), undefined)
})
check('tier thresholds constrain defaults without changing provider', () => {
const price = cheapestModelPrice({ id: 'test', limit: { context: 100000 }, cost: { input: 4, output: 8, tiers: [{ input: 1, output: 2, tier: { type: 'context', size: 32000 } }, { input: 5, output: 10, tier: { type: 'context', size: 64000 } }] } })!
assert.equal(price.minContext, 32000); assert.equal(price.maxContext, 64000)
})
let api: ModelApi = 'anthropic-messages', anonymous = false
const requests: Array<{ api: ModelApi; path: string; body: any }> = []
const rejectedAuth: unknown[] = []
const anthropic = (model: string): string => [
{ type: 'message_start', message: { id: 'msg_test', type: 'message', role: 'assistant', content: [], model, stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '协议正常' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 2 } },
{ type: 'message_stop' }
].map(value => `event: ${value.type}\ndata: ${JSON.stringify(value)}\n\n`).join('')
const gemini = (): string => 'data: ' + JSON.stringify({ candidates: [{ index: 0, content: { role: 'model', parts: [{ text: '协议正常' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2, totalTokenCount: 12 } }) + '\n\n'
const server = createServer(async (req, res) => {
const url = new URL(req.url!, 'http://local')
if (url.pathname === '/redirect') { res.writeHead(302, { location: 'http://127.0.0.1:1/trap' }); res.end(); return }
if (req.method === 'GET') {
const valid = api === 'anthropic-messages' ? url.pathname === '/v1/models' && req.headers['x-api-key'] === 'controlled-test-key'
: api === 'google-generative-ai' ? url.pathname === '/v1beta/models' && req.headers['x-goog-api-key'] === 'controlled-test-key' : url.pathname === '/v1/models' && req.headers.authorization === 'Bearer controlled-test-key'
res.writeHead(valid ? 200 : 404, { 'content-type': 'application/json' })
res.end(JSON.stringify(api === 'google-generative-ai' ? { models: [{ name: 'models/gemini-2.5-flash', displayName: 'Gemini test', supportedGenerationMethods: ['generateContent'] }, { name: 'models/embed', supportedGenerationMethods: ['embedContent'] }] }
: { data: [{ id: 'claude-3-5-haiku-latest' }, { id: 'claude-3-5-haiku-latest' }] })); return
}
let raw = ''; for await (const chunk of req) raw += chunk
const authorized = anonymous ? !req.headers['x-api-key'] && !req.headers['x-goog-api-key'] && !req.headers.authorization
: api === 'anthropic-messages' ? req.headers['x-api-key'] === 'controlled-test-key' && !req.headers.authorization : req.headers['x-goog-api-key'] === 'controlled-test-key'
if (!authorized) { rejectedAuth.push({ api, anonymous, path: req.url, headers: Object.keys(req.headers).filter(name => /api-key|authorization/.test(name)), googleSentinel: req.headers['x-goog-api-key'] === 'N/A' }); res.writeHead(401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Incorrect native authentication' } })); return }
const body = JSON.parse(raw || '{}'); requests.push({ api, path: req.url!, body })
res.writeHead(200, { 'content-type': 'text/event-stream' })
res.end(api === 'anthropic-messages' ? anthropic(body.model) : gemini())
})
server.listen(0, '127.0.0.1'); await once(server, 'listening')
const address = server.address(); assert(address && typeof address === 'object')
const root = `http://127.0.0.1:${address.port}`
try {
await assert.rejects(() => modelFetch(root + '/redirect', {}), /不同域名/); checks.push('credentials cannot follow a cross-origin redirect')
for (const currentApi of ['anthropic-messages', 'google-generative-ai'] as const) {
api = currentApi
const discovery = await discoverModelConnection(root, 'auto', 'controlled-test-key', true)
assert.equal(discovery.endpoint.api, api); assert.equal(discovery.models.length, 1)
checks.push(api + ' model discovery negotiates path and auth, filters and deduplicates')
const config = { api, baseUrl: discovery.endpoint.baseUrl, model: api === 'anthropic-messages' ? 'claude-3-5-haiku-latest' : 'gemini-2.5-flash', apiKey: 'controlled-test-key', temperature: 0, maxToolRounds: 2, maxOutputTokens: 128 }
const events = []
for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'hello' }] })) events.push(event)
assert.equal(events.filter(event => event.type === 'text').map(event => event.text).join(''), '协议正常')
assert(events.some(event => event.type === 'usage' && event.usage.totalTokens === 12))
checks.push(api + ' fallback gateway streams native text and usage')
const body = api === 'anthropic-messages' ? buildAnthropicRequest(config, { messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }] })
: buildGeminiRequest(config, { messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }] })
assert(JSON.stringify(body).includes('AAAA')); checks.push(api + ' image input preserves base64 data')
const executable = detectOmpPath(); assert(executable, 'Real OMP required for native protocol acceptance')
for (const authMode of ['api-key', 'none'] as const) {
anonymous = authMode === 'none'
const resolved: ResolvedModel = { profile: { id: config.model, providerId: 'custom', name: config.model, model: config.model, baseUrl: root, protocol: api === 'anthropic-messages' ? 'anthropic' : 'gemini', authMode, hasApiKey: !anonymous, keyStorage: 'none', supportsReasoning: true, supportsImages: true }, config: { ...config, authMode, apiKey: anonymous ? '' : config.apiKey } }
if (!supportsOmpModel(resolved)) {
assert.equal(api, 'google-generative-ai'); assert.equal(authMode, 'none')
await assert.rejects(() => prepareOmpModel(resolved), /原生 HTTP/)
checks.push('keyless Gemini explicitly routes to the native HTTP gateway rather than sending OMP sentinel credentials')
continue
}
process.env.YCODE_OMP_HOME = directory
const managed = await prepareOmpModel(resolved), frames: string[] = [], errors: string[] = []
assert(!(await readFile(resolve(directory, '.omp/profiles', managed.profile, 'agent/models.yml'), 'utf8')).includes(config.apiKey), 'native profile must never store the API key')
const kernel = new OmpKernel(executable, directory, managed.profile, undefined, managed.model, managed.env)
kernel.on('error', error => errors.push(String(error)))
kernel.on('stderr', value => { if (/error/i.test(String(value))) errors.push(String(value).slice(0, 300)) })
const start = requests.length
try {
await kernel.start()
const auto = await kernel.send({ type: 'set_thinking_level', level: 'auto' })
assert.equal(auto.success, true, 'OMP must accept automatic thinking intensity')
await kernel.setThinkingLevel('minimal')
const end = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(Error('Native protocol turn timeout: ' + JSON.stringify({ api, paths: requests.slice(start).map(r => r.path), frames, errors }))), 18000)
kernel.on('event', frame => { frames.push(frame.type); if (frame.type === 'agent_end') { clearTimeout(timer); resolve() } })
})
await kernel.prompt('Reply briefly.'); await end
assert(requests.length > start, JSON.stringify({ api, authMode, frames, errors, rejectedAuth }))
const request = requests[start]
console.log(JSON.stringify({ api, nativePath: request.path, thinking: request.body.thinking ?? request.body.generationConfig?.thinkingConfig, expectedRoot: discovery.endpoint.baseUrl, frameTypes: [...new Set(frames)], errors }))
const nativeUrl = new URL(request.path, root)
assert.equal(nativeUrl.pathname, api === 'anthropic-messages' ? '/v1/messages' : '/v1beta/models/gemini-2.5-flash:streamGenerateContent')
if (api === 'google-generative-ai') assert.equal(nativeUrl.searchParams.get('alt'), 'sse')
checks.push(api + ' real OMP native adapter completes using the negotiated route and ' + authMode + ' authentication')
} finally { await kernel.dispose() }
}
anonymous = false
}
console.log('CUSTOM_MODEL_REGRESSION_PASS ' + JSON.stringify({ directory, checks }))
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
+15
View File
@@ -0,0 +1,15 @@
# Git transport test fixtures
Install the pinned, test-only HTTP/HTTPS and SSH server libraries:
```powershell
npm ci --prefix scripts/fixtures/git-transport --ignore-scripts --no-audit --no-fund
```
The independent lockfile avoids changing YCode's Electron/Vite dependency tree.
The fixtures generate temporary certificates and SSH keys, listen only on
loopback, and serve test-owned repositories. The client under test is YCode's
actual bundled Git/HTTPS/OpenSSH runtime and native control Job. No paid API,
public Git provider, production credential or production repository is used.
Optional native dependency build scripts are disabled; SSH2 uses its JS fallback.
These packages are not application runtime dependencies and are not packaged.
+139
View File
@@ -0,0 +1,139 @@
{
"name": "ycode-git-transport-test-fixtures",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ycode-git-transport-test-fixtures",
"version": "1.0.0",
"dependencies": {
"selfsigned": "2.4.1",
"ssh2": "1.17.0"
}
},
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmmirror.com/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/nan": {
"version": "2.29.0",
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.29.0.tgz",
"integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==",
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/selfsigned": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"license": "MIT",
"dependencies": {
"@types/node-forge": "^1.3.0",
"node-forge": "^1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
}
}
}
@@ -0,0 +1,10 @@
{
"name": "ycode-git-transport-test-fixtures",
"version": "1.0.0",
"private": true,
"description": "Local-only HTTPS and SSH servers for sandbox Git acceptance tests",
"dependencies": {
"selfsigned": "2.4.1",
"ssh2": "1.17.0"
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 生成 YCode 应用图标资产。
*
* 输入:build/icon.svg(唯一矢量源)
* 输出:build/icon.ico、build/icon.png、resources/icon.png、build/icon-preview.png
*
* 图案是深红旗面 + 按国旗制式排布的五颗金星(一大四小,3:1),
* 星群坐标由 scripts/star-layout.mjs 算出,这里只负责栅格化与打包。
*
* 用法:node scripts/gen-icon.mjs
*/
import { Resvg } from '@resvg/resvg-js'
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const buildDir = join(root, 'build')
const resourcesDir = join(root, 'resources')
const masterSvg = readFileSync(join(buildDir, 'icon.svg'), 'utf8')
const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
function render(svg, size) {
return new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng()
}
function pick() {
return masterSvg
}
/** 组装 Windows ICO:每个尺寸内嵌一张 PNG(Vista 及以后支持)。 */
function buildIco(entries) {
const header = Buffer.alloc(6)
header.writeUInt16LE(0, 0)
header.writeUInt16LE(1, 2)
header.writeUInt16LE(entries.length, 4)
const dirEntries = []
let offset = 6 + 16 * entries.length
for (const { size, png } of entries) {
const entry = Buffer.alloc(16)
entry.writeUInt8(size >= 256 ? 0 : size, 0)
entry.writeUInt8(size >= 256 ? 0 : size, 1)
entry.writeUInt8(0, 2)
entry.writeUInt8(0, 3)
entry.writeUInt16LE(1, 4)
entry.writeUInt16LE(32, 6)
entry.writeUInt32LE(png.length, 8)
entry.writeUInt32LE(offset, 12)
offset += png.length
dirEntries.push(entry)
}
return Buffer.concat([header, ...dirEntries, ...entries.map((e) => e.png)])
}
mkdirSync(resourcesDir, { recursive: true })
const icoEntries = ICO_SIZES.map((size) => ({ size, png: render(pick(), size) }))
writeFileSync(join(buildDir, 'icon.ico'), buildIco(icoEntries))
writeFileSync(join(buildDir, 'icon.png'), render(masterSvg, 1024))
writeFileSync(join(resourcesDir, 'icon.png'), render(masterSvg, 512))
// 托盘:Windows 通知区域按 16/20/24/32 取用,给 32px 让系统自行缩放
writeFileSync(join(resourcesDir, 'tray.png'), render(masterSvg, 32))
// 备用图:常用尺寸各出一张 PNG,供托盘、商店页、文档等场景直接取用
const PNG_SIZES = [16, 20, 24, 32, 40, 48, 64, 96, 128, 180, 256, 512, 1024]
const sizesDir = join(buildDir, 'sizes')
mkdirSync(sizesDir, { recursive: true })
for (const size of PNG_SIZES) {
writeFileSync(join(sizesDir, `icon-${size}.png`), render(pick(), size))
}
// 对照表:浅色与深色背景下,各尺寸的真实观感
const sampleSizes = [128, 64, 48, 32, 16]
function tile(size) {
return render(pick(), size).toString('base64')
}
const rows = [
{ label: '浅色任务栏', bg: '#FFFFFF', fg: '#475467' },
{ label: '深色任务栏', bg: '#202124', fg: '#D0D5DD' }
]
let y = 78
const rowSvg = rows
.map((row) => {
let x = 150
const imgs = sampleSizes
.map((size) => {
const markup = `<image x="${x}" y="${y + (128 - size) / 2}" width="${size}" height="${size}" href="data:image/png;base64,${tile(size)}"/>`
x += size + 28
return markup
})
.join('')
const label = `<text x="40" y="${y + 68}" font-size="13" fill="${row.fg}" font-family="system-ui, 'Microsoft YaHei', sans-serif">${row.label}</text>`
const block = `<rect x="20" y="${y}" width="640" height="128" rx="10" fill="${row.bg}"/>${label}${imgs}`
y += 152
return block
})
.join('')
const previewSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 ${y + 10}" width="680" height="${y + 10}">
<rect width="680" height="${y + 10}" fill="#F7F8FA"/>
<text x="20" y="34" font-size="15" font-weight="500" fill="#1F2328" font-family="system-ui, 'Microsoft YaHei', sans-serif">YCode 图标:128 / 64 / 48 / 32 / 16 px 实际尺寸对照</text>
<text x="20" y="56" font-size="12" fill="#667085" font-family="system-ui, 'Microsoft YaHei', sans-serif">五颗星按国旗制式:一大四小(3:1),四颗小星各有一角尖对准大星中心;旗面为深红渐变+柔和光泽</text>
${rowSvg}
</svg>`
writeFileSync(join(buildDir, 'icon-preview.png'), new Resvg(previewSvg, { fitTo: { mode: 'width', value: 1360 } }).render().asPng())
console.log('icon.ico', icoEntries.length, 'sizes')
console.log('icon.png / resources/icon.png / icon-preview.png done')
console.log('build/sizes/icon-{', PNG_SIZES.join(','), '}.png done')
@@ -0,0 +1,70 @@
/** Code-native artwork for YCode's five general development roles. No remote image service. */
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Resvg } from '@resvg/resvg-js'
const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const palettes = {
explorer: ['#E4F3ED', '#9DCCB9', '#558E79', '#D8EEE4', '#1D3E37'],
implementer: ['#E7EFFB', '#A6BCE2', '#668CC3', '#E0EBFA', '#253D60'],
verifier: ['#FAF0DE', '#E6C384', '#B98B42', '#F8E9CB', '#55412B'],
reviewer: ['#EDEAF7', '#B9AADC', '#8770B5', '#E9E1F6', '#3D3258'],
debugger: ['#F7E9E6', '#DBADA5', '#B6786C', '#F5DDD6', '#533730']
}
const marks = {
explorer: '<circle cx="204" cy="202" r="8"/><path d="m210 208 8 8"/>',
implementer: '<path d="m199 195-7 8 7 8m13-16 7 8-7 8m-7-19-3 22"/>',
verifier: '<path d="m194 203 8 8 15-17"/>',
reviewer: '<path d="m205 190 13 5v9c0 8-8 13-13 15-5-2-13-7-13-15v-9z"/><path d="m199 203 4 4 8-9"/>',
debugger: '<path d="M191 204h7l4-10 7 19 4-9h7"/>'
}
function avatar(role) {
const [background, accent, edge, highlight, ink] = palettes[role]
return `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="${role}-bg" x2=".8" y2="1"><stop stop-color="${background}"/><stop offset="1" stop-color="${accent}"/></linearGradient>
<linearGradient id="${role}-shell" x2=".3" y2="1"><stop stop-color="#FFFFFF"/><stop offset="1" stop-color="${highlight}"/></linearGradient>
<linearGradient id="${role}-screen" x2=".7" y2="1"><stop stop-color="${ink}"/><stop offset="1" stop-color="#172A31"/></linearGradient>
<clipPath id="${role}-clip"><rect width="256" height="256" rx="48"/></clipPath>
</defs>
<g clip-path="url(#${role}-clip)">
<rect width="256" height="256" fill="url(#${role}-bg)"/>
<circle cx="128" cy="95" r="94" fill="#FFFFFF" opacity=".2"/>
<ellipse cx="128" cy="254" rx="94" ry="20" fill="${edge}" opacity=".16"/>
<path d="M48 256v-23c0-31 32-49 80-49s80 18 80 49v23" fill="${edge}"/>
<path d="M62 256v-23c0-24 26-40 66-40s66 16 66 40v23" fill="url(#${role}-shell)"/>
<path d="M106 175h44v24c-11 9-33 9-44 0z" fill="${accent}"/>
<path d="M128 55V34" stroke="${edge}" stroke-width="8" stroke-linecap="round"/>
<circle cx="128" cy="30" r="10" fill="${edge}"/>
<circle cx="125" cy="27" r="4" fill="#FFFFFF" opacity=".7"/>
<rect x="43" y="97" width="22" height="44" rx="11" fill="${edge}"/>
<rect x="191" y="97" width="22" height="44" rx="11" fill="${edge}"/>
<rect x="54" y="55" width="148" height="128" rx="42" fill="${edge}" opacity=".18" transform="translate(0 5)"/>
<rect x="54" y="51" width="148" height="128" rx="42" fill="url(#${role}-shell)"/>
<path d="M78 72c12-8 25-10 50-10" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round" opacity=".9"/>
<rect x="69" y="87" width="118" height="66" rx="25" fill="url(#${role}-screen)"/>
<rect x="87" y="105" width="16" height="23" rx="8" fill="${highlight}"/>
<rect x="153" y="105" width="16" height="23" rx="8" fill="${highlight}"/>
<path d="M119 136q9 6 18 0" fill="none" stroke="${highlight}" stroke-width="3.5" stroke-linecap="round"/>
<circle cx="79" cy="137" r="5" fill="${accent}" opacity=".55"/>
<circle cx="177" cy="137" r="5" fill="${accent}" opacity=".55"/>
<path d="M102 225h52" stroke="${accent}" stroke-width="7" stroke-linecap="round"/>
<path d="M114 239h28" stroke="${accent}" stroke-width="5" stroke-linecap="round" opacity=".65"/>
<circle cx="205" cy="204" r="29" fill="#FFFFFF" opacity=".28"/>
<circle cx="205" cy="203" r="25" fill="${ink}"/>
<g fill="none" stroke="${highlight}" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">${marks[role]}</g>
</g>
</svg>\n`
}
const directory = path.join(project, 'resources/agents/avatars')
await fs.mkdir(directory, { recursive: true })
for (const role of Object.keys(palettes)) {
const source = avatar(role)
await fs.writeFile(path.join(directory, `${role}.svg`), source)
await fs.writeFile(path.join(directory, `${role}.png`), new Resvg(source).render().asPng())
}
await fs.mkdir(path.join(project, '.tmp'), { recursive: true })
const preview = `<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="216" viewBox="0 0 1000 216"><rect width="1000" height="216" fill="#F8F9FA"/>${Object.keys(palettes).map((role, index) => `<g transform="translate(${index * 200 + 12},20) scale(.6875)">${avatar(role)}</g>`).join('')}</svg>`
await fs.writeFile(path.join(project, '.tmp/general-agent-avatars.png'), new Resvg(preview).render().asPng())
console.log('Generated five SVG sources, five 256px PNG avatars and .tmp/general-agent-avatars.png')
+370
View File
@@ -0,0 +1,370 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
artifactPathKey,
classifyArtifact,
extractArtifactCandidates,
indexArtifactSources,
type ArtifactSource
} from '../src/shared/artifact-index'
import type { LibraryArtifactEntry } from '../src/shared/library'
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
import {
filterLibraryEntries,
formatLibraryBytes,
groupLibraryEntries,
listLibraryWorkspaces,
sortLibraryEntries
} from '../src/renderer/src/library/catalog'
import {
FileArtifactIndexRepository,
probeArtifactPath
} from '../src/main/library/artifact-repository'
import { ArtifactIndexService } from '../src/main/library/artifact-service'
import { LibraryService } from '../src/main/library/service'
import { JsonLibraryUserStateRepository } from '../src/main/library/user-state'
import { collectWorkbenchArtifacts } from '../src/renderer/src/workbuddy/workbench/workbench-state'
function tool(
id: string,
name: string,
args: Record<string, unknown>,
status: ToolCallRecord['status'] = 'ok'
): ToolCallRecord {
return { id, name, args, argsRaw: JSON.stringify(args), status }
}
function message(
id: string,
createdAt: number,
input: Partial<Pick<ChatMessage, 'role' | 'parts' | 'tools' | 'attachments' | 'resources'>> = {}
): ChatMessage {
return {
id,
role: 'assistant',
content: '',
createdAt,
...input
}
}
async function writeFixture(path: string, content: string): Promise<void> {
await fs.mkdir(dirname(path), { recursive: true })
await fs.writeFile(path, content, 'utf8')
}
async function main(): Promise<void> {
const root = await fs.mkdtemp(join(tmpdir(), 'ycode-library-regression-'))
let outsideRoot = ''
try {
outsideRoot = await fs.mkdtemp(join(tmpdir(), 'ycode-library-outside-'))
const reportPath = join(root, 'report.md')
const tablePath = join(root, 'charts', 'table.csv')
const resultPath = join(root, 'notes', 'result.md')
const nativeWritePath = join(root, 'native', 'native-write.md')
const missingPath = join(root, 'missing.md')
await writeFixture(reportPath, '# Updated report\n')
await writeFixture(tablePath, 'name,value\na,1\n')
await writeFixture(resultPath, 'written result')
await writeFixture(nativeWritePath, 'native OMP write')
const messagesA: ChatMessage[] = [
message('user-presentation', 500, {
role: 'user',
parts: [{
type: 'artifact',
artifact: { id: 'user-artifact', name: '用户展示块', path: 'user-visible.md' }
}]
}),
message('m1', 1_000, {
parts: [{
type: 'artifact',
artifact: { id: 'report-artifact', name: '旧报告', path: 'report.md' }
}]
}),
message('m2', 2_000, {
tools: [
tool('present-ok', 'present_files', {
files: [{ path: 'charts/table.csv' }, { path: 'missing.md' }]
}),
tool('write-ok', 'write_to_file', {
file_path: 'notes/result.md',
content: 'written result'
}),
tool('read-ok', 'read_file', { path: 'notes/result.md' }),
tool('write-failed', 'write_to_file', {
file_path: 'failed.md',
content: 'must not index'
}, 'error')
],
attachments: [{
id: 'attachment',
name: 'attachment.md',
path: 'attachment.md',
mimeType: 'text/markdown',
size: 12
}],
resources: ['attachment.md', 'notes/result.md']
}),
message('m3', 3_000, {
tools: [
tool('write-newer', 'write_to_file', {
file_path: 'report.md',
content: '# Updated report\n'
})
]
})
]
const messagesB: ChatMessage[] = [
message('m4', 2_500, {
parts: [{
type: 'artifact',
artifact: { id: 'report-b', name: '任务 B 报告', path: 'report.md' }
}]
})
]
const sessionA: ArtifactSource = {
id: 'session-a',
title: '任务 A',
createdAt: 100,
updatedAt: 3_000,
workspaceId: 'workspace-a',
projectId: 'project-a',
cwd: root,
messages: messagesA
}
const sessionB: ArtifactSource = {
id: 'session-b',
title: '任务 B',
createdAt: 200,
updatedAt: 2_500,
workspaceId: 'workspace-a',
cwd: root,
messages: messagesB
}
const candidatesA = extractArtifactCandidates(messagesA, {
sessionId: sessionA.id,
cwd: root,
workspaceId: sessionA.workspaceId,
projectId: sessionA.projectId,
sourceSessionTitle: sessionA.title
})
assert.equal(candidatesA.length, 4, '只有显式 artifact、成功 present_files 和成功 write 应被提取')
const candidateReport = candidatesA.find((item) => artifactPathKey(item.path) === artifactPathKey(reportPath))
assert.equal(candidateReport?.sourceKind, 'write', '同任务同路径采用最新来源')
assert.equal(candidateReport?.messageId, 'm3')
assert.equal(candidateReport?.workspaceId, 'workspace-a')
assert.equal(candidateReport?.projectId, 'project-a')
assert.equal(candidatesA.some((item) => item.path.endsWith('failed.md')), false)
assert.equal(candidatesA.some((item) => item.path.endsWith('attachment.md')), false)
assert.equal(candidatesA.some((item) => item.path.endsWith('user-visible.md')), false)
const workbench = collectWorkbenchArtifacts(messagesA, root)
assert.equal(workbench.artifacts.length, 2, '工作台非改动产物应复用同一白名单')
assert.equal(workbench.changes.length, 2, '工作台写编辑产物应复用同一白名单')
const entries = await indexArtifactSources([sessionA, sessionB], probeArtifactPath)
assert.equal(entries.length, 5, '任务 A 四个产物与任务 B 同路径产物必须分开保留')
const reportEntries = entries.filter((entry) => artifactPathKey(entry.path) === artifactPathKey(reportPath))
assert.equal(reportEntries.length, 2, '跨任务同路径不能合并')
assert.deepEqual(new Set(reportEntries.map((entry) => entry.sessionId)), new Set(['session-a', 'session-b']))
assert.notEqual(reportEntries[0].id, reportEntries[1].id)
const reportA = entries.find((entry) => entry.sessionId === 'session-a'
&& artifactPathKey(entry.path) === artifactPathKey(reportPath))
assert.equal(reportA?.availability, 'available')
assert.equal(reportA?.size, Buffer.byteLength('# Updated report\n'))
assert.equal(reportA?.sourceSessionTitle, '任务 A')
assert.equal(reportA?.workspaceRoot, root)
const missing = entries.find((entry) => artifactPathKey(entry.path) === artifactPathKey(missingPath))
assert.equal(missing?.availability, 'unavailable', '无法访问文件必须保留索引并标记不可用')
assert.equal(classifyArtifact('table.csv'), 'spreadsheet')
assert.equal(classifyArtifact('README.md'), 'document')
assert.equal(classifyArtifact('app.ts'), 'code')
const nativeWriteMessages = [message('native-write-message', 3_500, {
tools: [tool('native-write', 'write', {
path: 'native/native-write.md',
content: 'native OMP write'
})]
})]
const nativeCandidates = extractArtifactCandidates(nativeWriteMessages, {
sessionId: 'native-write-session',
cwd: root,
sourceSessionTitle: '原生写入任务'
})
assert.equal(nativeCandidates.length, 1, '小写 OMP write 应经 Write 别名规范化后提取')
assert.equal(nativeCandidates[0].sourceKind, 'write')
assert.equal(nativeCandidates[0].sourceToolCallId, 'native-write')
assert.equal(artifactPathKey(nativeCandidates[0].path), artifactPathKey(nativeWritePath))
const nativeEntries = await indexArtifactSources([{
id: 'native-write-session',
title: '原生写入任务',
createdAt: 350,
updatedAt: 3_500,
cwd: root,
messages: nativeWriteMessages
}], probeArtifactPath)
assert.equal(nativeEntries.length, 1)
assert.equal(nativeEntries[0].availability, 'available')
const directOutsidePath = join(outsideRoot, 'direct.md')
const linkedOutsidePath = join(outsideRoot, 'linked.md')
const insideDirectory = join(root, 'safe-inside')
const insidePath = join(insideDirectory, 'inside.md')
await writeFixture(directOutsidePath, 'outside')
await writeFixture(linkedOutsidePath, 'must stay unavailable')
await writeFixture(insidePath, 'safe')
const escapeLink = join(root, 'escape-link')
const safeLink = join(root, 'safe-link')
await fs.symlink(outsideRoot, escapeLink, process.platform === 'win32' ? 'junction' : 'dir')
await fs.symlink(insideDirectory, safeLink, process.platform === 'win32' ? 'junction' : 'dir')
const unreferencedDirectory = join(root, 'large-unreferenced-tree')
await fs.mkdir(unreferencedDirectory, { recursive: true })
await Promise.all(Array.from({ length: 64 }, (_, index) =>
fs.writeFile(join(unreferencedDirectory, `source-${index}.ts`), `export const value = ${index}\n`, 'utf8')
))
const securitySession: ArtifactSource = {
id: 'security-session',
title: '边界检查任务',
createdAt: 400,
updatedAt: 4_000,
workspaceId: 'workspace-a',
cwd: root,
messages: [message('security-message', 4_000, {
tools: [tool('security-present', 'present_files', {
files: [
{ path: directOutsidePath },
{ path: join(escapeLink, 'linked.md') },
{ path: join(safeLink, 'inside.md') }
]
})]
})]
}
let securityProbeCalls = 0
const securityEntries = await indexArtifactSources([securitySession], async (artifactPath, context) => {
securityProbeCalls += 1
return probeArtifactPath(artifactPath, context)
})
assert.equal(securityEntries.length, 3, '大目录中未引用文件不能被扫描为产物')
assert.equal(securityProbeCalls, 3, '每个索引条目只探测自身路径')
assert.equal(
securityEntries.find((entry) => entry.path === directOutsidePath)?.availability,
'unavailable',
'来源 cwd 之外的显式路径必须不可用'
)
assert.equal(
securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability,
'unavailable',
'指向 cwd 之外的目录链接必须不可用'
)
assert.equal(
securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability,
'available',
'仍位于来源 cwd realpath 内的链接应可用'
)
const userStatePath = join(root, 'state', 'library-state.json')
const userState = new JsonLibraryUserStateRepository(userStatePath)
const repository = new FileArtifactIndexRepository(probeArtifactPath)
const artifactIndex = new ArtifactIndexService({
repository,
listSourceSessions: async () => [sessionA, sessionB] satisfies SessionMeta[],
getMessages: async (sessionId) => sessionId === sessionA.id ? messagesA : messagesB
})
const library = new LibraryService({ artifactIndex, userState, now: () => 9_000 })
const snapshot = await library.getSnapshot()
assert.equal(snapshot.generatedAt, 9_000)
const snapshotReportA = snapshot.artifacts.find((entry) => entry.id === reportA?.id)
assert.ok(snapshotReportA)
await library.setFavorite(snapshotReportA.id, true)
await library.recordRecent(snapshotReportA.id, 9_100)
await assert.rejects(() => library.setFavorite('missing-artifact', true), /产物不存在/)
const updated = await library.getSnapshot()
const updatedReportA = updated.artifacts.find((entry) => entry.id === snapshotReportA.id)
assert.equal(updatedReportA?.favorite, true)
assert.equal(updatedReportA?.recentAt, 9_100)
const reloadedState = new JsonLibraryUserStateRepository(userStatePath)
const persisted = await reloadedState.get()
assert.deepEqual(persisted.favoriteArtifactIds, [snapshotReportA.id])
assert.deepEqual(persisted.recent, [{ artifactId: snapshotReportA.id, openedAt: 9_100 }])
assert.equal(
(await fs.readdir(dirname(userStatePath))).some((name) => name.endsWith('.tmp')),
false,
'原子写入不得遗留临时文件'
)
const catalogEntries: LibraryArtifactEntry[] = updated.artifacts.map((entry) => ({
...entry,
favorite: entry.id === snapshotReportA.id,
...(entry.id === snapshotReportA.id ? { recentAt: 9_100 } : {})
}))
const allFilters = { query: '', kind: 'all' as const, workspaceKey: 'all', favoritesOnly: false }
assert.equal(filterLibraryEntries(catalogEntries, 'recent', allFilters).length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
query: '任务 B'
}).length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
kind: 'spreadsheet'
}).length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
favoritesOnly: true
}).length, 1)
const workspaceOptions = listLibraryWorkspaces(catalogEntries)
assert.equal(workspaceOptions.length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
workspaceKey: workspaceOptions[0].key
}).length, 5)
const groups = groupLibraryEntries(catalogEntries)
assert.equal(groups.length, 1)
assert.equal(groups[0].tasks.length, 2, '同一空间内必须按任务分组')
assert.equal(groups[0].label, root.split(/[\\/]/).pop())
const sortedByName = sortLibraryEntries(catalogEntries, { key: 'name', direction: 'asc' }, 'local')
assert.ok(sortedByName[0].name.localeCompare(sortedByName.at(-1)!.name, 'zh-CN') <= 0)
assert.equal(formatLibraryBytes(1536), '1.5 KB')
assert.equal(formatLibraryBytes(1024), '1 KB')
assert.equal(formatLibraryBytes(undefined), '—')
const corruptPath = join(root, 'state', 'corrupt.json')
await fs.writeFile(corruptPath, '{not-json', 'utf8')
const corrupt = new JsonLibraryUserStateRepository(corruptPath)
await assert.rejects(() => corrupt.get(), /资料库收藏数据损坏|JSON|Unexpected|property name/i)
console.log('library-regression: ok')
console.log(JSON.stringify({
indexedEntries: entries.length,
crossTaskSamePathEntries: reportEntries.length,
unavailableEntries: entries.filter((entry) => entry.availability === 'unavailable').length,
workspaceGroups: groups.length,
taskGroups: groups[0].tasks.length,
persistedRecent: persisted.recent.length,
persistedFavorites: persisted.favoriteArtifactIds.length,
nativeWriteEntries: nativeEntries.length,
nativeWriteAvailability: nativeEntries[0]?.availability,
securityEntries: securityEntries.length,
securityProbeCalls,
crossRootUnavailable: securityEntries.find((entry) => entry.path === directOutsidePath)?.availability,
symlinkEscapeUnavailable: securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability,
safeSymlinkAvailable: securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability
}, null, 2))
} finally {
await Promise.all([
fs.rm(root, { recursive: true, force: true }),
outsideRoot ? fs.rm(outsideRoot, { recursive: true, force: true }) : Promise.resolve()
])
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { resolve } from 'node:path'
import { renderToStaticMarkup } from 'react-dom/server'
import type { LibrarySnapshot } from '../src/shared/library'
import type { LibraryArtifactEntry } from '../src/shared/library'
import LibraryPage, {
LibraryHomeContent,
LocalArtifactsView,
relativeTimeLabel
} from '../src/renderer/src/library/LibraryPageParity'
import { groupLibraryEntries } from '../src/renderer/src/library/catalog'
const now = Date.UTC(2026, 9, 17, 12, 0, 0)
const entry: LibraryArtifactEntry = {
id: 'artifact:session-a:report',
sessionId: 'session-a',
workspaceId: 'workspace-a',
workspaceName: 'YCode',
workspaceRoot: 'C:\\workspace',
sourceSessionTitle: '查看当前项目',
messageId: 'message-a',
sourceKind: 'write',
name: 'project-report.md',
path: 'C:\\workspace\\project-report.md',
kind: 'document',
availability: 'available',
size: 11_264,
createdAt: now - 4_000,
updatedAt: now - 60_000,
recentAt: now - 120_000,
favorite: true
}
const snapshot: LibrarySnapshot = {
artifacts: [entry],
generatedAt: now
}
const api = {
async getSnapshot(): Promise<LibrarySnapshot> {
return snapshot
},
async setFavorite() {
return { version: 1 as const, favoriteArtifactIds: [entry.id], recent: [] }
},
async recordRecent() {
return { version: 1 as const, favoriteArtifactIds: [entry.id], recent: [] }
}
}
async function main(): Promise<void> {
const homePage = renderToStaticMarkup(
<LibraryPage api={api} onOpenArtifact={() => undefined} />
)
assert.match(homePage, /class="library-page library-page--home"/)
assert.match(homePage, /class="library-sidebar knowledge-sidebar-pc"/)
assert.match(homePage, /最近访问/)
assert.match(homePage, /我分享的/)
assert.match(homePage, /与我共享/)
assert.match(homePage, /个人资料尚未接入/)
assert.match(homePage, /团队资料尚未接入/)
assert.doesNotMatch(homePage, /library-local-panel/)
const homeContent = renderToStaticMarkup(
<LibraryHomeContent
entries={[entry]}
kind="all"
loading={false}
sidebarCollapsed={false}
onToggleSidebar={() => undefined}
onKindChange={() => undefined}
onOpen={() => undefined}
/>
)
assert.match(homeContent, /class="library-home-main main-layout-content-pc"/)
assert.match(homeContent, />名称</)
assert.match(homeContent, />所有者</)
assert.match(homeContent, />位置</)
assert.match(homeContent, />最近访问</)
assert.match(homeContent, /project-report\.md/)
assert.match(homeContent, /了解资料库/)
assert.equal(
(homeContent.match(/class="wb-home-guide-card-illustration-pc library-guide__illustration"/g) ?? []).length,
2
)
assert.doesNotMatch(homeContent, /library-robot-shell/)
const groups = groupLibraryEntries([entry])
const local = renderToStaticMarkup(
<LocalArtifactsView
entries={[entry]}
groups={groups}
kind="all"
query=""
sort={{ key: 'updatedAt', direction: 'desc' }}
favoritesOnly={false}
favoriteCount={1}
loading={false}
searchInputRef={{ current: null }}
collapsedWorkspaces={new Set()}
collapsedTasks={new Set()}
onBack={() => undefined}
onKindChange={() => undefined}
onQueryChange={() => undefined}
onFavoritesOnlyChange={() => undefined}
onSort={() => undefined}
onToggleWorkspace={() => undefined}
onToggleTask={() => undefined}
onOpen={() => undefined}
onFavorite={() => undefined}
/>
)
assert.match(local, /class="library-local-panel my-files-panel"/)
assert.match(local, /aria-label="返回资料库"/)
assert.match(local, /搜索文件、任务或工作空间/)
assert.match(local, />类型</)
assert.match(local, />更新人</)
assert.match(local, />更新时间</)
assert.match(local, />大小</)
assert.match(local, /role="table" aria-label="本地产物列表"/)
assert.match(local, /role="rowgroup"/)
assert.match(local, /role="row"/)
assert.match(local, /role="columnheader"/)
assert.match(local, /role="cell"/)
assert.match(local, /aria-label="更新人,未知">—</)
assert.doesNotMatch(local, /<th\b/)
assert.match(local, /class="library-file-row /)
assert.match(local, /class="library-file-button /)
assert.equal((local.match(/class="my-files-list-action-btn"/g) ?? []).length, 2)
assert.equal((local.match(/class="my-files-list-action-wrapper"/g) ?? []).length, 2)
assert.doesNotMatch(local, /class="library-favorite |class="library-open-action/)
assert.equal((local.match(/class="my-files-flat-sort-icon"/g) ?? []).length, 1)
assert.match(local, /class="my-files-flat-sort-icon desc"/)
assert.doesNotMatch(local, /knowledge-sidebar-pc/)
assert.equal(relativeTimeLabel(now - 30_000, now), '刚刚')
assert.equal(relativeTimeLabel(now - 2 * 60_000, now), '2分钟前')
assert.equal(relativeTimeLabel(now - 3 * 60 * 60_000, now), '3小时前')
assert.equal(relativeTimeLabel(now - 2 * 24 * 60 * 60_000, now), '2天前')
const css = await fs.readFile(resolve('src/renderer/src/library/library.css'), 'utf8')
assert.match(
css,
/--library-font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;/
)
assert.match(css, /\.library-page--home\.is-sidebar-collapsed,\s*\.library-page--local \{\s*grid-template-columns: minmax\(0, 1fr\);/)
assert.match(css, /grid-template-columns: var\(--library-sidebar-width\) minmax\(0, 1fr\);/)
assert.match(css, /\.library-local-column \{\s*width: 100px;/)
assert.match(css, /\.library-file-row \{\s*height: 46px;/)
assert.match(css, /\.library-sidebar-nav \{\s*[\s\S]*?padding: 2px 12px 10px;/)
assert.match(css, /\.library-page \.library-nav-item \{\s*box-sizing: border-box;\s*padding: 4px 12px;/)
assert.match(css, /\.library-page \.library-type-button \{\s*box-sizing: border-box;[\s\S]*?border: 1px solid var\(--library-border\);/)
assert.match(css, /\.my-files-flat-sort-icon\.asc,[\s\S]*?opacity: 1;/)
assert.match(css, /\.library-page \.my-files-list-action-btn \{[\s\S]*?width: 28px;\s*height: 28px;/)
console.log('library-ui-regression: ok')
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { resolve, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { cheapestModelPrice, lookupModelMetadata } from '../src/shared/model-metadata'
import { modelThinkingLevel } from '../src/shared/model-thinking'
import { resolveModelTokenLimits } from '../src/shared/model-token-limits'
import type * as Metadata from '../src/main/model-metadata'
const directory = await mkdtemp(resolve('.tmp/model-metadata-regression-')), cache = join(directory, 'model-metadata')
const catalog = JSON.parse(await readFile('resources/model-metadata/models.dev.json', 'utf8')), manifest = JSON.parse(await readFile('resources/model-metadata/manifest.json', 'utf8'))
const checks: string[] = []
const data = Buffer.from(JSON.stringify({ test: { id: 'test', models: { image: { id: 'image', name: 'Test image', tool_call: true, modalities: { input: ['text', 'image'] }, limit: { context: 32000, output: 4000 } } } } }))
const digest = createHash('sha256').update(data).digest('hex')
await mkdir(cache)
const file = join(directory, 'metadata.mjs'), resourceUrl = pathToFileURL(resolve('src/main/model-metadata.ts')).href
await build({ entryPoints: ['src/main/model-metadata.ts'], outfile: file, bundle: true, platform: 'node', format: 'esm', logLevel: 'warning',
define: { 'import.meta.url': JSON.stringify(resourceUrl) },
plugins: [{ name: 'metadata-electron', setup(build) { build.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock' })); build.onLoad({ filter: /.*/, namespace: 'mock' }, () => ({ contents: `export const app={isPackaged:false,getPath:()=>${JSON.stringify(directory)}}`, loader: 'js' })) } }] })
let instance = 0
const reload = (): Promise<typeof Metadata> => import(pathToFileURL(file).href + '?instance=' + ++instance)
async function cached(fetchedAt: string, sha256 = digest): Promise<void> { await writeFile(join(cache, digest + '.json'), data); await writeFile(join(cache, 'manifest.json'), JSON.stringify({ fetchedAt, sha256, providers: 1, models: 1, source: 'https://models.dev/api.json' })) }
await cached('2000-01-01T00:00:00Z')
assert.equal((await (await reload()).modelMetadataStatus()).sha256, manifest.sha256); checks.push('an older persistent cache cannot replace newer bundled metadata')
await cached('2099-01-01T00:00:00Z')
assert.equal((await (await reload()).modelMetadataStatus()).sha256, digest); checks.push('newer persistent metadata survives application reload')
await cached('2099-01-01T00:00:00Z', '0'.repeat(64))
assert.equal((await (await reload()).modelMetadataStatus()).sha256, manifest.sha256); checks.push('invalid cache digest falls back to the bundled snapshot')
const originalFetch = globalThis.fetch
try {
const metadata = await reload()
globalThis.fetch = async () => new Response(data, { headers: { 'content-type': 'application/json' } })
const updated = await metadata.refreshModelMetadata()
assert.equal(updated.sha256, digest)
assert.equal(JSON.parse(await readFile(join(cache, 'manifest.json'), 'utf8')).sha256, digest)
assert.equal((await metadata.modelMetadata('image'))?.supportsImages, true)
globalThis.fetch = async () => new Response('failed', { status: 503 })
await assert.rejects(() => metadata.refreshModelMetadata(), /503/)
assert.equal((await metadata.modelMetadataStatus()).sha256, digest); checks.push('manual refresh persists validated data atomically; a failed refresh retains usable metadata')
} finally { globalThis.fetch = originalFetch }
const qwen = lookupModelMetadata(catalog, 'qwen3.5-plus', 'openai', manifest)!
assert.equal(qwen.providerId, 'alibaba'); checks.push('canonical IDs take precedence over reseller and subscription prices')
for (const id of ['toString', 'constructor', 'constructor/not-a-model']) assert.equal(lookupModelMetadata(catalog, id, 'auto', manifest), null)
const crossed = cheapestModelPrice({ id: 'crossed', limit: { context: 400000 }, cost: { input: 2, output: 6, tiers: [{ input: 2.5, output: 5, tier: { type: 'context', size: 200000 } }] } })!
assert.deepEqual(crossed, { input: 2, output: 6, maxContext: 200000, minContext: undefined, cacheRead: undefined, cacheWrite: undefined, crossedTiers: true }); checks.push('crossed prices keep the smallest context band without assuming a token mix')
const gemini = lookupModelMetadata(catalog, 'gemini-2.5-flash', 'gemini', manifest)!
const profile = { id: 'test', model: 'test', name: 'Test', providerId: 'custom', baseUrl: 'https://example.com', hasApiKey: false, keyStorage: 'none' as const, ...gemini }
assert.equal(modelThinkingLevel(profile, 'high'), 'minimal')
assert.equal(modelThinkingLevel({ ...profile, canDisableThinking: false, onlyReasoning: true }, 'off'), 'minimal')
assert.equal(modelThinkingLevel({ ...profile, supportedEfforts: [], defaultEffort: undefined }), 'auto')
assert.equal(modelThinkingLevel({ ...profile, supportsReasoning: false }, 'high'), 'off'); checks.push('selector and requests share defaults, clamp unsupported effort and respect mandatory reasoning')
const sol = lookupModelMetadata(catalog, 'gpt-6.1-sol', 'openai', manifest)!
assert.deepEqual(sol.limits, { contextWindowTokens: 1050000, maxInputTokens: 922000, maxOutputTokens: 128000 })
assert.equal(resolveModelTokenLimits({ ...sol, tokenBudgetVersion: 2, metadata: sol }).contextWindowTokens, 272000)
assert.equal(sol.priceTiers?.length, 2); checks.push('catalog ceilings and price tiers remain available without expanding automatic cheap defaults')
const expanded = resolveModelTokenLimits({ tokenBudgetVersion: 2, metadata: sol, contextWindowTokens: 500000, maxOutputTokens: 128000 })
assert.equal(expanded.error, undefined)
assert.equal(expanded.contextWindowTokens, 500000)
assert.equal(expanded.maxInputTokens, 372000); assert.equal(expanded.maxOutputTokens, 128000)
assert.deepEqual(expanded.compaction, { reserveTokens: 128000, thresholdTokens: 372000 })
assert.equal(expanded.price?.input, 4); assert.equal(expanded.price?.output, 15)
checks.push('500K total and 128K output derive 372K input and compression threshold without expanding the total')
const reduced = resolveModelTokenLimits({ tokenBudgetVersion: 2, metadata: sol, contextWindowTokens: 32768, maxOutputTokens: 4096 })
assert.equal(reduced.contextWindowTokens, 32768); assert.equal(reduced.maxInputTokens, 28672); assert.equal(reduced.price?.input, 2)
checks.push('reducing total context recomputes input and the matching price band')
assert.match(resolveModelTokenLimits({ metadata: sol, maxInputTokens: 500000, maxOutputTokens: 131072 }).error!, /128,000/)
assert.match(resolveModelTokenLimits({ metadata: sol, maxInputTokens: 1100000, maxOutputTokens: 128000 }).error!, /1,050,000/)
assert.match(resolveModelTokenLimits({ tokenBudgetVersion: 2, metadata: sol, contextWindowTokens: 128000, maxOutputTokens: 128000 }).error!, /必须小于/)
assert.match(resolveModelTokenLimits({ maxInputTokens: -1 }).error!, /非负整数/)
checks.push('invalid total context and output budgets are rejected')
const migrated = resolveModelTokenLimits({ metadata: sol, maxInputTokens: 500000, maxOutputTokens: 128000, contextWindowTokens: 628000 })
assert.equal(migrated.contextWindowTokens, 500000); assert.equal(migrated.maxInputTokens, 372000); assert.equal(migrated.tokenBudgetVersion, 2)
checks.push('legacy first-field choices migrate to totals rather than retaining the erroneous sum')
const full = resolveModelTokenLimits({ tokenBudgetVersion: 2, metadata: sol, contextWindowTokens: 1050000, maxOutputTokens: 128000 })
assert.equal(full.maxInputTokens, 922000); assert.deepEqual(full.compaction, { reserveTokens: 157500, thresholdTokens: 892500 })
const small = resolveModelTokenLimits({ tokenBudgetVersion: 2, contextWindowTokens: 8192, maxOutputTokens: 1234 })
assert.deepEqual(small.compaction, { reserveTokens: 1234, thresholdTokens: 6958 })
checks.push('compression follows the larger of output reserve and OMP headroom, including small windows')
console.log('MODEL_METADATA_REGRESSION_PASS ' + JSON.stringify({ directory, checks }))
+85 -2
View File
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'
import { createServer } from 'node:http'
import type { CustomModelInput } from '../src/shared/models'
import type * as Registry from '../src/main/models'
import { endpointCandidates, inferModelApi } from '../src/shared/model-endpoint'
const STATE_KEY = '__YCODE_MODEL_REGISTRY_TEST__'
const globals = globalThis as typeof globalThis & Record<string, unknown>
@@ -32,6 +33,7 @@ try {
const legacyBefore = await readFile(configFile, 'utf8')
await build({
entryPoints: [join(process.cwd(), 'src/main/models.ts')], outfile, bundle: true, platform: 'node', format: 'esm', logLevel: 'silent',
define: { 'import.meta.url': JSON.stringify(pathToFileURL(join(process.cwd(), 'src/main/model-metadata.ts')).href) },
plugins: [{ name: 'model-registry-electron', setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock' }, () => ({ loader: 'js', contents: `
@@ -81,7 +83,8 @@ try {
assert.equal(edited?.config.model, 'updated-model')
assert.equal(edited?.config.apiKey, 'new-keywith-space', 'editing an empty secret keeps the stored secret')
assert.deepEqual(edited?.profile.supportedEfforts, ['high', 'low'])
assert.equal(edited?.profile.maxInputTokens, 131072)
assert.equal(edited?.profile.contextWindowTokens, 131072)
assert.equal(edited?.profile.maxInputTokens, 98304)
registry = await reload()
assert.equal((await registry.resolveModel())?.profile.id, 'renamed', 'selection survives a fresh module/application load')
await registry.setSelectedModel(null)
@@ -107,6 +110,16 @@ try {
assert.equal(JSON.stringify(fallback).includes('new-keywith-space'), false)
state.encryption = true
for (const protocol of ['openai', 'auto'] as const) {
assert.equal(inferModelApi('https://provider.example', protocol), 'openai-responses')
const candidates = endpointCandidates('https://provider.example', protocol)
assert.equal(candidates[0].api, 'openai-responses')
assert(candidates.findIndex(endpoint => endpoint.api === 'openai-completions') > candidates.findLastIndex(endpoint => endpoint.api === 'openai-responses'))
assert.equal(endpointCandidates('https://provider.example/v1/chat/completions/', protocol)[0].api, 'openai-completions')
}
assert.equal(inferModelApi('https://api.anthropic.com', 'auto'), 'anthropic-messages')
assert.equal(inferModelApi('https://generativelanguage.googleapis.com', 'auto'), 'google-generative-ai')
let responseMode = 'success'
let responsePayload: unknown = undefined
const requests: Array<{ path: string; auth?: string; data: Record<string, unknown> }> = []
@@ -120,6 +133,15 @@ try {
if (responseMode === 'not-found') { response.statusCode = 404; response.end(JSON.stringify({ error: 'private-endpoint-echo' })); return }
if (responseMode === 'malformed') { response.end('not JSON'); return }
if (responseMode === 'wrong-shape') { response.end('{}'); return }
if (responseMode === 'negotiate-both' || responseMode === 'negotiate-chat-only') {
if (request.url?.endsWith('/responses') && responseMode === 'negotiate-both') {
response.end(JSON.stringify({ object: 'response', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'OK' }] }] })); return
}
if (request.url?.endsWith('/chat/completions')) {
response.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'OK' } }] })); return
}
response.statusCode = 404; response.end('{}'); return
}
if (responsePayload !== undefined) { response.end(JSON.stringify(responsePayload)); return }
response.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'OK' } }] }))
})
@@ -201,6 +223,31 @@ try {
assert.equal(invalidResult.message.includes(endpoint), false)
}
responsePayload = undefined
responseMode = 'negotiate-both'
for (const protocol of ['openai', 'auto'] as const) {
const start = requests.length
const negotiated = await registry.testModel(input('prefer-responses-' + protocol, { baseUrl: endpoint, protocol }))
assert.equal(negotiated.success, true)
assert.equal(negotiated.endpoint?.api, 'openai-responses', 'Responses wins when both OpenAI APIs are supported')
assert.deepEqual(requests.slice(start).map(request => request.path), ['/v1/responses'], 'a successful Responses probe must not call Chat')
}
const previousChat = { api: 'openai-completions' as const, baseUrl: endpoint + '/custom', checkedAt: Date.now() - 1000, verifiedBy: 'generation' as const }
const previousInput = input('previous-chat', { baseUrl: endpoint, protocol: 'openai', endpoint: previousChat })
const startRetest = requests.length
const retested = await registry.testModel(previousInput)
assert.equal(retested.endpoint?.api, 'openai-responses', 're-testing a stored Chat route follows the new priority')
assert.equal(retested.endpoint?.baseUrl, previousChat.baseUrl, 're-testing preserves a negotiated custom root')
assert.deepEqual(requests.slice(startRetest).map(request => request.path), ['/v1/custom/responses'])
assert.equal(previousChat.api, 'openai-completions', 'testing must not mutate the supplied stored route')
const explicitChat = await registry.testModel(input('explicit-chat-protocol', { baseUrl: endpoint + '/chat/completions', protocol: 'openai' }))
assert.equal(explicitChat.endpoint?.api, 'openai-completions', 'an explicit Chat URL remains an intentional override')
assert.equal(requests.at(-1)?.path, '/v1/chat/completions')
responseMode = 'negotiate-chat-only'
const startFallback = requests.length
const chatFallback = await registry.testModel(input('chat-only-protocol', { baseUrl: endpoint, protocol: 'openai' }))
assert.equal(chatFallback.success, true)
assert.equal(chatFallback.endpoint?.api, 'openai-completions')
assert.deepEqual(requests.slice(startFallback).map(request => request.path), ['/v1/responses', '/v1/chat/completions'], 'unsupported Responses falls back to Chat')
responseMode = 'auth'
const authFailure = await registry.testModel(testInput)
assert.equal(authFailure.success, false)
@@ -236,6 +283,42 @@ try {
try {
assert.match((await registry.testModel(testInput)).message, /超时/)
} finally { globalThis.fetch = originalFetch }
const largeModel = input('large-sol', { model: 'gpt-6.1-sol', baseUrl: endpoint, protocol: 'openai',
endpoint: { api: 'openai-responses', baseUrl: endpoint, checkedAt: Date.now(), verifiedBy: 'generation' },
maxInputTokens: 500000, maxOutputTokens: 128000, contextWindowTokens: 272000 })
await registry.saveModel(largeModel)
let large = await registry.resolveModel(largeModel.id)
assert.deepEqual([large?.profile.maxInputTokens, large?.profile.maxOutputTokens, large?.profile.contextWindowTokens], [372000, 128000, 500000])
assert.equal(large?.profile.tokenBudgetVersion, 2)
assert.deepEqual([large?.profile.price?.input, large?.profile.price?.output, large?.config.maxOutputTokens], [4, 15, 128000])
const beforeInvalidLimit = await readFile(modelsFile, 'utf8')
await assert.rejects(() => registry.saveModel({ ...largeModel, previousId: largeModel.id, maxOutputTokens: 131072 }), /128,000/)
await assert.rejects(() => registry.saveModel({ ...largeModel, previousId: largeModel.id, maxInputTokens: 1100000 }), /1,050,000/)
await assert.rejects(() => registry.saveModel({ ...largeModel, previousId: largeModel.id, tokenBudgetVersion: 2, contextWindowTokens: 128000 }), /必须小于/)
assert.equal(await readFile(modelsFile, 'utf8'), beforeInvalidLimit, 'invalid token budgets must not change saved profiles')
registry = await reload()
large = await registry.resolveModel(largeModel.id)
assert.equal(large?.profile.contextWindowTokens, 500000, 'configured total survives a fresh registry load')
assert.equal(large?.config.maxOutputTokens, 128000, 'the actual request budget survives a fresh registry load')
const legacyRegistry = JSON.parse(await readFile(modelsFile, 'utf8'))
const legacyBudget = legacyRegistry.models.find((model: {id: string}) => model.id === largeModel.id)
delete legacyBudget.tokenBudgetVersion
legacyBudget.maxInputTokens = 500000; legacyBudget.contextWindowTokens = 628000
await writeFile(modelsFile, JSON.stringify(legacyRegistry))
const legacyBytes = await readFile(modelsFile, 'utf8')
registry = await reload()
const migratedBudget = (await registry.listModels()).models.find(model => model.id === largeModel.id)!
assert.deepEqual([migratedBudget.contextWindowTokens, migratedBudget.maxInputTokens, migratedBudget.maxOutputTokens, migratedBudget.tokenBudgetVersion], [500000, 372000, 128000, 2])
assert.equal((await registry.resolveModel(largeModel.id))?.profile.contextWindowTokens, 500000)
assert.equal(await readFile(modelsFile, 'utf8'), legacyBytes, 'reading legacy budgets must not rewrite credentials or unrelated registry entries')
await registry.saveModel({ ...largeModel, previousId: largeModel.id, tokenBudgetVersion: 2, contextWindowTokens: 500000, maxInputTokens: 372000 })
registry = await reload()
assert.equal((await registry.resolveModel(largeModel.id))?.profile.contextWindowTokens, 500000, 'a persisted migrated budget must not subtract output twice')
await registry.saveModel({ ...largeModel, previousId: largeModel.id, maxInputTokens: 32768, maxOutputTokens: 4096, contextWindowTokens: 628000 })
const reduced = await registry.resolveModel(largeModel.id)
assert.equal(reduced?.profile.contextWindowTokens, 32768)
assert.equal(reduced?.profile.maxInputTokens, 28672)
assert.equal(reduced?.profile.price?.input, 2)
await registry.setSelectedModel('renamed')
const deleted = await registry.deleteModel('renamed')
assert.equal(deleted.selectedModelId, deleted.models[0].id)
@@ -250,7 +333,7 @@ try {
registry = await reload()
await assert.rejects(() => registry.listModels(), /损坏/)
assert.equal(await readFile(modelsFile, 'utf8'), '{malformed', 'corrupt registries must never be silently overwritten')
console.log('PASS model registry: migration, encrypted CRUD, concurrent writes, persistence, selection, Chat/Responses HTTP schema, incomplete reasoning, blank-secret reuse and secret-safe failures')
console.log('PASS model registry: migration, encrypted CRUD, concurrent writes, persistence, selection, total/output budgets, legacy budget migration, derived input/reload/reduction, catalog limits, Responses priority, explicit Chat override, Chat fallback, stored-route retesting, Chat/Responses HTTP schema, incomplete reasoning, blank-secret reuse and secret-safe failures')
} finally {
if (server) await new Promise<void>(resolve => server!.close(() => resolve()))
globals[STATE_KEY] = previous
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import { normalize } from 'node:path'
import { detectOmpPath } from '../src/main/omp'
delete process.env['YCODE_OMP_PATH']
const detected = detectOmpPath()
assert.ok(detected, 'bundled OMP must be detected without YCODE_OMP_PATH')
const expected = normalize('resources/omp/omp.exe')
assert.ok(normalize(detected).toLowerCase().endsWith(expected.toLowerCase()),
`detected OMP must resolve to the bundled resources/omp/omp.exe, got ${detected}`)
console.log(`PASS detectOmpPath => ${detected}`)
+10 -2
View File
@@ -248,6 +248,7 @@ interface IpcTestState {
promptCalls: number
abortCalls: number
rejectNextPrompt: boolean
thinkingLevels: string[]
}
async function testIpcCancellationPersistence(): Promise<void> {
@@ -259,12 +260,14 @@ async function testIpcCancellationPersistence(): Promise<void> {
kernel: null,
promptCalls: 0,
abortCalls: 0,
rejectNextPrompt: false
rejectNextPrompt: false,
thinkingLevels: []
}
await mkdir(state.userData, { recursive: true })
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_TEST__?: IpcTestState }
const previous = globalWithMock.__YCODE_OMP_TEST__
globalWithMock.__YCODE_OMP_TEST__ = state
let cleanupKernels: (() => Promise<void>) | undefined
try {
const entry = join(process.cwd(), 'src', 'main', 'ipc.ts')
@@ -298,6 +301,7 @@ async function testIpcCancellationPersistence(): Promise<void> {
encryptString: (value) => Buffer.from(value),
decryptString: (value) => value.toString()
};
export const powerSaveBlocker = { start: () => 1, stop: () => undefined, isStarted: () => true };
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
@@ -314,6 +318,7 @@ async function testIpcCancellationPersistence(): Promise<void> {
import { EventEmitter } from 'node:events';
export class OmpKernel extends EventEmitter {
constructor() { super(); globalThis.__YCODE_OMP_TEST__.kernel = this; }
setThinkingLevel(level) { globalThis.__YCODE_OMP_TEST__.thinkingLevels.push(level); return Promise.resolve(); }
prompt() {
const state = globalThis.__YCODE_OMP_TEST__;
state.promptCalls++;
@@ -334,7 +339,7 @@ async function testIpcCancellationPersistence(): Promise<void> {
setImmediate(() => this.emit('event', { type: 'agent_end', yielded: true }));
return Promise.resolve({ type: 'response', success: true });
}
dispose() {}
dispose() { return Promise.resolve(); }
}
export const detectOmpPath = () => 'mock-omp';
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
@@ -346,6 +351,7 @@ async function testIpcCancellationPersistence(): Promise<void> {
})
const ipc = await import(pathToFileURL(outfile).href)
cleanupKernels = ipc.disposeOmpKernels
;(ipc.registerIpc as (getWindow: () => unknown) => void)(() => ({
webContents: { send: () => undefined }
}))
@@ -371,6 +377,7 @@ async function testIpcCancellationPersistence(): Promise<void> {
if (messages.at(-1)?.status !== 'streaming') break
}
check('the fake OMP kernel received one prompt and one abort', state.promptCalls === 1 && state.abortCalls === 1)
check('a non-reasoning model explicitly disables native thinking before prompt', state.thinkingLevels[0] === 'off')
check('cancelled assistant status is persisted through IPC', messages.at(-1)?.role === 'assistant' && messages.at(-1)?.status === 'cancelled', JSON.stringify(messages))
const sessionsFile = join(state.userData, 'sessions.json')
@@ -418,6 +425,7 @@ async function testIpcCancellationPersistence(): Promise<void> {
JSON.stringify(errorDiskMessages)
)
} finally {
await cleanupKernels?.()
globalWithMock.__YCODE_OMP_TEST__ = previous
await rm(tempRoot, { recursive: true, force: true })
}
+9 -5
View File
@@ -1,6 +1,6 @@
/**
* TODO 13:个性化提示词拼装的离线回归。
* 覆盖默认值不产生多余上下文、身份/风格/指令/人设/长期记忆分别进入提示词、超长截断与非法风格回退。
* 覆盖默认人设生效、身份/风格/指令/人设/长期记忆分别进入提示词、超长截断与非法风格回退。
*/
import assert from 'node:assert/strict'
import { buildPersonalizationPrompt, DEFAULT_PERSONA, normalizePersonalization } from '../src/shared/personalization'
@@ -10,13 +10,13 @@ assert.equal(base.tone, 'default')
assert.equal(base.loadingTips, true)
assert.equal(base.showFileChanges, false)
assert.equal(base.persona, DEFAULT_PERSONA)
assert.equal(buildPersonalizationPrompt(base), '', '默认设置不应把原版人设注入每轮提示词')
assert.ok(buildPersonalizationPrompt(base).includes(`### 人设设定\n${DEFAULT_PERSONA}`), '默认人设应在 UI 显示生效时进入提示词')
const identity = buildPersonalizationPrompt(normalizePersonalization({ userCallName: ' 小明 ', aiName: ' YCode 助手 ' }))
assert.match(identity, /^## 个性化偏好(用户设置,始终遵守)/)
assert.ok(identity.includes('- 用户希望被称呼为:小明'))
assert.ok(identity.includes('- 你的名字是:YCode 助手;自我介绍时使用这个名字。'))
assert.ok(!identity.includes(DEFAULT_PERSONA), '默认人设不应进入提示词')
assert.ok(identity.includes(DEFAULT_PERSONA), '默认人设应与其它生效的人设字段一致进入提示词')
const tone = buildPersonalizationPrompt(normalizePersonalization({ tone: 'professional' }))
assert.ok(tone.includes('- 回复风格:专业严谨——清晰、准确、值得信赖。'))
@@ -32,11 +32,15 @@ assert.ok(memory.includes('### 长期记忆\n用户偏好 TypeScript。'))
const long = buildPersonalizationPrompt(normalizePersonalization({ customPrompt: 'x'.repeat(8100) }))
assert.ok(long.includes('(内容过长,已截断)'))
assert.ok(long.length < 8100, '超长字段应被截断')
assert.ok(long.includes('x'.repeat(8000)), '超长用户指令应保留允许的前 8000 字符')
assert.ok(!long.includes('x'.repeat(8001)), '超长用户指令应在 8000 字符后截断')
const fallback = normalizePersonalization({ tone: 'not-a-tone' as never })
assert.equal(fallback.tone, 'default')
assert.equal(buildPersonalizationPrompt(fallback), '')
assert.ok(buildPersonalizationPrompt(fallback).includes(DEFAULT_PERSONA))
const noPersona = buildPersonalizationPrompt(normalizePersonalization({ persona: '' }))
assert.equal(noPersona, '', '明确清空人设后不应回填默认文本')
const tips = normalizePersonalization({ loadingTips: false, showFileChanges: true })
assert.equal(tips.loadingTips, false)
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../src/shared/types'
import {
deriveProjectTaskStatus,
matchesProjectTaskFilter,
projectTaskStatusClass,
runningSessionIds,
} from '../src/renderer/src/projects/project-task-model'
import {
projectCapabilityRefKey,
uniqueProjectCapabilityRefs,
} from '../src/renderer/src/projects/project-capability-model'
function assistant(overrides: Partial<ChatMessage>): ChatMessage {
return {
id: `assistant-${Math.random()}`,
role: 'assistant',
content: '',
createdAt: 1,
...overrides,
}
}
assert.deepEqual(
[...runningSessionIds({
byRunId: { sessionId: 'session-a' },
orphan: {},
duplicate: { sessionId: 'session-a' },
})],
['session-a'],
)
assert.equal(deriveProjectTaskStatus([], false), 'idle')
assert.equal(deriveProjectTaskStatus([], true), 'running')
assert.equal(deriveProjectTaskStatus([assistant({ status: 'done' })], true), 'running')
assert.equal(deriveProjectTaskStatus([assistant({ status: 'done' })], false), 'completed')
assert.equal(deriveProjectTaskStatus([assistant({ status: 'streaming' })], true), 'running')
assert.equal(deriveProjectTaskStatus([assistant({ status: 'done', interrupted: true })], false), 'cancelled')
assert.equal(deriveProjectTaskStatus([assistant({ status: 'error' })], false), 'error')
assert.equal(deriveProjectTaskStatus([assistant({ status: 'cancelled' })], false), 'cancelled')
assert.equal(deriveProjectTaskStatus([assistant({})], false), 'unknown')
assert.equal(deriveProjectTaskStatus([
assistant({ status: 'error' }),
assistant({ status: 'done' }),
{ id: 'user-latest', role: 'user', content: 'next', createdAt: 2 },
], false), 'completed')
assert.equal(matchesProjectTaskFilter('running', 'active'), true)
assert.equal(matchesProjectTaskFilter('completed', 'active'), false)
assert.equal(matchesProjectTaskFilter('completed', 'completed'), true)
assert.equal(matchesProjectTaskFilter('idle', 'completed'), false)
assert.equal(matchesProjectTaskFilter('error', 'active'), false)
assert.equal(matchesProjectTaskFilter('cancelled', 'completed'), false)
assert.equal(matchesProjectTaskFilter('unknown', 'all'), true)
assert.equal(projectTaskStatusClass('running'), '')
assert.equal(projectTaskStatusClass('completed'), ' wb-status-dot--success')
assert.equal(projectTaskStatusClass('error'), ' wb-status-dot--error')
assert.equal(projectTaskStatusClass('cancelled'), ' wb-status-dot--error')
assert.equal(projectTaskStatusClass('idle'), ' wb-status-dot--muted')
assert.equal(projectTaskStatusClass('unknown'), ' wb-status-dot--muted')
const sameIdRefs = [
{ id: 'shared-expert', source: 'market' as const, provider: 'provider-a' },
{ id: 'shared-expert', source: 'market' as const, provider: 'provider-b' },
{ id: 'shared-expert', source: 'market' as const, provider: 'provider-a' },
]
assert.notEqual(projectCapabilityRefKey(sameIdRefs[0]!), projectCapabilityRefKey(sameIdRefs[1]!))
assert.deepEqual(uniqueProjectCapabilityRefs(sameIdRefs), sameIdRefs.slice(0, 2))
console.log('PROJECT_TASK_MODEL_PASS')
@@ -0,0 +1,39 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".user-menu-popover",
".user-menu-items",
"[data-settings-tab=\"buddyApps\"]",
".settings-navigation__item--active[data-settings-tab=\"buddyApps\"]",
".settings-modal__panel"
],
"actions": [
{
"kind": "click",
"step_id": "open-account-menu",
"selector": ".user-menu-trigger--workbuddy",
"index": 0,
"frames": 0
},
{
"kind": "wait",
"step_id": "account-menu-ready",
"duration_ms": 500,
"frames": 0
},
{
"kind": "click",
"step_id": "open-settings-window",
"selector": ".user-menu-items .user-menu-item",
"index": 3,
"frames": 0
},
{
"kind": "wait",
"step_id": "settings-window-ready",
"duration_ms": 1200,
"frames": 0
}
]
}
@@ -0,0 +1,16 @@
{
"actions": [
{
"kind": "click",
"step_id": "open-library",
"selector": ".conversation-list-tab-button:nth-child(5)",
"frames": 0
},
{
"kind": "wait",
"step_id": "library-stable",
"duration_ms": 1000,
"frames": 1
}
]
}
@@ -0,0 +1,12 @@
{
"actions": [
{ "kind": "click", "step_id": "open-type-filter", "selector": ".wb-home-type-btn-pc", "frames": 1 },
{ "kind": "key", "step_id": "close-type-filter", "key": "Escape", "frames": 0 },
{ "kind": "click", "step_id": "open-personal-add", "selector": ".workspace-tree-section-add-btn-pc", "index": 0, "frames": 1 },
{ "kind": "key", "step_id": "close-personal-add", "key": "Escape", "frames": 0 },
{ "kind": "click", "step_id": "open-library-search", "selector": ".knowledge-sidebar-search-btn-pc", "frames": 1 },
{ "kind": "key", "step_id": "close-library-search", "key": "Escape", "frames": 0 },
{ "kind": "click", "step_id": "open-local-artifacts", "selector": ".memory-nav-item-pc", "index": 1, "frames": 0 },
{ "kind": "wait", "step_id": "local-artifacts-stable", "duration_ms": 1000, "frames": 1 }
]
}
@@ -0,0 +1,25 @@
{
"include_fonts": false,
"track_selectors": [
".conversation-list-tab-button",
".library-page",
".library-nav-item",
".my-files-flat-row--file",
".my-files-flat-col-actions"
],
"actions": [
{
"kind": "click",
"step_id": "open-library",
"selector": ".conversation-list-tab-button",
"index": 4,
"frames": 1
},
{
"kind": "wait",
"step_id": "library-ready",
"duration_ms": 450,
"frames": 1
}
]
}
@@ -0,0 +1,7 @@
{
"media_selectors": [
".wb-home-guide-card-agent-pc .wb-home-guide-card-illustration-pc",
".wb-home-guide-card-html-pc .wb-home-guide-card-illustration-pc"
],
"actions": []
}
@@ -0,0 +1,6 @@
{
"actions": [
{ "kind": "click", "step_id": "library-assets-open", "selector": ".conversation-list-tab-button:nth-child(5)", "frames": 0 },
{ "kind": "wait", "step_id": "library-assets-ready", "duration_ms": 1000, "frames": 1 }
]
}
@@ -0,0 +1,6 @@
{
"actions": [
{ "kind": "click", "step_id": "restore-current-task", "selector": ".conversation-agent-card", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "restored-current-task", "duration_ms": 600, "frames": 1 }
]
}
@@ -0,0 +1,64 @@
{
"include_fonts": false,
"track_selectors": [
".my-files-flat-col-actions",
".my-files-list-action-wrapper",
".my-files-list-action-btn",
"[role=dialog]",
"[role=menu]",
".wb-popover",
".wb-dropdown"
],
"actions": [
{
"kind": "click",
"step_id": "open-local-share",
"selector": ".my-files-flat-row--file .my-files-list-action-btn",
"index": 0,
"frames": 1
},
{
"kind": "wait",
"step_id": "local-share-open-stable",
"duration_ms": 350,
"frames": 1
},
{
"kind": "key",
"step_id": "close-local-share",
"key": "Escape",
"frames": 0
},
{
"kind": "wait",
"step_id": "local-share-close-stable",
"duration_ms": 180,
"frames": 1
},
{
"kind": "click",
"step_id": "open-local-more",
"selector": ".my-files-flat-row--file .my-files-list-action-btn",
"index": 1,
"frames": 1
},
{
"kind": "wait",
"step_id": "local-more-open-stable",
"duration_ms": 350,
"frames": 1
},
{
"kind": "key",
"step_id": "close-local-more",
"key": "Escape",
"frames": 0
},
{
"kind": "wait",
"step_id": "local-more-close-stable",
"duration_ms": 180,
"frames": 1
}
]
}
@@ -0,0 +1,22 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".memory-settings-panel"
],
"actions": [
{
"kind": "key",
"step_id": "close-memory-modal",
"key": "Escape",
"frames": 2,
"interval_ms": 120
},
{
"kind": "wait",
"step_id": "memory-modal-closed-settled",
"duration_ms": 300,
"frames": 1
}
]
}
@@ -0,0 +1,13 @@
{
"include_fonts": false,
"include_message_content": true,
"track_selectors": [],
"actions": [
{
"kind": "wait",
"step_id": "capture-import-dialog-text",
"duration_ms": 10,
"frames": 0
}
]
}
@@ -0,0 +1,48 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".settings-navigation__item--active",
".appearance-card--dark",
".memory-settings-panel"
],
"actions": [
{
"kind": "click",
"step_id": "open-appearance",
"selector": "[data-settings-tab=\"appearance\"]",
"frames": 1
},
{
"kind": "wait",
"step_id": "appearance-ready",
"duration_ms": 250,
"frames": 0
},
{
"kind": "click",
"step_id": "select-dark-theme",
"selector": ".appearance-card--dark",
"frames": 1
},
{
"kind": "wait",
"step_id": "dark-theme-ready",
"duration_ms": 700,
"frames": 1
},
{
"kind": "click",
"step_id": "return-to-memory-in-dark-theme",
"selector": "[data-settings-tab=\"memory\"]",
"frames": 1
},
{
"kind": "wait",
"step_id": "memory-dark-stable",
"duration_ms": 700,
"frames": 2
}
]
}
@@ -0,0 +1,24 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".memory-settings-panel",
".memory-settings-panel__manage-section"
],
"actions": [
{
"kind": "click",
"step_id": "open-memory-import",
"selector": ".memory-settings-panel__manage-section .wb-button",
"index": 0,
"frames": 2,
"interval_ms": 120
},
{
"kind": "wait",
"step_id": "memory-import-open-settled",
"duration_ms": 450,
"frames": 1
}
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".user-menu-popover",
".user-menu-items",
".settings-modal__panel",
".settings-navigation__item"
],
"actions": [
{ "kind": "click", "step_id": "open-account-menu", "selector": ".user-menu-trigger--workbuddy", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "account-menu-ready", "duration_ms": 500, "frames": 0 },
{ "kind": "click", "step_id": "open-settings-from-confirmed-menu-index-3", "selector": ".user-menu-items .user-menu-item", "index": 3, "frames": 0 },
{ "kind": "wait", "step_id": "settings-window-ready", "duration_ms": 800, "frames": 0 }
]
}
@@ -0,0 +1,48 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".settings-navigation__item--active",
".appearance-card--light",
".memory-settings-panel"
],
"actions": [
{
"kind": "click",
"step_id": "open-appearance-from-memory",
"selector": "[data-settings-tab=\"appearance\"]",
"frames": 1
},
{
"kind": "wait",
"step_id": "appearance-ready-from-dark",
"duration_ms": 250,
"frames": 0
},
{
"kind": "click",
"step_id": "restore-light-theme",
"selector": ".appearance-card--light",
"frames": 1
},
{
"kind": "wait",
"step_id": "light-theme-ready",
"duration_ms": 700,
"frames": 1
},
{
"kind": "click",
"step_id": "return-to-memory-in-light-theme",
"selector": "[data-settings-tab=\"memory\"]",
"frames": 1
},
{
"kind": "wait",
"step_id": "memory-light-stable",
"duration_ms": 700,
"frames": 2
}
]
}
@@ -0,0 +1,41 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".memory-settings-panel",
".memory-settings-panel__generate-memory-group",
".memory-settings-panel__manage-section"
],
"actions": [
{
"kind": "click",
"step_id": "conversation-memory-off",
"selector": ".memory-settings-panel .wb-switch",
"index": 0,
"frames": 2,
"interval_ms": 120,
"restore": true
},
{
"kind": "wait",
"step_id": "conversation-memory-off-settled",
"duration_ms": 350,
"frames": 1
},
{
"kind": "click",
"step_id": "local-memory-skills-off",
"selector": ".memory-settings-panel .wb-switch",
"index": 1,
"frames": 2,
"interval_ms": 120,
"restore": true
},
{
"kind": "wait",
"step_id": "local-memory-skills-off-settled",
"duration_ms": 350,
"frames": 1
}
]
}
@@ -0,0 +1,16 @@
{
"actions": [
{
"kind": "click",
"step_id": "open-discover-apps",
"selector": ".industry-template-switcher__trigger",
"frames": 0
},
{
"kind": "wait",
"step_id": "discover-apps-ready",
"duration_ms": 500,
"frames": 1
}
]
}
@@ -0,0 +1,20 @@
{
"track_selectors": [
".industry-template-switcher__trigger"
],
"actions": [
{
"kind": "key",
"step_id": "close-discover-apps",
"key": "Escape",
"modifiers": 0,
"frames": 0
},
{
"kind": "wait",
"step_id": "home-ready",
"duration_ms": 500,
"frames": 1
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"track_selectors": [
".conversation-list-tab-button-more"
],
"actions": [
{
"kind": "click",
"step_id": "open-more",
"selector": ".conversation-list-tab-button-more",
"frames": 0
},
{
"kind": "wait",
"step_id": "more-ready",
"duration_ms": 500,
"frames": 1
}
]
}
+176
View File
@@ -0,0 +1,176 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
const routeEvidencePath = resolve('.tmp/rea-todo20-scheduled-create.json')
const outputPath = resolve('src/renderer/src/automations/automation-source-assets.ts')
const checkOnly = process.argv.includes('--check')
const expected = {
automation: {
sha256: '687f7b062b0df0f353dafd1f17b00cb939685da5a12c8d3ce865b920b94d6710',
bytes: 131_537
},
src: {
sha256: '747e41d215375e1214c366213aea7e077d85421923029c61cbb6e9562be58de0',
bytes: 1_062_959
}
}
function fail(message) {
throw new Error(`automation UI recovery: ${message}`)
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex')
}
function assertArtifact(label, artifact, sourceText) {
const expectedArtifact = expected[label]
if (!artifact || !sourceText) fail(`${label} source artifact is missing`)
if (artifact.bytes !== expectedArtifact.bytes) {
fail(`${label} byte count changed: ${artifact.bytes} != ${expectedArtifact.bytes}`)
}
if (artifact.sha256 !== expectedArtifact.sha256 || sha256(sourceText) !== expectedArtifact.sha256) {
fail(`${label} SHA-256 does not match the pinned REA evidence`)
}
}
function findScript(evidence, suffix) {
return evidence.normalized_result?.scripts?.items?.find((item) =>
item.file_path?.endsWith(suffix)
)
}
function extractDataUri(source, variable) {
const escaped = variable.replaceAll('$', '\\$')
const match = new RegExp(`(?<![\\w$])${escaped}\\s*=\\s*\\x60(data:image/svg\\+xml,[^\\x60]+)\\x60`).exec(source)
if (!match) fail(`could not recover ${variable} SVG data URI`)
return match[1]
}
function parseJsxPath(attributesSource) {
const attributes = Object.fromEntries(
[...attributesSource.matchAll(/([A-Za-z][\w-]*)\s*:\s*`([^`]*)`/g)]
.map((match) => [match[1], match[2]])
)
if (!attributes.d) fail('a recovered path is missing its d attribute')
return attributes
}
function attributeName(name) {
return name
.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
}
function escapeAttribute(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function pathsToSvg(paths, width, height, viewBox) {
const markup = paths.map((attributes) => {
const serialized = Object.entries(attributes)
.filter(([name]) => name !== 'd')
.map(([name, value]) => `${attributeName(name)}="${escapeAttribute(value)}"`)
.join(' ')
return `<path ${serialized} d="${escapeAttribute(attributes.d)}"/>`
}).join('')
return `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${viewBox}" fill="black">${markup}</svg>`
)}`
}
function extractPaths(source, startMarker, endMarker, jsxFactory) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start + startMarker.length)
if (start < 0 || end < 0) fail(`could not locate ${startMarker}`)
const block = source.slice(start, end)
const pattern = new RegExp(`\\(0,${jsxFactory}\\.jsx\\)\\(\\x60path\\x60,\\s*\\{([^}]*)\\}\\)`, 'g')
const paths = [...block.matchAll(pattern)].map((match) => parseJsxPath(match[1]))
if (!paths.length) fail(`no SVG paths recovered from ${startMarker}`)
return paths
}
const evidence = JSON.parse(await readFile(routeEvidencePath, 'utf8'))
const srcItem = findScript(evidence, 'src-DPDj7_qD.js')
const automationItem = findScript(evidence, 'automation-qDy4JNlv.js')
assertArtifact('src', srcItem?.source?.artifact, srcItem?.source?.artifact?.text)
assertArtifact(
'automation',
automationItem?.source?.artifact,
automationItem?.source?.artifact?.text
)
const src = srcItem.source.artifact.text
const automation = automationItem.source.artifact.text
const templateIcons = {
news: extractDataUri(src, 'xb'),
languages: extractDataUri(src, 'Yy'),
moon: extractDataUri(src, '_b'),
'weekly-report': extractDataUri(src, 'Tb'),
film: extractDataUri(src, 'Ly'),
calendar: extractDataUri(src, 'Ny'),
lightbulb: extractDataUri(src, '$y'),
image: extractDataUri(src, 'Gy'),
hospital: extractDataUri(src, 'Vy'),
'messages-square': extractDataUri(src, 'sb'),
'list-todo': extractDataUri(src, 'rb')
}
const clockPaths = extractPaths(
src,
'Gc=(0,Uc.forwardRef)',
'Gc.displayName',
'Wc'
)
templateIcons['alarm-clock'] = pathsToSvg(clockPaths, 16, 16, '0 0 16 16')
const emptyIllustrationPaths = extractPaths(
automation,
'function TaskEmptyIllustration',
'var Ta,Ea',
'Ta'
)
const emptyIllustration = pathsToSvg(
emptyIllustrationPaths,
64,
64,
'0 0 64 64'
)
const output = `/**
* Generated by scripts/rea/recover-automation-ui.mjs.
*
* Source: fixed WorkBuddy 5.8.3 REA evidence
* ${evidence.evidence_id}
*
* Do not edit by hand. Re-run the recovery script to verify or refresh the assets.
*/
export const AUTOMATION_EVIDENCE = {
id: ${JSON.stringify(evidence.evidence_id)},
automationSha256: ${JSON.stringify(expected.automation.sha256)},
srcSha256: ${JSON.stringify(expected.src.sha256)}
} as const
export const AUTOMATION_TEMPLATE_ICON_URLS = ${JSON.stringify(templateIcons, null, 2)} as const
export type AutomationTemplateIconKey = keyof typeof AUTOMATION_TEMPLATE_ICON_URLS
export const AUTOMATION_EMPTY_ILLUSTRATION_URL = ${JSON.stringify(emptyIllustration)}
`
if (checkOnly) {
const current = await readFile(outputPath, 'utf8').catch(() => '')
if (current !== output) fail('generated asset file is stale')
console.log(`PASS automation UI assets ${expected.automation.sha256}`)
} else {
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, output, 'utf8')
console.log(`Wrote ${outputPath}`)
}
@@ -528,7 +528,7 @@ manifest.adaptations.push('Three conversation-shell rules are recovered alongsid
// --------------------------------------------------------------------------- types
const types=`/** Typed host contract for the recovered WorkBuddy 5.8.2 model selector. */
import type {ComponentType, ReactNode} from 'react'
export type ModelEffort='low'|'medium'|'high'|'xhigh'|'max'
export type ModelEffort='minimal'|'low'|'medium'|'high'|'xhigh'|'max'
export type ModelBusyLevel='idle'|'normal'|'busy'|'congested'|'unavailable'|'unknown'
export interface ModelBadgeSpec{label:string;color?:string;background?:string;icon?:string}
export interface ModelTierBadgeSpec{label:string;level?:'flagship'|'member';color?:string;background?:string}
@@ -644,7 +644,8 @@ manifest.hostBindings=[
{kind:'npm',name:'dompurify',names:['default']},
{kind:'npm',name:'react',names:['* as React','* as JSX (react/jsx-runtime)']},
]
const outputs=[[FOLDER+'source.mjs',moduleSource],[FOLDER+'source.d.mts',types],[FOLDER+'source.css',css]]
const hostSource=moduleSource.replace('var xW={low:', '// Host extension: models.dev exposes a minimal effort supported by the local kernel.\nvar xW={minimal:`极低`,low:')
const outputs=[[FOLDER+'source.mjs',hostSource],[FOLDER+'source.d.mts',types],[FOLDER+'source.css',css]]
manifest.outputs=outputs.map(([file,text])=>({file,sha256:sha(text),bytes:Buffer.byteLength(text)}))
outputs.push([FOLDER+'manifest.json',JSON.stringify(manifest,null,2)+'\n'])
const check=process.argv.includes('--check')
+414
View File
@@ -0,0 +1,414 @@
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = path.resolve(__dirname, '..', '..')
const tmpDir = path.join(repoRoot, '.tmp')
const evidence = {
home: {
path: path.join(tmpDir, 'rea-four-modules-library-stable-20261017.json'),
id: 'ev_82a9ad8ec38c807e465646d749e2cedbb150fec62e30234c97acd0bb309c349e'
},
local: {
path: path.join(tmpDir, 'rea-four-modules-library-local-20261017.json'),
id: 'ev_b7fe1b4adac2311b04dfa454126363ca382869d6a29664152d497a920c2e8cad'
}
}
const mediaEvidence = {
path: path.join(
tmpDir,
'rea-css-evidence',
'ev_d650991b41fe1831a4caf5dd225398e29db8e91205d00c2ac3047b00baa1998e.json'
),
id: 'ev_d650991b41fe1831a4caf5dd225398e29db8e91205d00c2ac3047b00baa1998e'
}
const illustrations = [
{
component: 'RobotGuideIllustration',
importName: 'agentGuideIllustration',
assetFile: 'wb-home-guide-card-agent-illustration.svg',
selector: '.wb-home-guide-card-agent-pc .wb-home-guide-card-illustration-pc',
sourceUrl:
'https://static.workbuddy.cn/web/library/5609e40689e0ddd5b7e8ede0a4e43acd7b1ba74d/js/federation/releases/e7fafd8eaa71ac3f/guide-card-agent-illustration.1abe0c58.svg',
bytes: 2857,
sha256: '1b0984754f470c4529b09d2fa56a957f23df3bd7fc16cc27d74989b1112ff815'
},
{
component: 'HtmlGuideIllustration',
importName: 'htmlGuideIllustration',
assetFile: 'wb-home-guide-card-html-illustration.svg',
selector: '.wb-home-guide-card-html-pc .wb-home-guide-card-illustration-pc',
sourceUrl:
'https://static.workbuddy.cn/web/library/5609e40689e0ddd5b7e8ede0a4e43acd7b1ba74d/js/federation/releases/e7fafd8eaa71ac3f/guide-card-html-illustration.0c0d6493.svg',
bytes: 1413,
sha256: '1af18b1901999725af4114997bba4c779c6d04f09712c44d34add010433d1e47'
}
]
const icons = [
{
name: 'ClockIcon',
evidence: 'home',
rootIndex: 872,
parentClass: 'memory-nav-item-icon-pc'
},
{
name: 'FolderLineIcon',
evidence: 'home',
rootIndex: 878,
parentClass: 'memory-nav-item-icon-pc'
},
{
name: 'MoreIcon',
evidence: 'home',
rootIndex: 1066,
parentClass: 'wb-feed-more-btn-pc'
},
{
name: 'InfoIcon',
evidence: 'home',
rootIndex: 1290,
parentClass: 'wb-home-guide-title-pc'
},
{
name: 'LightbulbIcon',
evidence: 'home',
rootIndex: 1323,
parentClass: 'wb-home-guide-playbook-title-pc'
},
{
name: 'BackIcon',
evidence: 'local',
rootIndex: 848,
parentClass: 'kb-back-to-library__btn'
},
{
name: 'SortArrowsIcon',
evidence: 'local',
rootIndex: 878,
parentClass: 'my-files-flat-col-name my-files-flat-sortable',
variant: 'sort'
},
{
name: 'ShareIcon',
evidence: 'local',
rootIndex: 954,
parentClass: 'my-files-list-action-btn'
},
{
name: 'TableMoreIcon',
evidence: 'local',
rootIndex: 959,
parentClass: 'my-files-list-action-btn'
},
{
name: 'TaskIcon',
evidence: 'local',
rootIndex: 967,
parentClass: 'my-files-flat-col-name'
}
]
const notes = {
star_icon: {
status: 'unresolved',
reason:
'No star/favorite SVG exists in the captured library UI. WorkBuddy uses a CSS checkbox for the favorite filter and renders share plus more actions in file rows.',
evidence_ids: Object.values(evidence).map((item) => item.id),
searched_classes: [
'my-files-favorite-checkbox',
'my-files-favorite-checkbox__label',
'my-files-list-action-btn'
]
},
rejected_reusable_home_media: {
status: 'not_applicable',
reason:
'A previously archived image body exists, but it belongs to the older .wb-home-route__growth-buddy img selector and is not evidence for either current guide-card illustration.',
evidence_id: 'ev_474129797ce3ab73eaaab2cd41247653e7f128c3b89b598158b57b74e1f17e88',
selector: '.wb-home-route__growth-buddy img',
source_url:
'https://staging-download.codebuddy.cn/operation-platform/assets/_general/ffca4ecbc52c1e3e.png',
artifact_sha256: '2713ed5bbf1288d56ebeaca49bfb0d622c967914ab9c98955cc995932049e7e8',
bytes: 13426
}
}
const svgAttributeNames = new Map([
['class', 'className'],
['clip-path', 'clipPath'],
['clip-rule', 'clipRule'],
['fill-rule', 'fillRule'],
['stroke-dasharray', 'strokeDasharray'],
['stroke-dashoffset', 'strokeDashoffset'],
['stroke-linecap', 'strokeLinecap'],
['stroke-linejoin', 'strokeLinejoin'],
['stroke-miterlimit', 'strokeMiterlimit'],
['stroke-width', 'strokeWidth'],
['transform-origin', 'transformOrigin']
])
const svgTagNames = new Map([
['clippath', 'clipPath'],
['lineargradient', 'linearGradient'],
['radialgradient', 'radialGradient']
])
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
function readEvidence(spec) {
const value = JSON.parse(fs.readFileSync(spec.path, 'utf8').replace(/^\uFEFF/, ''))
assert.equal(value.evidence_id, spec.id)
assert.equal(value.operation, 'inspect_electron_page')
return value
}
function jsxAttributeName(name) {
return svgAttributeNames.get(name) ?? name
}
function jsxAttributes(attributes) {
return Object.entries(attributes)
.map(([name, value]) => `${jsxAttributeName(name)}=${JSON.stringify(value)}`)
.join(' ')
}
function recoverIcon(document, spec) {
const layoutNodes = new Map(document.nodes.map((node) => [node.node_index, node]))
const svgByIndex = new Map(document.svg_nodes.map((node) => [node.node_index, node]))
const childrenByParent = new Map()
for (const node of document.svg_nodes) {
const children = childrenByParent.get(node.parent_index) ?? []
children.push(node)
childrenByParent.set(node.parent_index, children)
}
const root = svgByIndex.get(spec.rootIndex)
assert.ok(root, `missing SVG root ${spec.rootIndex} for ${spec.name}`)
assert.equal(root.node_name, 'svg')
const parent = layoutNodes.get(root.parent_index)
assert.ok(parent, `missing SVG parent for ${spec.name}`)
assert.equal(parent.attributes?.class, spec.parentClass)
const subtree = []
const visit = (node) => {
subtree.push({
node_index: node.node_index,
parent_index: node.parent_index,
node_name: node.node_name,
attributes: node.attributes
})
for (const child of childrenByParent.get(node.node_index) ?? []) visit(child)
}
visit(root)
return {
root,
parent,
childrenByParent,
hash: sha256(JSON.stringify(subtree))
}
}
function renderNode(recovery, node, isRoot = false, spec = null) {
const tag = svgTagNames.get(node.node_name.toLowerCase()) ?? node.node_name
const attributes = { ...node.attributes }
let prefix = ''
if (isRoot) {
delete attributes.width
delete attributes.height
if (spec?.variant === 'sort') {
delete attributes.class
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} className={mergeClassNames("my-files-flat-sort-icon", direction, className)} `
} else if ('class' in attributes) {
const sourceClass = attributes.class
delete attributes.class
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} className={mergeClassNames(${JSON.stringify(sourceClass)}, className)} `
} else {
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} `
}
}
const serialized = jsxAttributes(attributes)
const opener = `<${tag} ${prefix}${serialized}`
const children = recovery.childrenByParent.get(node.node_index) ?? []
if (!children.length) return `${opener} />`
return `${opener}>${children.map((child) => renderNode(recovery, child)).join('')}</${tag}>`
}
function functionSource(name, recovery, evidenceId, version, spec) {
const provenance = [
`WorkBuddy ${version}`,
`REA ${evidenceId}`,
`parent .${spec.parentClass.replaceAll(' ', '.')}`,
`root ${spec.rootIndex}`,
`subtree sha256 ${recovery.hash}`
].join('; ')
if (spec.variant === 'sort') {
return `// Exact SVG recovery: ${provenance}\nexport function ${name}({ direction, className, width, height, ...svgProps }: SortIconProps): React.JSX.Element {\n return ${renderNode(recovery, recovery.root, true, spec)}\n}`
}
return `// Exact SVG recovery: ${provenance}\nexport function ${name}(props: IconProps): React.JSX.Element {\n const { className, width, height, ...svgProps } = props\n return ${renderNode(recovery, recovery.root, true, spec)}\n}`
}
function mergeClassNames(...values) {
return values.filter(Boolean).join(' ') || undefined
}
function readMediaEvidence(spec) {
const value = JSON.parse(fs.readFileSync(spec.path, 'utf8').replace(/^\uFEFF/, ''))
assert.equal(value.evidence_id, spec.id)
const items = value.normalized_result?.visuals?.media_assets?.items
assert.ok(Array.isArray(items))
return items
}
function recoverIllustration(item, spec) {
assert.equal(item.selector, spec.selector)
assert.equal(item.status, 'exported')
assert.equal(item.source?.url, spec.sourceUrl)
assert.equal(item.source?.mime_type, 'image/svg+xml')
assert.equal(item.artifact?.mime_type, 'image/svg+xml')
assert.equal(item.artifact?.bytes, spec.bytes)
assert.equal(item.artifact?.sha256, spec.sha256)
const bytes = Buffer.from(item.artifact.data_base64, 'base64')
assert.equal(bytes.length, spec.bytes)
assert.equal(sha256(bytes), spec.sha256)
const svg = bytes.toString('utf8')
assert.match(svg.trimStart(), /^<svg\b/)
return {
bytes,
record: {
component: spec.component,
asset_file: `src/renderer/src/library/assets/${spec.assetFile}`,
evidence_id: mediaEvidence.id,
evidence_file: path.relative(repoRoot, mediaEvidence.path).replaceAll('\\', '/'),
selector: item.selector,
node_index: item.node_index,
node_class: item.selected_node?.attributes?.class,
source_url: item.source.url,
source_url_sha256: item.source.url_sha256,
status: item.status,
visibility: item.visibility,
mime_type: item.artifact.mime_type,
bytes: item.artifact.bytes,
sha256: item.artifact.sha256
}
}
}
function illustrationSource(records) {
const imports = records
.map(({ spec }) => `import ${spec.importName} from './assets/${spec.assetFile}'`)
.join('\n')
const components = records.map(({ record, spec }) => {
const provenance = [
`REA ${mediaEvidence.id}`,
`selector ${spec.selector}`,
`source ${spec.sourceUrl}`,
`${spec.bytes} bytes`,
`sha256 ${spec.sha256}`
].join('; ')
return `// Exact WorkBuddy asset recovery: ${provenance}\nexport function ${spec.component}(): React.JSX.Element {\n return (\n <img\n className="wb-home-guide-card-illustration-pc library-guide__illustration"\n src={${spec.importName}}\n alt=""\n />\n )\n}`
}).join('\n\n')
return `// Generated by scripts/rea/recover-library-icons.cjs. Do not hand edit.\n${imports}\n\n${components}\n`
}
function main() {
const loaded = Object.fromEntries(
Object.entries(evidence).map(([name, spec]) => [name, readEvidence(spec)])
)
const iconRecords = []
const iconSources = []
for (const spec of icons) {
const value = loaded[spec.evidence]
const document = value.normalized_result.visuals.layout.documents[0]
const recovery = recoverIcon(document, spec)
const version = value.normalized_result.browser.user_agent.match(/WorkBuddy\/([^\s]+)/)?.[1] ?? 'unknown'
iconSources.push(functionSource(spec.name, recovery, value.evidence_id, version, spec))
iconRecords.push({
name: spec.name,
evidence_id: value.evidence_id,
evidence_file: path.relative(repoRoot, evidence[spec.evidence].path).replaceAll('\\', '/'),
root_node_index: spec.rootIndex,
parent_class: recovery.parent.attributes.class,
source_sha256: recovery.hash,
svg_attributes: recovery.root.attributes
})
}
const iconOutput = `// Generated by scripts/rea/recover-library-icons.cjs. Do not hand edit.\nimport type { SVGProps } from 'react'\n\ntype IconProps = SVGProps<SVGSVGElement>\ntype SortIconProps = IconProps & { direction?: 'asc' | 'desc' }\n\nfunction mergeClassNames(...values: Array<string | undefined>): string | undefined {\n return values.filter(Boolean).join(' ') || undefined\n}\n\n${iconSources.join('\n\n')}\n`
const iconDestination = path.join(repoRoot, 'src', 'renderer', 'src', 'library', 'SourceLibraryIcons.tsx')
const mediaItems = readMediaEvidence(mediaEvidence)
const illustrationRecoveries = illustrations.map((spec) => {
const item = mediaItems.find((candidate) => candidate.selector === spec.selector)
assert.ok(item, `missing media item ${spec.selector}`)
return { spec, ...recoverIllustration(item, spec) }
})
const illustrationOutput = illustrationSource(illustrationRecoveries)
const illustrationDestination = path.join(
repoRoot,
'src',
'renderer',
'src',
'library',
'SourceLibraryIllustrations.tsx'
)
const assetDirectory = path.join(repoRoot, 'src', 'renderer', 'src', 'library', 'assets')
const manifestPath = path.join(tmpDir, 'library-icon-recovery-manifest.json')
const manifest = {
icon_source_file: path.relative(repoRoot, iconDestination).replaceAll('\\', '/'),
icon_file_sha256: sha256(iconOutput),
illustration_source_file: path.relative(repoRoot, illustrationDestination).replaceAll('\\', '/'),
illustration_file_sha256: sha256(illustrationOutput),
icons: iconRecords,
illustrations: illustrationRecoveries.map(({ record }) => record),
notes
}
if (process.argv.includes('--check')) {
assert.equal(fs.readFileSync(iconDestination, 'utf8'), iconOutput)
assert.equal(fs.readFileSync(illustrationDestination, 'utf8'), illustrationOutput)
for (const { bytes, record } of illustrationRecoveries) {
assert.deepEqual(fs.readFileSync(path.join(repoRoot, record.asset_file)), bytes)
}
assert.deepEqual(JSON.parse(fs.readFileSync(manifestPath, 'utf8')), manifest)
} else {
fs.mkdirSync(assetDirectory, { recursive: true })
fs.writeFileSync(iconDestination, iconOutput)
fs.writeFileSync(illustrationDestination, illustrationOutput)
for (const { bytes, spec } of illustrationRecoveries) {
fs.writeFileSync(path.join(assetDirectory, spec.assetFile), bytes)
}
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
}
process.stdout.write(`${JSON.stringify({
status: 'PASS',
recovered_icons: iconRecords.length,
recovered_illustrations: illustrationRecoveries.length,
unresolved: Object.entries(notes)
.filter(([, note]) => note.status === 'unresolved')
.map(([name]) => name),
icon_file: iconDestination,
icon_file_sha256: manifest.icon_file_sha256,
illustration_file: illustrationDestination,
illustration_file_sha256: manifest.illustration_file_sha256,
manifest: manifestPath
}, null, 2)}\n`)
}
main()
+205
View File
@@ -0,0 +1,205 @@
import { promises as fs } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '..', '..')
const tmpDir = join(repoRoot, '.tmp')
const evidenceFiles = {
home: join(tmpDir, 'rea-four-modules-library-stable-20261017.json'),
local: join(tmpDir, 'rea-four-modules-library-local-20261017.json')
}
const prefixes = [
'knowledge-sidebar',
'main-layout-content',
'workspace-tree',
'wb-home',
'doc-feed',
'wb-feed',
'my-files'
]
const styleKeys = [
'display',
'position',
'font-family',
'font-size',
'font-weight',
'line-height',
'color',
'background-color',
'border-radius',
'border-top-width',
'border-top-color',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'gap',
'align-items',
'justify-content',
'box-shadow',
'overflow',
'max-width',
'min-height',
'transition-property',
'transition-duration',
'transition-timing-function',
'opacity',
'transform'
]
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, '')
}
function cssRules(source) {
const css = stripComments(source)
const stack = []
const rules = []
let boundary = 0
let quote = ''
let escaped = false
for (let index = 0; index < css.length; index += 1) {
const char = css[index]
if (quote) {
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === quote) quote = ''
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (char === '{') {
const prelude = css.slice(boundary, index).trim()
stack.push({ prelude, bodyStart: index + 1 })
boundary = index + 1
continue
}
if (char === '}') {
const entry = stack.pop()
if (entry) {
const body = css.slice(entry.bodyStart, index).trim()
if (body && !entry.prelude.startsWith('@')) {
rules.push({
context: stack.map((item) => item.prelude).filter((item) => item.startsWith('@')),
selector: entry.prelude,
body
})
}
}
boundary = index + 1
continue
}
if (char === ';' && stack.length === 0) boundary = index + 1
}
return rules
}
function matchesPrefix(selector) {
return prefixes.some((prefix) => selector.includes(prefix))
}
function relevantNode(node) {
const bounds = node.bounds ?? []
if (bounds.length < 4 || bounds[2] <= 0 || bounds[3] <= 0) return false
if (!node.attributes || !Object.keys(node.attributes).length) return false
return true
}
function layoutNode(node) {
const styles = node.styles ?? {}
return {
nodeIndex: node.node_index,
parentIndex: node.parent_index,
tag: node.node_name,
attributes: node.attributes,
bounds: node.bounds,
styles: Object.fromEntries(styleKeys.map((key) => [key, styles[key]]))
}
}
async function readEvidence(path) {
return JSON.parse(await fs.readFile(path, 'utf8'))
}
async function main() {
const evidence = {}
for (const [name, path] of Object.entries(evidenceFiles)) {
evidence[name] = await readEvidence(path)
}
const seenRules = new Set()
const recovered = []
const styleSources = new Map()
for (const [name, entry] of Object.entries(evidence)) {
for (const sheet of entry.normalized_result.styles.items ?? []) {
const text = sheet.source?.artifact?.text
if (!text || !prefixes.some((prefix) => text.includes(prefix))) continue
const sheetName = basename(sheet.file_path ?? 'inline.css')
styleSources.set(sheetName, {
path: sheet.file_path,
bytes: sheet.source.artifact.bytes,
sha256: sheet.source.artifact.sha256
})
for (const rule of cssRules(text)) {
if (!matchesPrefix(rule.selector)) continue
const key = `${rule.context.join('>')}|${rule.selector}|${rule.body}`
if (seenRules.has(key)) continue
seenRules.add(key)
recovered.push({
evidence: name,
sheet: sheetName,
...rule
})
}
}
}
const css = recovered
.map((rule) => {
const prefix = rule.context.length ? `${rule.context.join(' { ')} { ` : ''
const suffix = rule.context.length ? ` ${'}'.repeat(rule.context.length)}` : ''
return `/* ${rule.evidence}:${rule.sheet} */\n${prefix}${rule.selector} { ${rule.body} }${suffix}`
})
.join('\n\n')
const layout = {}
for (const [name, entry] of Object.entries(evidence)) {
layout[name] = (entry.normalized_result.visuals.layout.documents ?? [])
.flatMap((document) => document.nodes ?? [])
.filter(relevantNode)
.map(layoutNode)
}
await fs.mkdir(tmpDir, { recursive: true })
await Promise.all([
fs.writeFile(join(tmpDir, 'library-recovered-rules.css'), `${css}\n`, 'utf8'),
fs.writeFile(join(tmpDir, 'library-layout-summary.json'), JSON.stringify({
styleSources: Object.fromEntries(styleSources),
layout
}, null, 2), 'utf8')
])
process.stdout.write(JSON.stringify({
rules: recovered.length,
css: join(tmpDir, 'library-recovered-rules.css'),
layout: join(tmpDir, 'library-layout-summary.json'),
styleSources
}, null, 2))
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+64
View File
@@ -0,0 +1,64 @@
// Project the WorkBuddy 5.8.3 MemorySettingsPanel CSS recovered through REA.
// This script only reads an archived REA Evidence record; it never contacts the app.
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
const evidenceId = 'ev_ccbabc0d8507e997eba15aab8b5effa66f7e82d6720c6131bfcd62157b9d6896'
const evidencePath = path.resolve(
process.env.YCODE_MEMORY_EVIDENCE ??
`.tmp/rea-css-evidence/${evidenceId}.json`
)
const outputPath = path.resolve(
'src/renderer/src/components/source-memory-settings-recovered.css'
)
const expectedSourceSha256 = '43e0b0207ff1a91ad6066efc772dad351b2b5e7afe588d38a5ee38537eb01aad'
const expectedSourceBytes = 25213
const evidence = JSON.parse(await fs.readFile(evidencePath, 'utf8'))
assert.equal(evidence.evidence_id, evidenceId, 'REA Evidence identity changed')
assert.equal(evidence.operation, 'inspect_electron_page')
assert.equal(evidence.normalized_result?.styles?.total, 52, 'unexpected WorkBuddy CSS source count')
assert.equal(evidence.normalized_result?.styles?.truncated, false, 'CSS capture was truncated')
assert.equal(evidence.normalized_result?.styles?.source_failures ?? 0, 0, 'CSS source failure')
const matches = evidence.normalized_result.styles.items.filter(item =>
/MemorySettingsPanel-[^/\\]+\.css$/i.test(item.file_path ?? '')
)
assert.equal(matches.length, 1, 'expected exactly one MemorySettingsPanel CSS source')
const source = matches[0].source?.artifact
assert.equal(source?.sha256, expectedSourceSha256, 'MemorySettingsPanel CSS digest changed')
assert.equal(source?.bytes, expectedSourceBytes, 'MemorySettingsPanel CSS byte length changed')
assert.equal(typeof source?.text, 'string', 'MemorySettingsPanel CSS text is unavailable')
const header = [
'/*',
` * REA Evidence ${evidenceId}`,
` * Source ${path.basename(matches[0].file_path)}`,
` * SHA-256 ${expectedSourceSha256}`,
' * The declarations below are the unmodified recovered module source.',
' */',
''
].join('\n')
const output = `${header}${source.text.trim()}\n`
const outputSha256 = createHash('sha256').update(output).digest('hex')
if (process.argv.includes('--check')) {
const current = await fs.readFile(outputPath, 'utf8')
assert.equal(current, output, 'recovered MemorySettingsPanel CSS is stale')
} else {
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, output)
}
console.log(JSON.stringify({
evidenceId,
sourceFile: path.basename(matches[0].file_path),
sourceSha256: expectedSourceSha256,
sourceBytes: expectedSourceBytes,
output: path.relative(process.cwd(), outputPath).replaceAll('\\', '/'),
outputBytes: Buffer.byteLength(output),
outputSha256
}, null, 2))
@@ -0,0 +1,56 @@
// Read retained REA exports only. Never accesses the installed WorkBuddy or CDP.
const fs = require('node:fs')
const crypto = require('node:crypto')
const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
const postcss = require('postcss')
const assert = require('node:assert/strict')
const sha = text => crypto.createHash('sha256').update(text).digest('hex')
const archive = JSON.parse(fs.readFileSync('.tmp/rea-runtime-scripts.json', 'utf8'))
const source = archive.items.find(item => item.file_path.endsWith('src-DbE1_mgM.js')).source.artifact
assert.equal(sha(source.text), '6c6ce63c93854783270e855572797d75e536e3b8d4548410e31fe9d147e1feb4')
const css = fs.readFileSync('.tmp/rea-css-recovered/src-CyXHA0IC.css', 'utf8')
assert.equal(sha(css), 'a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe')
const primitiveCss = fs.readFileSync('.tmp/rea-css-recovered/src-Bh3YZ35H.css', 'utf8')
assert.equal(sha(primitiveCss), '88dbdc1cd3f59d0e8b75824512a465a2be714bc7af481d790fe3a9484f85e01a')
const ast = parse(source.text, { sourceType: 'module' })
const wanted = new Set(['ShieldSecuredIcon', 'ShieldDangerIcon', 'ShieldErrorIcon'])
const bodies = new Map()
traverse(ast, {
FunctionDeclaration(p) { if (wanted.has(p.node.id?.name)) bodies.set(p.node.id.name, p.node) },
AssignmentExpression(p) { if (wanted.has(p.node.left.name)) bodies.set(p.node.left.name, p.node.right) },
VariableDeclarator(p) { if (wanted.has(p.node.id.name) && p.node.init) bodies.set(p.node.id.name, p.node.init) }
})
assert.ok(bodies.has('ShieldSecuredIcon') && bodies.has('ShieldDangerIcon'))
let icons = '// Generated from fixed REA ' + archive.evidence_id + '.\n// @ts-nocheck -- the original JSX runtime bodies are retained.\nimport { jsx, jsxs } from "react/jsx-runtime"\n'
for (const [name, node] of bodies) {
let body = source.text.slice(node.start, node.end).replace(/\(0,[\w$]+\.(jsx|jsxs)\)/g, '$1')
assert.ok(!/\(0,[\w$]+\.(jsx|jsxs)\)/.test(body), `${name}: unresolved JSX runtime alias`)
icons += node.type === 'FunctionDeclaration' ? `export ${body}\n` : `export const ${name} = ${body}\n`
}
const rules = []
postcss.parse(css).walkRules(rule => {
if (/\.cr-permission-(panel|setting-popover|confirm)/.test(rule.selector)) rules.push(rule.toString())
})
postcss.parse(css).walkAtRules('keyframes', rule => {
if (rule.params === 'permissionPopIn') rules.push(rule.toString())
})
assert.ok(rules.length >= 20)
// The existing workspace adapter only recovered the button variants it used.
// Bring in the confirmation's actual danger/secondary states, including disabled.
postcss.parse(primitiveCss).walkRules(rule => {
if (!/\.wb-button(?:\b|--|__)/.test(rule.selector)) return
const copy = rule.clone()
copy.selectors = copy.selectors.map(selector => `.yc-source-permission ${selector}`)
rules.push(copy.toString())
})
const outputs = [
['src/renderer/src/workbuddy/composer/permission/SourcePermissionIcons.tsx', icons],
['src/renderer/src/workbuddy/composer/permission/source-permission-recovered.css',
`/* REA ${archive.evidence_id}; CSS ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94.\n * Source SHA-256 ${sha(css)}. Exact permission panel/confirmation declarations. */\n${rules.join('\n')}\n`]
]
for (const [filename, text] of outputs) {
if (process.argv.includes('--check')) assert.equal(fs.readFileSync(filename, 'utf8'), text, filename)
else { fs.mkdirSync(require('node:path').dirname(filename), { recursive: true }); fs.writeFileSync(filename, text) }
}
console.log(`PERMISSION_SOURCE_PASS ${bodies.size} icons, ${rules.length} rules`)
+52
View File
@@ -0,0 +1,52 @@
const fs = require('node:fs')
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const postcss = require('postcss')
const evidence = JSON.parse(fs.readFileSync('.tmp/rea-todo20-new-project-dialog.json', 'utf8').replace(/^\uFEFF/, ''))
assert.equal(evidence.evidence_id, 'ev_bc0f5546bb714cad4634168969a115be8f34e307bc09f51caf1457f5d3ed61ec')
const output = postcss.root()
function sheet(name) {
const item = evidence.normalized_result.styles.items.find(item => item.file_path.endsWith(name))
assert.ok(item?.source.included)
const text = item.source.artifact.text
assert.equal(crypto.createHash('sha256').update(text).digest('hex'), item.source.artifact.sha256)
return postcss.parse(text)
}
sheet('tokens-Df-i-zsO.css').walkRules(rule => {
if (!rule.selector.includes(':root') && !rule.selector.includes('[data-theme=dark]')) return
if (!rule.nodes.some(node => node.type === 'decl' && node.prop.startsWith('--'))) return
const dark = rule.selector.includes('dark')
const target = postcss.rule({ selector: dark ? '[data-theme=dark] .yc-project-surface' : '.yc-project-surface' })
for (const node of rule.nodes) if (node.type === 'decl' && node.prop.startsWith('--')) target.append(node.clone())
output.append(target)
})
assert.ok(output.nodes.length >= 2)
function select(container) {
const result = container.clone({ nodes: [] })
for (const node of container.nodes || []) {
if (node.type === 'rule' && /\.wb-(?:button|input|textarea)(?:[\s.:#[\]>+~,-]|__|--|$)/.test(node.selector)) {
const copy = node.clone()
copy.selectors = copy.selectors.map(selector => `:where(.yc-project-surface) ${selector}`)
result.append(copy)
} else if (node.type === 'atrule' && node.nodes && !/keyframes$/.test(node.name)) {
const nested = select(node)
if (nested.nodes.length) result.append(nested)
}
}
return result
}
const controls = sheet('src-Bh3YZ35H.css')
controls.walkRules(rule => {
if (!rule.selector.includes(':root') && !rule.selector.includes('[data-theme=dark]')) return
const declarations = rule.nodes.filter(node => node.type === 'decl' && node.prop.startsWith('--'))
if (!declarations.length) return
const target = postcss.rule({ selector: rule.selector.includes('dark') ? '[data-theme=dark] .yc-project-surface' : '.yc-project-surface' })
declarations.forEach(node => target.append(node.clone()))
output.append(target)
})
output.append(select(controls).nodes)
const text = `/* WorkBuddy 5.8.3 shared controls/tokens; REA ${evidence.evidence_id}. */\n${output.toString()}\n`
const file = 'src/renderer/src/projects/source-project-controls.css'
if (process.argv.includes('--check')) assert.equal(fs.readFileSync(file, 'utf8'), text)
else fs.writeFileSync(file, text)
console.log(`PASS project controls/tokens: ${output.nodes.length} top-level blocks, hashes verified`)
@@ -0,0 +1,20 @@
// Recover the observed project-detail selectors from REA retained evidence.
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),postcss=require('postcss'),assert=require('node:assert/strict')
const evidence=JSON.parse(fs.readFileSync('.tmp/rea-todo20-project-tasks-full.json','utf8').replace(/^\uFEFF/,''))
assert.equal(evidence.evidence_id,'ev_8782905c492acd1618e137480a3da8a8d7eba56e8f328e4856b008e1dd7f34ea')
const source=evidence.normalized_result.styles.items.find(item=>item.file_path.endsWith('project-detail-page-aED_k-Ym.css'))
assert.ok(source?.source.included)
const css=source.source.artifact.text
const sha=crypto.createHash('sha256').update(css).digest('hex')
assert.equal(sha,source.source.artifact.sha256)
const root=postcss.parse(css),out=postcss.root();let count=0
function select(container){const result=container.clone({nodes:[]});for(const node of container.nodes||[]){
if(node.type==='rule'&&/(?:project-detail-view|project-task-panel|project-instruction-drawer|context-panel|wb-config-card|task-item|wb-list-item|wb-status-dot|wb-avatar|collaborator-avatar-stack)/.test(node.selector)){
const rule=node.clone();rule.selectors=rule.selectors.map(selector=>`:where(.yc-project-surface) ${selector}`);result.append(rule);count++
}else if(node.type==='atrule'&&node.nodes&&!/keyframes$/i.test(node.name)){const nested=select(node);if(nested.nodes.length)result.append(nested)}
}return result}
out.append(select(root).nodes)
const text=`/* WorkBuddy 5.8.3 project detail; REA ${evidence.evidence_id}; source SHA-256 ${sha}. */\n${out.toString()}\n`
const destination=path.resolve('src/renderer/src/projects/source-project-detail-recovered.css')
if(process.argv.includes('--check'))assert.equal(fs.readFileSync(destination,'utf8'),text);else fs.writeFileSync(destination,text)
console.log(`PASS project-detail CSS: ${count} scoped rules; SHA verified`)
+22
View File
@@ -0,0 +1,22 @@
// Export only the image already retained by REA. No installation-directory access.
const fs = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const evidence = JSON.parse(fs.readFileSync('.tmp/rea-todo20-project-hero.json', 'utf8').replace(/^\uFEFF/, ''))
assert.equal(evidence.evidence_id, 'ev_d740e73903dd69444105e2509b238e5912f2c93d3adaa183b05ec7221512ef7a')
const item = evidence.normalized_result.visuals.media_assets.items.find(item => item.selector === '.landing-hero')
assert.ok(item?.artifact)
assert.equal(item.artifact.mime_type, 'image/png')
assert.ok(item.source.url.endsWith('/landing-hero-B6659kdy.png'))
const bytes = Buffer.from(item.artifact.data_base64, 'base64')
assert.equal(bytes.length, item.artifact.bytes)
assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), item.artifact.sha256)
assert.equal(bytes.subarray(0, 8).toString('hex'), '89504e470d0a1a0a')
const destination = path.resolve('src/renderer/src/projects/assets/landing-hero.png')
if (process.argv.includes('--check')) assert.deepEqual(fs.readFileSync(destination), bytes)
else {
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.writeFileSync(destination, bytes)
}
console.log(`PASS project hero: ${bytes.length} bytes; SHA-256 ${item.artifact.sha256}`)
+24
View File
@@ -0,0 +1,24 @@
const fs = require('node:fs')
const assert = require('node:assert/strict')
const evidence = JSON.parse(fs.readFileSync('.tmp/rea-todo20-project-page.json', 'utf8').replace(/^\uFEFF/, ''))
assert.equal(evidence.evidence_id, 'ev_89843a8b67f411d3297b5f3e428830393cd0fe8712603816cf8b7bbe442064d9')
const document = evidence.normalized_result.visuals.layout.documents[0]
const parent = document.nodes.find(node => node.attributes.class === 'landing-template-icon')
const svg = document.svg_nodes.find(node => node.parent_index === parent.node_index)
assert.equal(svg.node_name, 'svg')
const names = { 'clip-path': 'clipPath', 'fill-rule': 'fillRule', 'class': 'className' }
function jsx(node) {
assert.ok(['svg', 'clipPath', 'path', 'g'].includes(node.node_name))
const attrs = Object.entries(node.attributes).map(([key, value]) => {
if (key === 'id') return 'id={clipId}'
if (key === 'clip-path') return 'clipPath={`url(#${clipId})`}'
return `${names[key] || key}=${JSON.stringify(value)}`
}).join(' ')
const children = document.svg_nodes.filter(child => child.parent_index === node.node_index).map(jsx).join('')
return `<${node.node_name} ${attrs}>${children}</${node.node_name}>`
}
const output = `// WorkBuddy 5.8.3, REA ${evidence.evidence_id}; literal SVG recovery.\nimport { useId } from 'react'\nexport default function ProjectIcon(): React.JSX.Element {\nconst clipId = useId()\nreturn ${jsx(svg)}\n}\n`
const destination = 'src/renderer/src/projects/ProjectIcon.tsx'
if (process.argv.includes('--check')) assert.equal(fs.readFileSync(destination, 'utf8'), output)
else fs.writeFileSync(destination, output)
console.log('PASS project SVG recovery, per-instance clip ID')
+60
View File
@@ -0,0 +1,60 @@
// Consume retained REA evidence only; never reads the WorkBuddy installation.
const fs = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto')
const postcss = require('postcss')
const assert = require('node:assert/strict')
const evidence = JSON.parse(fs.readFileSync(path.resolve('.tmp/rea-todo20-new-project-dialog.json'), 'utf8').replace(/^\uFEFF/, ''))
assert.equal(evidence.evidence_id, 'ev_bc0f5546bb714cad4634168969a115be8f34e307bc09f51caf1457f5d3ed61ec')
const sheet = evidence.normalized_result.styles.items.find(item => item.file_path.endsWith('collab-YMJYRnW_.css'))
assert.ok(sheet?.source.included)
const source = sheet.source.artifact.text
const sha = crypto.createHash('sha256').update(source).digest('hex')
assert.equal(sha, '4737f6a8f8f993de933e5ebae0d94b96cb39788e160470490f607508537e5377')
const root = postcss.parse(source)
// The shared modal lives outside collab's chunk; recover its rules from the same evidence.
let modalRules = 0
for (const item of evidence.normalized_result.styles.items) {
if (item === sheet || !item.source?.included || !item.source.artifact?.text) continue
const text = item.source.artifact.text
if (!text.includes('.collab-modal__') && !text.includes('.workbuddy-topbar')) continue
assert.equal(crypto.createHash('sha256').update(text).digest('hex'), item.source.artifact.sha256)
const parsed = postcss.parse(text)
function select(container) {
const result = container.clone({ nodes: [] })
for (const node of container.nodes || []) {
if (node.type === 'rule' && /\.collab-modal__|\.workbuddy-topbar(?:[\s.:#[\]>+~,-]|-|$)/.test(node.selector)) {
result.append(node.clone()); modalRules++
} else if (node.type === 'atrule' && node.nodes && !/keyframes$/i.test(node.name)) {
const nested = select(node)
if (nested.nodes.length) result.append(nested)
}
}
return result
}
root.append(select(parsed).nodes)
}
let count = 0
root.walkRules(rule => {
// Preserve keyframe selectors and all enclosing media/supports conditions.
for (let parent = rule.parent; parent; parent = parent.parent) {
if (parent.type === 'atrule' && /keyframes$/i.test(parent.name)) return
}
rule.selectors = rule.selectors.map(selector => {
// Body/theme conditions are ancestors of the surface, never its descendants.
const ancestor = selector.match(/^(body(?:\[[^\]]+\]|\.[\w-]+|:[\w-]+(?:\([^)]*\))?)*|html(?:\[[^\]]+\]|\.[\w-]+)*|:root)\s+/)
return ancestor
? `${ancestor[1]} :where(.yc-project-surface) ${selector.slice(ancestor[0].length)}`
: `:where(.yc-project-surface) ${selector}`
})
count++
})
const output = `/* Generated from WorkBuddy 5.8.3; REA ${evidence.evidence_id}; source SHA-256 ${sha}. */\n${root.toString()}\n`
const destination = path.resolve('src/renderer/src/projects/source-projects-recovered.css')
if (process.argv.includes('--check')) {
assert.equal(fs.readFileSync(destination, 'utf8'), output)
} else {
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.writeFileSync(destination, output)
}
console.log(`PASS project styles: ${count} scoped rules (${modalRules} shared modal); source SHA verified${process.argv.includes('--check') ? '; generated file unchanged' : ''}`)
+66
View File
@@ -0,0 +1,66 @@
// Static AST decoding of retained REA sources; no recovered code is executed.
const fs = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const parser = require('@babel/parser')
const ts = {
isStringLiteral: n => n?.type === 'StringLiteral',
isNoSubstitutionTemplateLiteral: n => n?.type === 'TemplateLiteral' && n.expressions.length === 0,
isArrayLiteralExpression: n => n?.type === 'ArrayExpression',
isObjectLiteralExpression: n => n?.type === 'ObjectExpression',
isPropertyAssignment: n => n?.type === 'ObjectProperty',
isBinaryExpression: n => n?.type === 'AssignmentExpression',
isIdentifier: n => n?.type === 'Identifier',
forEachChild: (node, callback) => { for (const value of Object.values(node)) { if (Array.isArray(value)) value.forEach(item => { if (item?.type) callback(item) }); else if (value?.type) callback(value) } }
}
const literal = node => node.type === 'TemplateLiteral' ? node.quasis[0].value.cooked : node.value
const evidence = JSON.parse(fs.readFileSync('.tmp/rea-todo20-project-page.json', 'utf8').replace(/^\uFEFF/, ''))
assert.equal(evidence.evidence_id, 'ev_89843a8b67f411d3297b5f3e428830393cd0fe8712603816cf8b7bbe442064d9')
function source(name) {
const item = evidence.normalized_result.scripts.items.find(item => item.file_path.endsWith(name))
assert.ok(item?.source.included)
const text = item.source.artifact.text
assert.equal(crypto.createHash('sha256').update(text).digest('hex'), item.source.artifact.sha256)
return parser.parse(text, { sourceType: 'module' })
}
function decode(node) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return literal(node)
if (ts.isArrayLiteralExpression(node)) return node.elements.map(decode)
if (ts.isObjectLiteralExpression(node)) {
const result = {}
for (const property of node.properties) {
assert.ok(ts.isPropertyAssignment(property))
const key = property.key.name ?? property.key.value
if (key !== 'icon') result[key] = decode(property.value)
}
return result
}
throw Error(`Nonliteral template node: ${node.type}`)
}
const definitions = new Map()
function visit(node) {
if (ts.isBinaryExpression(node) && node.operator === '=' && ts.isIdentifier(node.left) && ['vt', 'gt', 'St', 'bt', 'mt'].includes(node.left.name) && ts.isObjectLiteralExpression(node.right)) definitions.set(node.left.name, decode(node.right))
ts.forEachChild(node, visit)
}
visit(source('project-list-page-DpHRTtk8.js'))
assert.equal(definitions.size, 5)
const translations = new Map()
function strings(node) {
if (ts.isPropertyAssignment(node) && ts.isStringLiteral(node.key) && node.key.value.startsWith('collab.list.template.') && (ts.isStringLiteral(node.value) || ts.isNoSubstitutionTemplateLiteral(node.value))) translations.set(node.key.value, literal(node.value))
ts.forEachChild(node, strings)
}
strings(source('zh-cn-C7BTHfrQ.js'))
const templates = ['vt', 'gt', 'St', 'bt', 'mt'].map(key => {
const item = definitions.get(key)
for (const field of ['title', 'desc', 'prompt']) {
assert.ok(translations.has(item[field]), `Missing translation: ${item[field]}`)
item[field] = translations.get(item[field])
}
return item
})
const output = JSON.stringify(templates, null, 2) + '\n'
const destination = path.resolve('src/renderer/src/projects/project-templates.json')
if (process.argv.includes('--check')) assert.equal(fs.readFileSync(destination, 'utf8'), output)
else fs.writeFileSync(destination, output)
console.log(`PASS project templates: ${templates.length} source configurations and Chinese instructions recovered`)
+49
View File
@@ -0,0 +1,49 @@
// Consume pinned, retained REA Evidence only. No access to the installed WorkBuddy or target protocols.
import fs from 'node:fs/promises'
import crypto from 'node:crypto'
import { parse } from '@babel/parser'
import postcss from 'postcss'
const evidence = JSON.parse(await fs.readFile('.tmp/rea-runtime-scripts.json', 'utf8'))
if (evidence.evidence_id !== 'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb') throw Error('REA Evidence changed')
const item = evidence.items.find(item => item.file_path?.endsWith('src-DbE1_mgM.js'))
const source = item?.source?.artifact?.text
if (!source || crypto.createHash('sha256').update(source).digest('hex') !== '6c6ce63c93854783270e855572797d75e536e3b8d4548410e31fe9d147e1feb4') throw Error('REA source digest changed')
const names = ['MessageQuoteTableIcon', 'FileArchiveIcon', 'FileArdotIcon', 'FileAudioIcon', 'FileCodeIcon', 'FileDiagramIcon', 'FileDocIcon', 'FileFolderIcon', 'FileHtmlIcon', 'FileImageIcon', 'FileMarkdownIcon', 'FilePdfIcon', 'FileSheetIcon', 'FileSlideIcon', 'FileTxtIcon', 'FileUnknownIcon', 'FileVideoIcon', 'Close12Icon', 'ErrorIcon$1']
const functions = new Map(), literals = new Map()
function visit(node) {
if (!node || typeof node !== 'object') return
if (node.type === 'FunctionDeclaration' && names.includes(node.id?.name)) functions.set(node.id.name, source.slice(node.start, node.end))
if (node.type === 'AssignmentExpression' && node.left.type === 'Identifier') {
if (names.includes(node.left.name) && node.right.type === 'ArrowFunctionExpression') functions.set(node.left.name, `const ${node.left.name} = ${source.slice(node.right.start, node.right.end)}`)
if (['TemplateLiteral', 'StringLiteral'].includes(node.right.type) && !node.right.expressions?.length) literals.set(node.left.name, source.slice(node.right.start, node.right.end))
}
for (const value of Object.values(node)) if (Array.isArray(value)) value.forEach(visit); else if (value && typeof value === 'object') visit(value)
}
visit(parse(source, { sourceType: 'module' }))
const constants = new Map()
const declarations = names.map(name => {
const definition = functions.get(name)
if (!definition) throw Error('Missing icon ' + name)
for (const match of definition.matchAll(/\bd:([A-Za-z_$][\w$]*)[,}]/g)) {
const literal = literals.get(match[1])
if (!literal) throw Error('Missing inert path constant ' + match[1])
constants.set(match[1], literal)
}
return 'export ' + definition.replace(/\(0,[\w$]+\.(jsx|jsxs)\)/g, ' $1').replace(/ErrorIcon\$1/g, 'ReferenceErrorIcon')
})
const directory = 'src/renderer/src/workbuddy/references'
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(`${directory}/SourceReferenceIcons.tsx`, `// @ts-nocheck\n// Inert SVG geometry recovered from REA ${evidence.evidence_id}, src-DbE1_mgM.js.\nimport { jsx, jsxs } from 'react/jsx-runtime'\n${[...constants].map(([key, value]) => `const ${key} = ${value}`).join('\n')}\n${declarations.join('\n')}\n`)
const css = await fs.readFile('.tmp/rea-css-recovered/src-CyXHA0IC.css', 'utf8')
if (crypto.createHash('sha256').update(css).digest('hex') !== 'a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe') throw Error('REA CSS digest changed')
const selected = []
postcss.parse(css).walkRules(rule => {
const selectors = rule.selectors.filter(selector => /1krka/.test(selector) && !/sceneTag|urlTag|base64UrlImg/.test(selector) || /^\.cr-input-editor-host \[data-slate-editor\]/.test(selector))
if (!selectors.length) return
const node = rule.clone(); node.selectors = selectors
let tree = node
for (let parent = rule.parent; parent?.type === 'atrule'; parent = parent.parent) { const outer = parent.clone({ nodes: [] }); outer.append(tree); tree = outer }
selected.push(tree.toString())
})
await fs.writeFile(`${directory}/source-reference-tags.css`, `/* REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94; src-CyXHA0IC.css sha256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe. Exact declarations with parent conditions retained. */\n${selected.join('\n')}\n`)
console.log(JSON.stringify({ icons: names.length, rules: selected.length, sourceEvidence: evidence.evidence_id }))
+26 -4
View File
@@ -1,9 +1,12 @@
import fs from 'node:fs'
import path from 'node:path'
import { createHash } from 'node:crypto'
import { parse } from '@babel/parser'
import traverseModule from '@babel/traverse'
import postcss from 'postcss'
const traverse=traverseModule.default??traverseModule, evidence=JSON.parse(fs.readFileSync('.tmp/rea-runtime-scripts.json','utf8')),cache=new Map()
const traverse=traverseModule.default??traverseModule, evidence=JSON.parse(fs.readFileSync('.tmp/rea-runtime-scripts.json','utf8')),cache=new Map(),checkOnly=process.argv.includes('--check')
const HELP_FEEDBACK_EVIDENCE={id:'ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e',fileSha256:'87511827e6cf8f016c9c073fb654ffcc13520b0770dc2ef997564efc1ffe6ba7',pathLength:534,pathSha256:'d84f185b0d52e3b7eb61c96efc21a34887c213746b12132ddd3595d33e332287'}
const hash=(value)=>createHash('sha256').update(value).digest('hex')
function source(base){return evidence.items.find((item)=>item.file_path.endsWith(base))?.source.artifact.text}
function ast(base){if(!cache.has(base))cache.set(base,parse(source(base),{sourceType:'module'}));return cache.get(base)}
const context='contexts-BMeWSUHk.js',staticValue=(node)=>!node?undefined:node.type==='TemplateLiteral'&&!node.expressions.length?node.quasis[0].value.cooked:node.type==='StringLiteral'||node.type==='NumericLiteral'?node.value:undefined
@@ -30,9 +33,28 @@ function resolve(base,name,depth=0){
return next?resolve(base,next,depth+1):null
}
const icons={};for(const[id,name]of Object.entries(names)){const svg=resolve(context,name);if(svg)icons[id]=svg}
fs.writeFileSync('src/renderer/src/components/source-settings-navigation-reference.ts','// REA '+evidence.evidence_id+'; SettingsNavigation/DC/cXe and source SVG geometry only.\nexport const sourceSettingsTabs = '+JSON.stringify(tabs,null,2)+'\nexport const sourceSettingsGroups = '+JSON.stringify(groups,null,2)+'\nexport const sourceSettingsIcons: Record<string,{props:Record<string,string|number>;children:{tag:string;props:Record<string,string|number>}[]}> = '+JSON.stringify(icons,null,2)+'\nconst labels:Record<string,string> = '+JSON.stringify(labels,null,2)+'\nexport const sourceSettingsLabel=(key:string):string=>labels[key]??key\n')
const fixedEvidenceFile=path.join('.tmp','rea-css-evidence',HELP_FEEDBACK_EVIDENCE.id+'.json'),fixedEvidenceBytes=fs.readFileSync(fixedEvidenceFile),fixedEvidenceSha=hash(fixedEvidenceBytes)
if(fixedEvidenceSha!==HELP_FEEDBACK_EVIDENCE.fileSha256)throw new Error('Fixed settings navigation Evidence changed: '+fixedEvidenceSha)
const fixedEvidence=JSON.parse(fixedEvidenceBytes.toString('utf8'))
if(fixedEvidence.evidence_id!==HELP_FEEDBACK_EVIDENCE.id)throw new Error('Fixed settings navigation Evidence identity mismatch')
const fixedDocument=fixedEvidence.normalized_result.visuals.layout.documents.find((document)=>document.nodes.some((node)=>String(node.attributes?.class??'').split(/\s+/).includes('settings-navigation__group--about')))
if(!fixedDocument)throw new Error('settings-navigation__group--about is absent from fixed Evidence')
const fixedChildren=new Map();for(const node of fixedDocument.nodes){const list=fixedChildren.get(node.parent_index)??[];list.push(node);fixedChildren.set(node.parent_index,list)}
const descendants=(root)=>{const output=[],queue=[...(fixedChildren.get(root.node_index)??[])];while(queue.length){const node=queue.shift();output.push(node);queue.push(...(fixedChildren.get(node.node_index)??[]))}return output}
const aboutGroup=fixedDocument.nodes.find((node)=>String(node.attributes?.class??'').split(/\s+/).includes('settings-navigation__group--about')),aboutNodes=descendants(aboutGroup)
const aboutItem=aboutNodes.find((node)=>String(node.attributes?.class??'').split(/\s+/).includes('settings-navigation__item')),aboutSvg=aboutNodes.find((node)=>node.node_name.toLowerCase()==='svg')
if(!aboutItem||!aboutSvg)throw new Error('About navigation item or SVG is absent from fixed Evidence')
const fixedSvgRoot=fixedDocument.svg_nodes.find((node)=>node.node_index===aboutSvg.node_index&&node.node_name==='svg'),fixedPath=fixedDocument.svg_nodes.find((node)=>node.parent_index===aboutSvg.node_index&&node.node_name==='path')
if(!fixedSvgRoot)throw new Error('About navigation SVG root is absent from fixed Evidence')
if(!fixedPath||typeof fixedPath.attributes.d!=='string')throw new Error('About navigation SVG path is absent from fixed Evidence')
const pathLength=Buffer.byteLength(fixedPath.attributes.d,'utf16le')/2,pathSha=hash(fixedPath.attributes.d)
if(pathLength!==HELP_FEEDBACK_EVIDENCE.pathLength||pathSha!==HELP_FEEDBACK_EVIDENCE.pathSha256)throw new Error('About navigation path fingerprint mismatch: '+pathLength+' '+pathSha)
icons.helpFeedback={props:{viewBox:fixedSvgRoot.attributes.viewBox,fill:fixedSvgRoot.attributes.fill,xmlns:'http://www.w3.org/2000/svg'},children:[{tag:'path',props:{transform:fixedPath.attributes.transform,fill:fixedPath.attributes.fill,fillRule:fixedPath.attributes['fill-rule'],d:fixedPath.attributes.d}}]}
const generatedReference='// REA '+evidence.evidence_id+'; SettingsNavigation/DC/cXe and source SVG geometry only.\n// About path: REA '+HELP_FEEDBACK_EVIDENCE.id+'; evidence sha256='+fixedEvidenceSha+'; path sha256='+pathSha+'; length='+pathLength+'.\nexport const sourceSettingsTabs = '+JSON.stringify(tabs,null,2)+'\nexport const sourceSettingsGroups = '+JSON.stringify(groups,null,2)+'\nexport const sourceSettingsIcons: Record<string,{props:Record<string,string|number>;children:{tag:string;props:Record<string,string|number>}[]}> = '+JSON.stringify(icons,null,2)+'\nconst labels:Record<string,string> = '+JSON.stringify(labels,null,2)+'\nexport const sourceSettingsLabel=(key:string):string=>labels[key]??key\n'
const root=postcss.parse(fs.readFileSync('.tmp/rea-css-recovered/contexts-BkjwYSZT.css','utf8')),css=[]
root.walkRules((rule)=>{if(!/\.settings-(?:navigation|search)/.test(rule.selector))return;let copy=rule.clone();copy.selectors=copy.selectors.map((s)=>'.source-settings-navigation '+s);let tree=copy;for(let parent=rule.parent;parent?.type==='atrule';parent=parent.parent){const wrap=parent.clone({nodes:[]});wrap.append(tree);tree=wrap}css.push(tree.toString())})
const variables=new Set();const collect=(text)=>{for(const match of text.matchAll(/var\((--[\w-]+)/g))variables.add(match[1])};collect(css.join(''));const tokenRules=[];postcss.parse(fs.readFileSync('.tmp/rea-css-recovered/tokens-Df-i-zsO.css','utf8')).walkRules((rule)=>tokenRules.push(rule));for(let pass=0;pass<10;pass++)for(const rule of tokenRules)rule.walkDecls((decl)=>{if(variables.has(decl.prop))collect(decl.value)});const light=new Map(),dark=new Map();for(const rule of tokenRules){const target=rule.selector.includes('dark')?dark:rule.selector.includes(':root')?light:null;if(target)rule.walkDecls((decl)=>{if(variables.has(decl.prop))target.set(decl.prop,decl.value)})}const serialize=(map)=>[...map].map(([key,value])=>key+':'+value).join(';');css.unshift('.source-settings-navigation{'+serialize(light)+'}',':root[data-theme=dark] .source-settings-navigation{'+serialize(dark)+'}');
fs.writeFileSync('src/renderer/src/components/source-settings-navigation-recovered.css','/* REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94; media conditions retained. */\n'+css.join('\n')+'\n')
console.log(JSON.stringify({tabs:tabs.length,groups:groups.length,icons:Object.keys(icons),unknownIcons:Object.keys(names).filter((key)=>!icons[key]),rules:css.length}))
const generatedCss='/* REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94; media conditions retained. */\n'+css.join('\n')+'\n',outputs=new Map([['src/renderer/src/components/source-settings-navigation-reference.ts',generatedReference],['src/renderer/src/components/source-settings-navigation-recovered.css',generatedCss]]),drift=[]
for(const[file,content]of outputs){if(checkOnly){if(!fs.existsSync(file)||fs.readFileSync(file,'utf8')!==content)drift.push(file)}else fs.writeFileSync(file,content)}
if(drift.length){console.error('settings navigation recovery drift: '+drift.join(', '));process.exitCode=1}
console.log(JSON.stringify({check:checkOnly,idempotent:drift.length===0,tabs:tabs.length,groups:groups.length,icons:Object.keys(icons),unknownIcons:Object.keys(names).filter((key)=>!icons[key]),rules:css.length,helpFeedbackEvidence:HELP_FEEDBACK_EVIDENCE.id,helpFeedbackPathLength:pathLength,helpFeedbackPathSha256:pathSha}))
+52
View File
@@ -0,0 +1,52 @@
// Only retained REA evidence is read; this script never opens WorkBuddy or its installation.
import fs from 'node:fs'
import path from 'node:path'
import { createHash } from 'node:crypto'
import postcss from 'postcss'
const sha = text => createHash('sha256').update(text).digest('hex')
const archive = JSON.parse(fs.readFileSync('.tmp/rea-runtime-scripts.json', 'utf8'))
const source = archive.items.find(item => item.file_path.endsWith('contexts-BMeWSUHk.js'))?.source.artifact
const sourcePin = '5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b'
if (!source || source.sha256 !== sourcePin || sha(source.text) !== sourcePin) throw Error('REA runtime source digest mismatch')
const cssManifest = JSON.parse(fs.readFileSync('.tmp/rea-css-recovered/manifest.json', 'utf8'))
const cssIdentity = cssManifest.files.find(file => file.file === 'contexts-BkjwYSZT.css')
const css = fs.readFileSync(path.join('.tmp/rea-css-recovered', cssIdentity.file), 'utf8')
if (sha(css) !== cssIdentity.sha256) throw Error('REA CSS source digest mismatch')
const output = postcss.root()
postcss.parse(css).walkRules(rule => {
if (!/\.conversation-(?:team-collaboration|member-detail)(?:[\s_.:#\[>+-]|$)/.test(rule.selector)) return
let copy = rule.clone()
for (let parent = rule.parent; parent?.type === 'atrule'; parent = parent.parent) {
copy = parent.clone({ nodes: [copy] })
}
output.append(copy)
})
if (!output.nodes.length) throw Error('REA member CSS was not found')
const folder = 'src/renderer/src/workbuddy/subagents'
const header = `/* WorkBuddy 5.8.2; retained REA ${cssManifest.sourceEvidenceId}.\n * Exact selected source rules; host primitive adapters are kept separately.\n */\n`
const result = header + output.toString().replaceAll('}', '}\n').trimEnd() + '\n'
// The conversation's generated theme is scoped to .chat-pane. Give the sibling
// member surface the same retained token declarations without leaking them globally.
const memberTheme = postcss.root()
postcss.parse(fs.readFileSync('src/renderer/src/workbuddy/conversation/source-styles.css', 'utf8')).walkRules(rule => {
const selectors = rule.selectors.filter(selector => /^\.chat-pane\.cr-theme(?:\[data-cr-theme=(?:light|dark)\])?$/.test(selector))
if (!selectors.length) return
const declarations = rule.nodes.filter(node => node.type === 'decl' && node.prop.startsWith('--')).map(node => node.clone())
if (declarations.length) memberTheme.append(postcss.rule({ selector: selectors.map(selector => selector.replace('.chat-pane', '.conversation-member-detail')).join(','), nodes: declarations }))
})
if (!memberTheme.nodes.length) throw Error('Scoped conversation theme tokens were not found')
const themeResult = '/* Same retained conversation token scopes for the sibling member detail surface. */\n' + memberTheme.toString().replaceAll('}', '}\n').trimEnd() + '\n'
const manifest = { version: '5.8.2', runtimeEvidenceId: archive.evidence_id, runtimeFile: 'contexts-BMeWSUHk.js', runtimeSha256: sourcePin,
cssEvidenceId: cssManifest.sourceEvidenceId, cssFile: cssIdentity.file, cssSha256: cssIdentity.sha256, rules: output.nodes.length,
sourceRanges: { members: [6187325, 6193670], detail: [5913738, 5917850], finishedFooter: [6891900, 6892500], streamingFooter: [6977800, 6978800] },
adaptations: ['YCode SubagentSnapshot supplies identity, status and events.', 'The existing workbench owns member selection and its detail surface.', 'Source button/avatar/icon primitives bind to local React elements; adapters are separate.', 'Cancellation remains available from the member detail header.'],
limitations: ['No current live WorkBuddy capture; full-state pixel and motion equivalence remains unverified.'], outputSha256: sha(result), themeOutputSha256: sha(themeResult) }
fs.mkdirSync(folder, { recursive: true })
for (const [file, content] of [['source.css', result], ['theme.css', themeResult], ['manifest.json', JSON.stringify(manifest, null, 2) + '\n']]) {
const target = path.join(folder, file)
if (process.argv.includes('--check')) {
if (fs.readFileSync(target, 'utf8') !== content) throw Error('Recovered source drift: ' + target)
} else fs.writeFileSync(target, content)
}
console.log(JSON.stringify({ rules: output.nodes.length, cssEvidenceId: manifest.cssEvidenceId, outputSha256: manifest.outputSha256 }))
+70
View File
@@ -0,0 +1,70 @@
// SVG recovery from the fixed REA script export only; no original installation/runtime access.
const fs = require('node:fs'), crypto = require('node:crypto')
const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
const archive = JSON.parse(fs.readFileSync('.tmp/rea-runtime-scripts.json', 'utf8'))
const cache = new Map()
const literal = node => !node ? undefined : node.type === 'TemplateLiteral' && !node.expressions.length ? node.quasis[0].value.cooked : ['StringLiteral', 'NumericLiteral', 'BooleanLiteral'].includes(node.type) ? node.value : undefined
const object = node => Object.fromEntries((node?.properties || []).filter(prop => prop.type === 'ObjectProperty').flatMap(prop => {
const value = literal(prop.value)
return value !== undefined ? [[prop.key.name ?? prop.key.value, value]] : []
}))
function get(file) {
if (cache.has(file)) return cache.get(file)
const source = archive.items.find(item => item.file_path.endsWith(file))?.source?.artifact
if (!source) return
if (crypto.createHash('sha256').update(source.text).digest('hex') !== source.sha256) throw Error('REA digest mismatch')
if (file === 'contexts-BMeWSUHk.js' && source.sha256 !== '5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b') throw Error('REA context identity changed')
if (file === 'src-DPDj7_qD.js' && source.sha256 !== '747e41d215375e1214c366213aea7e077d85421923029c61cbb6e9562be58de0') throw Error('REA icon identity changed')
const ast = parse(source.text, { sourceType: 'module' })
const result = { source, ast }
cache.set(file, result)
return result
}
function resolve(file, name, depth = 0) {
const source = get(file)
if (!source || depth > 8) return
let imported, declaration
traverse(source.ast, {
ImportDeclaration(p) { for (const spec of p.node.specifiers) if (spec.local.name === name && spec.type === 'ImportSpecifier') imported = { file: p.node.source.value.replace('./', ''), member: spec.imported.name } },
FunctionDeclaration(p) { if (p.node.id?.name === name) declaration = p },
AssignmentExpression(p) { if (p.node.left.name === name) declaration = p },
VariableDeclarator(p) { if (p.node.id?.name === name && p.node.init) declaration = p }
})
if (imported && get(imported.file)) {
let local
traverse(get(imported.file).ast, { ExportNamedDeclaration(p) { for (const spec of p.node.specifiers) if (spec.exported.name === imported.member) local = spec.local.name } })
if (local) return resolve(imported.file, local, depth + 1)
}
if (!declaration) return
const right = declaration.node.right ?? declaration.node.init
if (right?.type === 'CallExpression' && typeof literal(right.arguments[0]) === 'string' && right.arguments[1]?.type === 'Identifier') {
let nodes
traverse(source.ast, { AssignmentExpression(p) { if (p.node.left.name === right.arguments[1].name && p.node.right.type === 'ArrayExpression') nodes = p.node.right.elements } })
if (nodes) return { file, sha256: source.source.sha256, props: { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }, children: nodes.map(entry => ({ tag: literal(entry.elements[0]), props: object(entry.elements[1]) })) }
}
if (right?.type === 'CallExpression' && right.arguments[0]?.type === 'Identifier') return resolve(file, right.arguments[0].name, depth + 1)
let svg, paths = [], next
declaration.traverse({ CallExpression(p) {
const tag = p.node.arguments[0]
if (tag?.type === 'Identifier') { next = tag.name; return }
const kind = literal(tag)
if (!['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse'].includes(kind)) return
const props = object(p.node.arguments[1])
if (kind === 'svg') svg = props
else paths.push({ tag: kind, props })
} })
if (svg && paths.length) return { file, sha256: source.source.sha256, props: svg, children: paths }
return next ? resolve(file, next, depth + 1) : undefined
}
const icons = {}
for (const [key, binding] of Object.entries({ chevronDown: 'Oo', chevronRight: 'yo', loading: 'xo', check: 'Ea', failure: 'Ba', close: 'Da', assignment: 'Pte' })) {
icons[key] = resolve('contexts-BMeWSUHk.js', binding) || null
}
if (Object.values(icons).some(icon => !icon)) throw Error('A selected source member icon was not recovered')
const output = 'src/renderer/src/workbuddy/subagents/icons.json'
const content = JSON.stringify({ version: '5.8.2', evidenceId: archive.evidence_id, icons }, null, 2) + '\n'
if (process.argv.includes('--check')) {
if (fs.readFileSync(output, 'utf8') !== content) throw Error('Recovered member icon drift')
} else fs.writeFileSync(output, content)
console.log(JSON.stringify({ icons: Object.keys(icons).length, sha256: crypto.createHash('sha256').update(content).digest('hex') }))
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import { normalizeUserContent, trimUserContent } from '../src/shared/user-content'
import type { UserContentBlock } from '../src/shared/conversation'
import { userContentToSlate, slateToUserContent } from '../src/renderer/src/workbuddy/references/ReferenceContentEditor'
import { MessageQuoteTag } from '../src/renderer/src/workbuddy/conversation/MessageQuoteTag'
import { UserInlineContent } from '../src/renderer/src/workbuddy/references/UserInlineContent'
import { ReferenceIcon } from '../src/renderer/src/workbuddy/references/ContentReferenceTag'
const collections = { quotes: [{ id: 'q' }], attachments: [{ id: 'a' }], resources: ['C:/资料/说明.md'], skills: [{ id: 's' }] }
const blocks: UserContentBlock[] = [{ type: 'text', text: '前😀' }, { type: 'quote', id: 'q' }, { type: 'text', text: '\n中\n' },
{ type: 'attachment', id: 'a' }, { type: 'resource', id: collections.resources[0] }, { type: 'skill', id: 's' }, { type: 'text', text: '后' }]
const normalized = normalizeUserContent(blocks, '前😀\n中\n后', collections)
assert.deepEqual(normalized, blocks, 'mixed order must survive validation')
assert.deepEqual(normalizeUserContent(slateToUserContent(userContentToSlate(blocks)), '前😀\n中\n后', collections), blocks,
'paragraphs, inline voids, adjacent references and Unicode must round trip without changing the prompt')
assert.deepEqual(normalizeUserContent([{ type: 'text', text: 'safe' }, { type: 'attachment', id: 'forged' }, { type: 'quote', id: 'q' }, { type: 'quote', id: 'q' }], 'safe', { quotes: [{ id: 'q' }] }),
[{ type: 'text', text: 'safe' }, { type: 'quote', id: 'q' }], 'layout cannot introduce payloads or duplicate reference identities')
assert.deepEqual(normalizeUserContent([{ type: 'text', text: 'changed' }], 'trusted', {}), [{ type: 'text', text: 'trusted' }], 'view metadata cannot replace model text')
assert.deepEqual(trimUserContent([{ type: 'text', text: ' 前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后 ' }]),
[{ type: 'text', text: '前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后' }], 'trimming cannot flatten the inline reference order')
const quote = { id: 'q', sourceId: 'm:table:t', kind: 'table' as const, markdown: '| sensitive snapshot |', preview: '| sensitive snapshot |' }
const tag = renderToStaticMarkup(<MessageQuoteTag quote={quote} readOnly />)
assert.ok(tag.includes('会话表格') && !tag.includes('sensitive snapshot'), 'legacy table previews cannot leak raw Markdown into the label or tooltip')
assert.ok(tag.includes('viewBox="0 0 14 14"') && tag.includes('_fileTag_1krka_111'), 'table must use its actual SVG and pill variant')
assert.ok(!tag.includes('<button'), 'read-only references cannot expose removal')
const message = renderToStaticMarkup(<UserInlineContent message={{ id: 'm', role: 'user', content: '前后', createdAt: 0, quotes: [quote],
attachments: [{ id: 'a', name: '报告.pdf', size: 10, mimeType: 'application/pdf' }],
userContent: [{ type: 'text', text: '前' }, { type: 'quote', id: 'q' }, { type: 'attachment', id: 'a' }, { type: 'text', text: '后' }] }} />)
assert.ok(message.indexOf('前') < message.indexOf('会话表格') && message.indexOf('会话表格') < message.indexOf('报告.pdf') && message.indexOf('报告.pdf') < message.lastIndexOf('后'), 'read-only projection must keep the original inline order')
for (const name of ['说明.md','表格.csv','报告.pdf','文档.docx','代码.ts','页面.html','图片.png','压缩.zip','音频.wav','视频.mp4','other.unknown']) {
const icon = renderToStaticMarkup(<ReferenceIcon kind="file" name={name} />)
assert.ok(icon.includes('<svg') && icon.includes('width="14"'), 'recovered icon must render without unresolved path constants: ' + name)
}
console.log('reference-content-regression: inline order, Unicode/newlines, validation, snapshots, legacy labels, read-only tags and recovered icon family passed')
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict'
import {
DEFAULT_MEMORY_PREFERENCES,
mergeMemoryPreferences,
normalizeMemoryPreferences
} from '../src/shared/memory'
import {
buildPersonalizationPrompt,
DEFAULT_PERSONA,
normalizePersonalization
} from '../src/shared/personalization'
import { storageUsagePresentation } from '../src/renderer/src/components/GeneralSettingsPanel'
import {
LOADING_TIPS_EVENT,
LOADING_TIPS_STORAGE_KEY,
loadingTipsEnabled,
setLoadingTipsEnabled,
subscribeLoadingTips
} from '../src/renderer/src/personalization-prefs'
assert.deepEqual(normalizeMemoryPreferences(undefined), DEFAULT_MEMORY_PREFERENCES)
assert.deepEqual(
mergeMemoryPreferences(undefined, { conversationMemory: false }),
{ conversationMemory: false, localMemorySkills: false },
'changing conversation memory must not change local memory skills'
)
assert.deepEqual(
mergeMemoryPreferences(undefined, { localMemorySkills: true }),
{ conversationMemory: true, localMemorySkills: true },
'changing local memory skills must not change conversation memory'
)
assert.deepEqual(
mergeMemoryPreferences({ conversationMemory: false }, undefined),
{ conversationMemory: false, localMemorySkills: false },
'a partial stored view must inherit the declared default for its omitted key'
)
const gib = 1024 ** 3
const exactStorage = storageUsagePresentation({
dataBytes: gib,
dataUsagePartial: false,
diskTotal: 100 * gib,
diskFree: 50 * gib
})
assert.equal(exactStorage.usageText, '已用 1.00 GB / 共 100.0 GB')
assert.equal(exactStorage.cacheLabel, '系统缓存')
assert.equal(exactStorage.otherLabel, '磁盘已用')
assert.equal(exactStorage.cachePercentLowerBound, 1)
assert.equal(exactStorage.otherPercentUpperBound, 49)
assert.equal(exactStorage.freePercent, 50)
assert.equal(exactStorage.partial, false)
const partialStorage = storageUsagePresentation({
dataBytes: gib,
dataUsagePartial: true,
diskTotal: 100 * gib,
diskFree: 50 * gib
})
assert.equal(partialStorage.usageText, '统计未完成 · 至少已扫描 1.00 GB')
assert.equal(partialStorage.cacheLabel, '系统缓存(至少)')
assert.equal(partialStorage.otherLabel, '其他磁盘数据(至多)')
assert.equal(partialStorage.partial, true)
const overCountedStorage = storageUsagePresentation({
dataBytes: 80 * gib,
dataUsagePartial: true,
diskTotal: 100 * gib,
diskFree: 70 * gib
})
assert.equal(overCountedStorage.cachePercentLowerBound, 30, 'cache must not exceed the mounted disk used ratio')
assert.equal(overCountedStorage.otherPercentUpperBound, 0)
assert.equal(overCountedStorage.freePercent, 70)
const defaultPrompt = buildPersonalizationPrompt(normalizePersonalization(undefined))
assert.ok(defaultPrompt.includes(`### 人设设定\n${DEFAULT_PERSONA}`))
assert.equal(buildPersonalizationPrompt(normalizePersonalization({ persona: '' })), '')
const storage = new Map<string, string>()
const fakeLocalStorage = {
get length(): number { return storage.size },
clear(): void { storage.clear() },
getItem(key: string): string | null { return storage.get(key) ?? null },
key(index: number): string | null { return [...storage.keys()][index] ?? null },
removeItem(key: string): void { storage.delete(key) },
setItem(key: string, value: string): void { storage.set(key, value) }
}
const fakeWindow = new EventTarget()
Object.defineProperty(globalThis, 'localStorage', { value: fakeLocalStorage, configurable: true })
Object.defineProperty(globalThis, 'window', { value: fakeWindow, configurable: true })
assert.equal(loadingTipsEnabled(), true)
let notifications = 0
const unsubscribe = subscribeLoadingTips(() => { notifications++ })
setLoadingTipsEnabled(false)
assert.equal(storage.get(LOADING_TIPS_STORAGE_KEY), '0')
assert.equal(notifications, 1, 'same-window custom event should update immediately')
const unrelatedStorage = new Event('storage')
Object.defineProperty(unrelatedStorage, 'key', { value: 'unrelated' })
fakeWindow.dispatchEvent(unrelatedStorage)
assert.equal(notifications, 1, 'unrelated storage keys must be ignored')
const matchingStorage = new Event('storage')
Object.defineProperty(matchingStorage, 'key', { value: LOADING_TIPS_STORAGE_KEY })
fakeWindow.dispatchEvent(matchingStorage)
assert.equal(notifications, 2, 'another window storage event should update the subscriber')
unsubscribe()
fakeWindow.dispatchEvent(new Event(LOADING_TIPS_EVENT))
fakeWindow.dispatchEvent(matchingStorage)
assert.equal(notifications, 2, 'unsubscribe must clean up both local and storage listeners')
console.log('remediation-pure-regression: all checks passed')
+5
View File
@@ -64,6 +64,7 @@ async function main(): Promise<void> {
const outfile = join(root, 'ipc.mjs')
await build({ entryPoints: ['src/main/ipc.ts'], outfile, bundle: true, external: ['node-pty'], platform: 'node', format: 'esm', logLevel: 'silent',
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
plugins: [testTerminalHostPlugin, { name: 'resource-ipc-adapters', setup(builder) {
builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'resource-test' }))
builder.onLoad({ filter: /.*/, namespace: 'resource-test' }, () => ({ loader: 'js', contents: `
@@ -72,6 +73,7 @@ async function main(): Promise<void> {
export const ipcMain={handle:(name,handler)=>state.handlers.set(name,handler)};
export const dialog={showSaveDialog:async()=>{state.dialogCalls++;return {canceled:!state.savePath,filePath:state.savePath}},showOpenDialog:async()=>({canceled:true,filePaths:[]})};
export const shell={openPath:async()=>''};
export const powerSaveBlocker={start:()=>1,stop:()=>{}};
export const safeStorage={isEncryptionAvailable:()=>false};
export class BrowserWindow{}
export class WebContentsView{}
@@ -81,6 +83,9 @@ async function main(): Promise<void> {
export const getWorkspaceRoot=async()=>globalThis.__YCODE_RESOURCE_TEST__.workspace;
export const getConfigView=async()=>({}); export const updateConfig=async()=>({});
export const getGatewayConfig=async()=>({}); export const setWorkspaceRoot=async()=>{};
export const getStoredAgentSettings=async()=>({}); export const setStoredAgentSettings=async()=>{};
export const getStoredMemoryPreferences=async()=>({}); export const setStoredMemoryPreferences=async()=>{};
export const getStoredPersonalization=async()=>({}); export const setStoredPersonalization=async()=>{};
` }))
} }]
})
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { listSandboxToolchains, importSandboxToolchain } from '../src/main/sandbox/toolchains'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
if (process.platform !== 'win32') throw new Error('Bundled Git verification requires real Windows isolation')
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-bundled-git-'))
const workspace = join(fixture, 'workspace \u4e2d\u6587'), stateRoot = join(fixture, 'data'), resources = resolve(process.env.YCODE_TEST_BUNDLED_RESOURCES ?? 'resources/sandbox/win32-x64')
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'input.txt'), 'baseline\n')
const lock = JSON.parse(await fs.readFile(resolve('resources/sandbox/git-runtime.lock.json'), 'utf8'))
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const context = { workspaceRoot: workspace, runtimes: ['git'], sessionId: 'bundled-git-session', runId: 'bundled-git-run', toolCallId: 'bundled-git-call', approve: async () => true }
const configure = (resourceDirectory = resources, useBundledGit = true): void => configureSandbox({ stateRoot, resourceDirectory, useBundledGit })
const gitView = async () => (await listSandboxToolchains()).find(item => item.kind === 'git')!
const { verifyBundledGit } = await import(pathToFileURL(resolve('scripts/vendor-sandbox-git.mjs')).href)
const originalPath = process.env.PATH
try {
configure()
const view = await gitView()
check('fresh userData exposes the pinned bundled Git without registration', view.state === 'registered' && view.source === 'bundled' && view.version === lock.version)
check('availability queries do not create a user Git registration', !await fs.stat(join(stateRoot, 'toolchains/git.json')).catch(() => null))
const component = JSON.parse(await fs.readFile(join(resources, 'manifest.json'), 'utf8'))
const bundle = await verifyBundledGit(join(resources, 'git'))
check('native component pins the complete bundled runtime manifest', component.bundledGit.manifestSha256 === bundle.manifestSha256 && component.bundledGit.archiveSha256 === lock.archive.sha256)
const notices = await fs.readFile(join(resources, 'git/files/THIRD_PARTY_NOTICES.txt'), 'utf8')
check('license, copyright and source provenance travel with the runtime', notices.includes(lock.releaseUrl) && notices.includes('GPL-2.0') && (await fs.readFile(join(resources, 'git/files/LICENSE.txt'), 'utf8')).includes('GNU GENERAL PUBLIC LICENSE'))
process.env.PATH = ''
let result = await executeSandboxCommand('git --version && git status --porcelain=v1', context)
check('real AppContainer Git works with empty host PATH and no user registry', result.ok && result.data.stdout.includes(`git version ${lock.version}`) && result.data.toolchains?.[0].id === view.id && result.data.git?.persistence === 'unchanged')
result = await executeSandboxCommand('echo bundled>bundled.txt && git add bundled.txt && git commit -q -m bundled-offline-commit', context)
check('bundled Git stages and commits through the existing approval boundary', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'committed' && (await fs.readFile(join(workspace, 'bundled.txt'), 'utf8')).trim() === 'bundled')
result = await executeSandboxCommand('git log -1 --oneline && git status --porcelain=v1', context)
check('a later command sees the saved bundled-Git commit', result.ok && result.data.stdout.includes('bundled-offline-commit') && result.data.git?.persistence === 'unchanged')
const copied = join(fixture, 'app resources \u5b89\u88c5/sandbox/win32-x64')
await fs.cp(resources, copied, { recursive: true, errorOnExist: true, force: false })
configure(copied)
result = await executeSandboxCommand('git --version && git log -1 --oneline', context)
check('copied application resources work at a space/Unicode installation path', result.ok && result.data.stdout.includes(`git version ${lock.version}`) && result.data.stdout.includes('bundled-offline-commit'))
const override = await importSandboxToolchain('git', join(copied, 'git/files/git.exe'))
check('a deliberate imported override takes precedence over the bundled runtime', (await gitView()).source === 'imported' && (await gitView()).id === override.id && override.id !== view.id)
const overrideIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
const overrideImage = join(stateRoot, 'toolchains/objects', overrideIdentity.id, 'files/git.exe')
await fs.appendFile(overrideImage, 'tampered')
await assert.rejects(executeSandboxCommand('git --version', context), /摘要不匹配/)
check('a damaged explicit override is rejected without silently switching sources', (await gitView()).source === 'imported')
const alias = join(stateRoot, 'toolchains/git.json'), aliasBytes = await fs.readFile(alias)
await fs.writeFile(alias, '{}')
await assert.rejects(executeSandboxCommand('git --version', context), /引用无效/)
check('an invalid user reference does not activate a bundled fallback', (await gitView()).state === 'invalid')
await fs.writeFile(alias, aliasBytes); await fs.unlink(alias)
check('removing the optional override restores the application default', (await gitView()).source === 'bundled' && (await gitView()).id === view.id)
const image = join(copied, 'git/files/git.exe'), imageBytes = await fs.readFile(image)
await fs.appendFile(image, 'tampered')
await assert.rejects(executeSandboxCommand('git --version', context), /摘要不匹配/)
await assert.rejects(verifyBundledGit(join(copied, 'git')), /digest mismatch/)
check('tampered bundled executable fails runtime and packaging checks', true)
await fs.writeFile(image, imageBytes)
const manifestPath = join(copied, 'git/manifest.json'), manifestBytes = await fs.readFile(manifestPath)
await fs.appendFile(manifestPath, ' ')
await assert.rejects(executeSandboxCommand('git --version', context), /内置 Git 清单摘要不匹配/)
await assert.rejects(verifyBundledGit(join(copied, 'git')), /manifest digest mismatch/)
check('a bundled manifest replacement is rejected against the component pin', (await gitView()).state === 'invalid')
await fs.writeFile(manifestPath, manifestBytes)
await fs.rename(manifestPath, `${manifestPath}.missing`)
await assert.rejects(executeSandboxCommand('git --version', context), /ENOENT/)
check('missing bundled files never fall back to the system Git installation', (await gitView()).state === 'invalid')
result = await executeSandboxCommand('echo SHELL_STILL_ISOLATED', { ...context, runtimes: [] })
check('a Git component failure does not turn shell execution into a host fallback', result.ok && result.data.isolated && result.data.stdout.includes('SHELL_STILL_ISOLATED') && (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'unavailable'))
await fs.rename(`${manifestPath}.missing`, manifestPath)
configure(copied, false)
check('a host configuration can explicitly disable bundled Git without granting host PATH', (await gitView()).state === 'missing')
configure(copied)
check('restored resources expose the original pinned identity', (await gitView()).id === view.id && (await verifyBundledGit(join(copied, 'git'))).manifestSha256 === bundle.manifestSha256)
process.env.PATH = originalPath
const badArchive = join(fixture, 'bad.zip'); await fs.writeFile(badArchive, 'not-an-archive')
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--archive', badArchive, '--offline', '--output', join(fixture, 'bad-output/git')], { windowsHide: true, stdio: 'pipe' }), /Official MinGit archive digest mismatch/)
check('the explicit vendor command rejects archives with a wrong digest before extraction', true)
const unrelated = join(fixture, 'unrelated/git'); await fs.mkdir(unrelated, { recursive: true }); await fs.writeFile(join(unrelated, 'user-file.txt'), 'preserve-user-data')
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--offline', '--output', unrelated], { windowsHide: true, stdio: 'pipe' }), /Refusing to replace an existing directory/)
check('vendoring refuses to move an unrelated existing output directory', await fs.readFile(join(unrelated, 'user-file.txt'), 'utf8') === 'preserve-user-data')
if (process.env.YCODE_TEST_MINGIT_ARCHIVE) {
const output = join(fixture, 'reproducible/git')
execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--archive', resolve(process.env.YCODE_TEST_MINGIT_ARCHIVE), '--offline', '--output', output], { windowsHide: true, stdio: 'pipe', timeout: 60_000 })
check('the pinned archive produces an identical manifest in an offline rebuild', (await verifyBundledGit(output)).manifestSha256 === bundle.manifestSha256)
}
console.log(`SANDBOX_BUNDLED_GIT_PASS ${checks.length}/${checks.length}`)
} finally {
if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, resources, version: lock.version, scope: 'Real AppContainer execution using bundled Git with no user registration and empty host PATH; copied/actual packaged resources when supplied.' }, null, 2) + '\n')
console.log(`SANDBOX_BUNDLED_GIT_REPORT ${join(fixture, 'report.json')}`)
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { commitChanges, restoreTransaction, digest } from '../src/main/sandbox/transactions'
if (process.platform !== 'win32') throw new Error('Directory integration requires real Windows native execution')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-directories-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const context = { workspaceRoot: workspace, sessionId: 'directory-session', runId: 'directory-run', toolCallId: 'directory-call', approve: async () => true }
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
try {
let approvals = 0
let result = await executeSandboxCommand('mkdir empty\\nested', { ...context, approve: async () => { approvals++; return true } })
check('empty directory tree commits after one batch approval', result.ok && approvals === 1 && result.data.changes.filter(change => change.operation === 'mkdir').length === 2 && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
const manifest = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', result.data.transactionId!, 'manifest.json'), 'utf8'))
check('directory transaction persists a versioned recovery journal', manifest.version === 3 && manifest.directories.length === 2 && /^[a-f0-9]{16}:[a-f0-9]{32}$/.test(manifest.workspaceIdentity))
await restoreTransaction(result.data.transactionId!, workspace); await restoreTransaction(result.data.transactionId!, workspace)
check('directory creation recovery removes children and parents and is idempotent', !await fs.stat(join(workspace, 'empty')).catch(() => null))
result = await executeSandboxCommand('mkdir denied', { ...context, approve: async () => false })
check('directory creation rejection leaves no host directory', !result.ok && !await fs.stat(join(workspace, 'denied')).catch(() => null))
await fs.mkdir(join(workspace, 'original/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'original/sub/data.bin'), Buffer.from([0, 1, 127, 255]))
result = await executeSandboxCommand('rmdir /s /q original', context)
check('recursive directory deletion records files and directories in one transaction', result.ok && result.data.changes.some(change => change.operation === 'delete') && result.data.changes.filter(change => change.operation === 'rmdir').length === 2 && !await fs.stat(join(workspace, 'original')).catch(() => null))
await restoreTransaction(result.data.transactionId!, workspace)
check('recursive deletion recovery recreates original directories and exact binary bytes', (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])))
result = await executeSandboxCommand('move original moved', context)
check('directory rename is represented by complete transactional tree changes', result.ok && !await fs.stat(join(workspace, 'original')).catch(() => null) && (await fs.stat(join(workspace, 'moved/sub'))).isDirectory())
await restoreTransaction(result.data.transactionId!, workspace)
check('directory rename restores its original tree and removes the new tree', !await fs.stat(join(workspace, 'moved')).catch(() => null) && (await fs.stat(join(workspace, 'original/sub'))).isDirectory())
const conflict = await executeSandboxCommand('rmdir /s /q original', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'original/external.txt'), 'external'); return true } }).then(() => null, error => error as Error)
check('approval-time additions reject the full directory deletion before mutation', !!conflict && (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])) && await fs.readFile(join(workspace, 'original/external.txt'), 'utf8') === 'external')
await fs.unlink(join(workspace, 'original/external.txt'))
await fs.writeFile(join(workspace, 'original/.env'), 'DUMMY_CREDENTIAL')
await assert.rejects(executeSandboxCommand('rmdir /s /q original', context), /未授权|新增内容/)
check('excluded credentials inside a deleted directory are protected by host preflight', await fs.readFile(join(workspace, 'original/.env'), 'utf8') === 'DUMMY_CREDENTIAL' && (await fs.stat(join(workspace, 'original/sub/data.bin'))).isFile())
await fs.unlink(join(workspace, 'original/.env'))
result = await executeSandboxCommand('mkdir restored-conflict & echo generated>restored-conflict\\file.txt', context)
await fs.writeFile(join(workspace, 'restored-conflict/external.txt'), 'external')
await assert.rejects(restoreTransaction(result.data.transactionId!, workspace), /未授权|新增内容/)
check('directory recovery refuses to remove later external additions', (await fs.readFile(join(workspace, 'restored-conflict/file.txt'), 'utf8')).trim() === 'generated' && await fs.readFile(join(workspace, 'restored-conflict/external.txt'), 'utf8') === 'external')
await fs.unlink(join(workspace, 'restored-conflict/external.txt')); await restoreTransaction(result.data.transactionId!, workspace)
check('recovery succeeds after the external conflict is explicitly resolved', !await fs.stat(join(workspace, 'restored-conflict')).catch(() => null))
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
let guarded = 0
const failed = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context, undefined,
{ beforeApply: async () => undefined, check: async () => { if (++guarded === 3) throw new Error('injected policy revocation') } }, [{ path: 'compensated-empty', before: false, after: true }]).then(() => null, error => error as Error & { recoveryRequired?: boolean })
check('mid-commit failure compensates both file writes and created directories', !!failed && !failed.recoveryRequired && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before' && !await fs.stat(join(workspace, 'compensated-empty')).catch(() => null))
console.log(`SANDBOX_DIRECTORY_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Native NTFS transactions pin root/ancestors and use relative handle operations. Rename handoff is not a single atomic swap. ACL/timestamp parity, power-loss durability and Windows recycle-bin semantics remain pending.'] }, null, 2) + '\n')
console.log(`SANDBOX_DIRECTORY_REPORT ${join(fixture, 'report.json')}`)
}
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { release, arch } from 'node:os'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { executeSandboxCommand } from '../src/main/sandbox/service'
if (process.platform !== 'win32') throw new Error('P0 mechanisms must be tested on Windows')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-feasibility-')), workspace = join(fixture, 'workspace')
await fs.mkdir(workspace)
const probe = resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), registryId = randomUUID()
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
await fs.copyFile(probe, join(workspace, 'sandbox-probe.exe'))
const checks: Array<{ label: string; pass: boolean }> = [], observations: Record<string, unknown> = { os: release(), architecture: arch() }
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
function host(args: string[]): Promise<{ code: number | null; stdout: string }> {
return new Promise((done, reject) => {
const child = spawn(probe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''
const timeout = setTimeout(() => child.kill(), 8000)
child.stdout.on('data', chunk => { stdout += chunk }); child.stderr.on('data', chunk => { stdout += chunk })
child.once('error', error => { clearTimeout(timeout); reject(error) }); child.once('close', code => { clearTimeout(timeout); done({ code, stdout }) })
})
}
try {
observations.host = JSON.parse((await host(['--host-facts'])).stdout)
const created = await host(['--registry-create', registryId])
check('registry fixture is created only under a UUID-owned test key', created.code === 0)
const before = await host(['--registry-read', registryId])
check('host positive control reads the exact fake registry value', before.code === 0 && before.stdout.includes('1193046'))
const context = { workspaceRoot: workspace, runId: 'p0-mechanisms', toolCallId: 'registry' }
const read = await executeSandboxCommand(`sandbox-probe.exe --registry-read ${registryId}`, context)
const write = await executeSandboxCommand(`sandbox-probe.exe --registry-write ${registryId}`, context)
observations.registry = { read: { ok: read.ok, stdout: read.data.stdout }, write: { ok: write.ok, stdout: write.data.stdout } }
check('AppContainer cannot read the host-owned registry fixture value', !read.ok && !read.data.stdout.includes('1193046'))
const after = await host(['--registry-read', registryId])
check('AppContainer registry writes cannot change the host fixture', after.code === 0 && after.stdout === before.stdout)
check('invalid registry identities are rejected without arbitrary key access', (await host(['--registry-create', '../outside'])).code === 2)
const directory = join(fixture, 'locked'), renamed = join(fixture, 'renamed'), ready = join(fixture, 'ready'), unlock = join(fixture, 'unlock')
await fs.mkdir(directory); await fs.writeFile(join(directory, 'sample.txt'), 'test-only')
const held = host(['--hold-directory', directory, ready, unlock])
for (let attempt = 0; attempt < 100 && !await fs.stat(ready).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
check('native directory handle acquires its test-only object before replacement attempt', !!await fs.stat(ready).catch(() => null))
const replacement = await fs.rename(directory, renamed).then(() => null, error => error as NodeJS.ErrnoException)
observations.directoryReplacement = { error: replacement?.code ?? null }
const blocked = !!replacement && ['EPERM', 'EACCES', 'EBUSY'].includes(replacement.code ?? '')
await fs.writeFile(unlock, 'release')
check('native directory-handle probe exits and releases ownership', (await held).code === 0)
check('no-delete-sharing directory handle blocks replacement while held', blocked)
await fs.rename(directory, renamed)
check('positive control can rename the directory after handle release', await fs.readFile(join(renamed, 'sample.txt'), 'utf8') === 'test-only')
if (process.argv.includes('--probe-pipe')) {
const pipeId = randomUUID(), runs = join(fixture, 'state/runs')
const client = executeSandboxCommand(`sandbox-probe.exe --pipe-client ${pipeId}`, { ...context, toolCallId: 'p0-pipe', timeoutMs: 6000 })
let profile: string | undefined
for (let attempt = 0; attempt < 100 && !profile; attempt++) {
for (const entry of await fs.readdir(runs)) {
const record = await fs.readFile(join(runs, entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), () => null)
if (record?.identity?.toolCallId === 'p0-pipe') profile = record.profile
}
if (!profile) await new Promise(done => setTimeout(done, 10))
}
assert.ok(profile, 'pipe scenario request must provide a host-owned profile identity')
const server = host(['--pipe-server', pipeId, profile])
const clientResult = await client, serverResult = await server
observations.authenticatedPipe = { client: { ok: clientResult.ok, stdout: clientResult.data.stdout }, server: serverResult }
const available = clientResult.ok && clientResult.data.stdout.includes('BROKER_PONG') && serverResult.code === 0 && serverResult.stdout.includes('PIPE_AUTHENTICATED')
observations.brokerPipeAvailable = available
if (available) check('host-scoped broker pipe accepts and authenticates the actual AppContainer client', true)
else {
check('unavailable broker transport cannot enable host execution or writeback', !clientResult.ok && clientResult.data.isolated && clientResult.data.sandbox === 'windows-appcontainer' && clientResult.data.writeback === 'not-applied' && !clientResult.data.changes.length)
console.log('SANDBOX_PIPE_UNAVAILABLE: experimental transport remains gated; see observations')
if (process.argv.includes('--require-pipe')) throw new Error('Authenticated broker pipe feasibility has not passed')
}
}
console.log(`SANDBOX_FEASIBILITY_PASS ${checks.length}/${checks.length}`)
} finally {
const cleanup = await host(['--registry-delete', registryId]).catch(error => ({ code: -1, stdout: String(error) }))
observations.registryCleanup = cleanup
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, observations, fixture, limitations: [
'Registry results describe the current AppContainer and fake HKCU key only; no configurable registry policy is implemented.',
'Holding one directory is a candidate primitive, not proof of safe ancestor traversal or a complete file broker.',
'The pipe probe exchanges a fixed echo only; it does not implement operations, policy dispatch, queue bounds or descendant Job membership authentication.',
'Controlled egress, descendant program policy, restricted ConPTY and hard disk quotas require further prototypes.',
'This host permission level and OS are the only tested environment; no original WorkBuddy execution occurred.'
] }, null, 2) + '\n')
console.log(`SANDBOX_FEASIBILITY_REPORT ${join(fixture, 'report.json')}`)
assert.equal(cleanup.code, 0, 'test registry key must be cleaned')
}
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { effectiveSandboxPolicy } from '../src/main/sandbox/policy-store'
import { createGitControl } from '../src/main/sandbox/git-control'
import { captureHostGit } from '../src/main/sandbox/git-host-source'
import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { executeTool } from '../src/main/agent/tools'
import { workspaceAction } from '../src/main/sandbox/workspace-service'
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-complete-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe')
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
function hostGit(...args: string[]): string {
return execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'commit.gpgSign=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args],
{ cwd: workspace, encoding: 'utf8', windowsHide: true, env: { SystemRoot: process.env.SystemRoot, PATH: `${resolve('resources/sandbox/win32-x64/git/files')};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' } })
}
let approvals = 0
const context = { workspaceRoot: workspace, sessionId: 'complete-session', runId: 'complete-run', toolCallId: 'complete-call', approve: async () => { approvals++; return true } }
const run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent', preserveGitConflicts: true })
async function gitRun(argv: string[]) {
const result = await executeTool('sandbox_git', { action: 'run', argv }, context)
await fs.writeFile(join(fixture, 'last-tool-result.json'), JSON.stringify(result, null, 2))
return result
}
try {
const policy = await effectiveSandboxPolicy(workspace), control = await createGitControl(policy)
try {
check('native parent-monitored host-only control runs the bundled Git', (await control.run(['--version'])).stdout.toString().includes('git version'))
hostGit('init', '-q', '--template=', '--initial-branch=main')
await fs.writeFile(join(workspace, 'tracked.txt'), 'initial\n')
hostGit('add', '.'); hostGit('commit', '-q', '-m', 'real-host-history')
await fs.writeFile(join(workspace, 'tracked.txt'), 'staged\n'); hostGit('add', 'tracked.txt')
await fs.writeFile(join(workspace, 'tracked.txt'), 'unstaged\n')
hostGit('config', 'credential.helper', '!echo SHOULD_NOT_RUN')
const snapshot = await captureHostGit(policy, control)
check('native metadata capture retains real HEAD, index and object history', snapshot.repository.has('HEAD') && snapshot.repository.has('index') && snapshot.files.some(file => file.path.startsWith('objects/')))
check('host executable configuration is excluded from the mirror', !snapshot.repository.get('config')!.toString().includes('SHOULD_NOT_RUN'))
} finally { await control.close() }
const imported = await gitHostAction('import', context)
check('explicit import is approval-bound and creates a session host mirror', imported.state === 'ready' && approvals === 1 && imported.pendingMetadata === false)
let result = await run('git log -1 --oneline && git diff --cached && git diff')
check('actual isolated Git sees host history and distinguishes staged and unstaged changes', result.ok && result.data.git?.mode === 'host-mirror' && result.data.stdout.includes('real-host-history') && result.data.stdout.includes('+staged') && result.data.stdout.includes('+unstaged'))
check('queries do not consume a host write approval', approvals === 1 && result.data.git?.persistence === 'unchanged')
result = await run('echo complete-local>tracked.txt && git add tracked.txt && git commit -q -m complete-local-commit && git branch feature && git tag v1')
check('private commits, branches and tags retain host ancestry without touching the host', result.ok && !hostGit('log', '-1', '--oneline').includes('complete-local-commit') && hostGit('diff').includes('+unstaged'))
const published = await gitHostAction('commit', context)
check('one approved group publishes worktree and actual host Git metadata', published.writeback === 'committed' && !!published.groupId && approvals === 2 && hostGit('log', '-1', '--oneline').includes('complete-local-commit') && hostGit('branch', '--list', 'feature').includes('feature') && hostGit('tag', '--list', 'v1').includes('v1'))
check('publication leaves the real host index and worktree coherent', hostGit('status', '--porcelain=v1').trim() === '' && (await fs.readFile(join(workspace, 'tracked.txt'), 'utf8')).includes('complete-local'))
check('host executable configuration survives publication without being imported', hostGit('config', '--get', 'credential.helper').includes('SHOULD_NOT_RUN'))
check('host status is bound to the imported session and clears metadata changes', (await hostGitStatus(workspace, context.sessionId)).pendingMetadata === false && (await hostGitStatus(workspace, 'another-session')).state === 'unimported')
const literalMessage = 'literal %PATH% & ^ "quoted"'
check('native argv executes Git directly without a command-shell expansion', (await gitRun(['commit', '--allow-empty', '-m', literalMessage])).ok && (await gitRun(['log', '-1', '--format=%s'])).content.includes(literalMessage))
check('branch switching retains the real host branch topology', (await gitRun(['switch', 'feature'])).ok)
await run('echo feature-change>feature.txt && git add feature.txt && git commit -q -m feature-change')
await gitRun(['switch', 'main']); await gitRun(['merge', '--no-edit', 'feature'])
check('a normal merge completes in the isolated draft', (await gitRun(['log', '--oneline', '-3'])).content.includes('feature-change'))
await run('echo scratch>stash.txt && git add stash.txt')
check('stash creation and restore persist across Jobs', (await gitRun(['stash', 'push', '-m', 'saved-stash'])).ok && (await gitRun(['stash', 'list'])).content.includes('saved-stash') && (await gitRun(['stash', 'pop'])).ok)
await gitRun(['reset', '--hard', 'HEAD'])
await gitRun(['switch', '-c', 'conflict-side'])
await run('echo side-value>tracked.txt && git add tracked.txt && git commit -q -m side-conflict')
await gitRun(['switch', 'main'])
await run('echo main-value>tracked.txt && git add tracked.txt && git commit -q -m main-conflict')
const merge = await gitRun(['merge', '--no-edit', 'conflict-side'])
check('a failed merge retains genuine conflict files and MERGE_HEAD for a later command', !merge.ok && (merge.data as { gitConflictState?: boolean }).gitConflictState && (await gitRun(['status', '--porcelain'])).content.includes('UU tracked.txt'))
await run('echo resolved-value>tracked.txt && git add tracked.txt')
check('conflict resolution commits with the owned noninteractive editor', (await gitRun(['commit', '--no-edit'])).ok)
await gitRun(['switch', '-c', 'cherry-source'])
await run('echo cherry>cherry.txt && git add cherry.txt && git commit -q -m cherry-source')
await gitRun(['switch', 'main'])
check('cherry-pick applies an existing real history commit', (await gitRun(['cherry-pick', 'cherry-source'])).ok && (await gitRun(['show', 'HEAD:cherry.txt'])).content.includes('cherry'))
check('revert makes a new inverse commit', (await gitRun(['revert', '--no-edit', 'HEAD'])).ok)
await gitRun(['switch', '-c', 'rebase-side'])
await run('echo rebase>rebase.txt && git add rebase.txt && git commit -q -m rebase-side')
await gitRun(['switch', 'main'])
await run('echo base>base.txt && git add base.txt && git commit -q -m rebase-base')
await gitRun(['switch', 'rebase-side'])
check('rebase rewrites a private branch onto the updated base', (await gitRun(['rebase', 'main'])).ok && (await gitRun(['log', '-3', '--oneline'])).content.includes('rebase-base'))
check('detached HEAD checkout and return survive command boundaries', (await gitRun(['switch', '--detach', 'HEAD~1'])).ok && (await gitRun(['status', '--short', '--branch'])).content.includes('HEAD') && (await gitRun(['switch', 'rebase-side'])).ok)
check('worktree reset retains coherent object history', (await gitRun(['reset', '--hard', 'HEAD~1'])).ok && (await gitRun(['fsck', '--connectivity-only'])).ok)
const beforeReject = hostGit('rev-parse', 'HEAD').trim()
const rejected = await gitHostAction('commit', { ...context, approve: async () => false })
check('rejecting the combined commit preserves host HEAD and private changes', rejected.writeback === 'rejected' && hostGit('rev-parse', 'HEAD').trim() === beforeReject && (await hostGitStatus(workspace, context.sessionId)).pendingMetadata)
const integrated = await workspaceAction('commit', context)
check('the common workspace commit routes imported Git through the same combined host transaction', integrated.writeback === 'committed' && !!integrated.git?.groupId && hostGit('branch', '--show-current').trim() === 'rebase-side')
const newProject = join(fixture, 'new-project'), newContext = { ...context, workspaceRoot: newProject, sessionId: 'new-session' }
await fs.mkdir(newProject); await fs.writeFile(join(newProject, 'new.txt'), 'new-host-file\n')
const initialized = await gitHostAction('init', newContext)
check('init creates an unborn private repository without a synthetic baseline or host .git', initialized.mode === 'new-repository' && !await fs.lstat(join(newProject, '.git')).catch(() => null))
const firstCommit = await executeTool('sandbox_git', { action: 'run', argv: ['add', '.'] }, newContext)
const firstHistory = await executeTool('sandbox_git', { action: 'run', argv: ['commit', '-m', 'first-real-commit'] }, newContext)
check('a new private repository stages existing project files and makes its first commit', firstCommit.ok && firstHistory.ok)
const firstPublish = await gitHostAction('commit', newContext)
check('first host publication creates and identity-binds .git before publishing the real first commit', firstPublish.writeback === 'committed' && !!firstPublish.groupId && (await fs.lstat(join(newProject, '.git/HEAD'))).isFile())
const linked = join(fixture, 'linked-worktree')
hostGit('worktree', 'add', '-q', '-b', 'linked-test', linked)
const linkedContext = { ...context, workspaceRoot: linked, sessionId: 'linked-session' }
check('linked worktree import binds both private and common Git directory identities', (await gitHostAction('import', linkedContext)).linkedWorktree === true)
await executeSandboxCommand('echo linked>linked.txt && git add linked.txt && git commit -q -m linked-commit', { ...linkedContext, runtimes: ['git'], workspaceMode: 'persistent' })
const linkedPublish = await gitHostAction('commit', linkedContext)
check('linked worktree publication commits its index/HEAD and shared objects/refs with separate native roots', linkedPublish.writeback === 'committed' && hostGit('log', '-1', '--oneline', 'linked-test').includes('linked-commit') && (await fs.readFile(join(linked, 'linked.txt'), 'utf8')).includes('linked'))
console.log(`SANDBOX_GIT_COMPLETE_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
console.log(`SANDBOX_GIT_COMPLETE_REPORT ${join(fixture, 'report.json')}`)
}
+182
View File
@@ -0,0 +1,182 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
import { resetSandboxGit, sandboxGitState } from '../src/main/sandbox/git-repository'
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { restoreTransaction } from '../src/main/sandbox/transactions'
import { sandboxOperation } from '../src/main/sandbox/maintenance'
if (process.platform !== 'win32') throw new Error('Git regression requires real Windows isolation')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-'))
const workspace = join(fixture, 'workspace space \u4e2d\u6587'), stateRoot = join(fixture, 'state'), home = join(fixture, 'host-home')
await fs.mkdir(workspace); await fs.mkdir(home)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64'), useBundledGit: false })
const source = process.env.YCODE_TEST_GIT_SOURCE ?? 'C:\\Program Files\\Git\\cmd\\git.exe'
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name)))
Object.assign(environment, { HOME: home, USERPROFILE: home, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' })
const hostGit = (...args: string[]): string => execFileSync(source, args, { cwd: workspace, env: environment, windowsHide: true, encoding: 'utf8', timeout: 15_000 })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
let approvals = 0
const context = { workspaceRoot: workspace, sessionId: 'git-session', runId: 'git-run', toolCallId: 'git-call', runtimes: ['git'], approve: async () => { approvals++; return true } }
async function run(command: string, overrides: Partial<typeof context> = {}) {
const result = await executeSandboxCommand(command, { ...context, ...overrides })
await fs.writeFile(join(fixture, 'last-result.json'), JSON.stringify(result, null, 2) + '\n')
return result
}
async function inventory(root: string): Promise<Record<string, string>> {
const result: Record<string, string> = {}
async function walk(path: string, prefix = ''): Promise<void> {
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
const name = `${prefix}${entry.name}`
if (entry.isDirectory()) await walk(join(path, entry.name), `${name}/`)
else result[name] = createHash('sha256').update(await fs.readFile(join(path, entry.name))).digest('hex')
}
}
await walk(root); return result
}
try {
await fs.writeFile(join(workspace, 'tracked.txt'), 'host committed\n')
await fs.writeFile(join(workspace, '.env'), 'DUMMY_HISTORICAL_SECRET_NOT_FOR_SANDBOX')
hostGit('init', '-q', '--template=', '--initial-branch=host-branch')
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'add', '--all')
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-q', '-m', 'HOST_HISTORY_MUST_NOT_BE_IMPORTED')
await fs.unlink(join(workspace, '.env'))
await fs.writeFile(join(workspace, 'tracked.txt'), 'current filtered snapshot\n')
hostGit('add', 'tracked.txt')
hostGit('config', 'credential.helper', '!echo HOST_CREDENTIAL_HELPER_MUST_NOT_RUN')
hostGit('config', 'core.fsmonitor', '!echo HOST_FSMONITOR_MUST_NOT_RUN')
const originalGit = await inventory(join(workspace, '.git'))
await fs.mkdir(join(workspace, 'nested space'))
await fs.writeFile(join(workspace, 'nested space/\u8def\u5f84.txt'), 'unicode baseline\n')
await fs.writeFile(join(workspace, 'binary.dat'), Buffer.from([0, 1, 255, 0, 32]))
await fs.writeFile(join(workspace, 'hook-script'), '#!/bin/sh\nexit 73\n')
await fs.writeFile(join(workspace, '.gitattributes'), 'crlf.txt text eol=lf\nencoded.txt working-tree-encoding=UTF-16LE filter=host-filter\n')
await fs.writeFile(join(workspace, 'crlf.txt'), 'raw CRLF baseline\r\n')
await fs.writeFile(join(workspace, 'encoded.txt'), 'raw snapshot bytes\n')
check('Git starts missing without inheriting the host installation', (await listSandboxToolchains()).find(item => item.kind === 'git')?.state === 'missing')
await assert.rejects(run('git --version'), /尚未注册/)
check('missing Git runtime fails before native launch', true)
const runtime = await importSandboxToolchain('git', source)
check('explicit Git import selects the x64 image and its DLL closure', runtime.state === 'registered' && runtime.features?.includes('git-local') && runtime.files! >= 7)
const isolatedImage = join(fixture, 'missing-dll/git.exe')
await fs.mkdir(join(fixture, 'missing-dll'))
await fs.copyFile(join(stateRoot, 'toolchains/objects', runtime.id!, 'files/git.exe'), isolatedImage)
await assert.rejects(importSandboxToolchain('git', isolatedImage), /ENOENT/)
check('an incomplete import preserves the previously registered Git runtime', (await listSandboxToolchains()).find(item => item.kind === 'git')?.id === runtime.id)
check('registered Git capability states the private repository boundary', (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'implemented' && item.detail.includes('沙盒专用')))
let result = await run('git --version && git status --porcelain=v1 && git diff --exit-code && git diff --cached --exit-code && git branch --show-current && git log -1 --oneline')
check('real isolated Git runs with a synthetic filtered baseline', result.ok && result.data.stdout.includes('git version') && result.data.stdout.includes('ycode-sandbox') && result.data.stdout.includes('filtered workspace snapshot') && !result.data.stdout.includes('HOST_HISTORY'))
check('read-only Git does not ask for approval or change the repository revision', approvals === 0 && result.data.git?.initialized === true && result.data.git.persistence === 'unchanged')
check('result and content explicitly distinguish sandbox state from host Git state', result.data.git?.mode === 'private-snapshot' && result.content.includes('不含宿主历史/索引') && result.data.toolchains?.[0].id === runtime.id)
const baseline = await sandboxGitState(workspace)
result = await run('git show "HEAD:nested space/\u8def\u5f84.txt" && git fsck --no-reflogs')
check('synthetic trees/index support nested Unicode names, spaces and binary objects', result.ok && result.data.stdout.includes('unicode baseline'))
result = await run('git diff --exit-code -- crlf.txt encoded.txt && git show HEAD:crlf.txt && git show HEAD:encoded.txt')
check('raw-byte baseline remains clean with CRLF and project encoding/filter attributes', result.ok && result.data.stdout.includes('raw CRLF baseline') && result.data.stdout.includes('raw snapshot bytes') && result.data.git?.persistence === 'unchanged')
result = await run('git status --porcelain=v1 && git log --all -p && git config --get-regexp "credential|fsmonitor|hooksPath"')
check('host history, deleted credentials and host configuration are absent', result.ok && !result.data.stdout.includes('DUMMY_HISTORICAL_SECRET') && !result.data.stdout.includes('HOST_CREDENTIAL') && !result.data.stdout.includes('HOST_FSMONITOR'))
check('repeated status/log preserves the exact private revision without approvals', (await sandboxGitState(workspace)).id === baseline.id && approvals === 0)
await fs.writeFile(join(workspace, 'tracked.txt'), 'edited outside after sandbox baseline\n')
result = await run('git status --short && git diff -- tracked.txt')
check('status and diff observe external worktree edits against the private baseline', result.ok && result.data.stdout.includes(' M tracked.txt') && result.data.stdout.includes('+edited outside after sandbox baseline') && approvals === 0)
result = await run('git add tracked.txt && git diff --cached --stat')
check('staging is approval-bound and persists independently of worktree writeback', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'unchanged' && approvals === 1)
result = await run('git diff --cached -- tracked.txt && git commit -q -m local-sandbox-commit')
check('a later command sees the staged index and creates a local commit', result.ok && result.data.stdout.includes('+edited outside after sandbox baseline') && result.data.git?.persistence === 'saved' && approvals === 2)
check('completed Git persistence can be queried from the durable run outcome', (await sandboxOperation(result.data.runId)).git?.revision === result.data.git?.revision)
result = await run('git log -2 --oneline && git status --porcelain=v1 && git fsck --no-reflogs')
check('private commits and valid objects survive command boundaries', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.stdout.includes('filtered workspace snapshot') && result.data.git?.persistence === 'unchanged')
await importSandboxToolchain('node', await fs.realpath(process.execPath))
result = await run('node --require "%YCODE_SANDBOX_PROCESS%" -e "console.log(require(\'child_process\').execFileSync(\'git\',[\'log\',\'-1\',\'--oneline\'],{encoding:\'utf8\'}))"', { runtimes: ['node', 'git'] })
check('a controlled Node subprocess inherits the private Git runtime and repository', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.git?.persistence === 'unchanged')
const beforeReject = await sandboxGitState(workspace)
result = await run('git branch rejected-branch', { approve: async () => false })
check('rejected metadata-only approval leaves the active revision unchanged', !result.ok && result.data.git?.persistence === 'rejected' && (await sandboxGitState(workspace)).id === beforeReject.id)
result = await run('git branch --list rejected-branch')
check('a rejected branch does not appear in later commands', result.ok && !result.data.stdout.trim())
const beforeFailure = await sandboxGitState(workspace)
result = await run('git branch failed-command-branch && exit /b 7')
check('failed commands discard private metadata changes', !result.ok && result.data.exitCode === 7 && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
result = await executeSandboxCommand('git branch timed-out-branch && echo GIT_MUTATION_BEFORE_TIMEOUT && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, timeoutMs: 1200 })
check('a real native timeout discards already-created private branch metadata', !result.ok && result.data.status === 'timeout' && result.data.stdout.includes('GIT_MUTATION_BEFORE_TIMEOUT') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
const controller = new AbortController(), cancellation = setTimeout(() => controller.abort(), 2500)
try { result = await executeSandboxCommand('git branch cancelled-branch && echo GIT_MUTATION_BEFORE_CANCEL && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, signal: controller.signal }) }
finally { clearTimeout(cancellation) }
check('cancellation discards private mutations and releases the repository lock', !result.ok && result.data.status === 'cancelled' && result.data.stdout.includes('GIT_MUTATION_BEFORE_CANCEL') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
result = await run('git switch -c feature-local && echo sandbox-file>created.txt && git add created.txt && git commit -q -m approved-file-and-commit')
check('approved worktree changes and private branch/commit are both saved', result.ok && result.data.writeback === 'committed' && result.data.git?.persistence === 'saved' && (await fs.readFile(join(workspace, 'created.txt'), 'utf8')).trim() === 'sandbox-file')
check('file writeback retains an independently recoverable transaction', !!result.data.transactionId && !!result.data.authorizationId)
await restoreTransaction(result.data.transactionId!, workspace)
result = await run('git status --short && git branch --show-current')
check('file recovery becomes a visible worktree deletion without rewriting private history', result.ok && result.data.stdout.includes(' D created.txt') && result.data.stdout.includes('feature-local'))
const beforeConflict = await sandboxGitState(workspace)
await assert.rejects(run('git branch concurrent-conflict', { approve: async () => { await fs.appendFile(join(workspace, 'tracked.txt'), 'parallel edit\n'); return true } }), /Git.*期间变化/)
check('an edit during metadata approval rejects stale private state', (await sandboxGitState(workspace)).id === beforeConflict.id)
result = await run('mkdir "%GIT_DIR%\\hooks" && copy /y hook-script "%GIT_DIR%\\hooks\\pre-commit" >nul && git config core.hooksPath "%GIT_DIR%\\hooks" && git config core.fsmonitor "echo BAD_FSMONITOR" && git config credential.helper "echo BAD_CREDENTIAL" && git commit --allow-empty -q -m hooks-disabled')
check('default hooks and fsmonitor remain disabled even after private repo config changes', result.ok && result.data.git?.persistence === 'saved')
result = await run('git config --get core.fsmonitor && git config --get core.hooksPath && git ls-remote https://example.invalid/test.git')
check('remote transport is denied without prompting for host credentials', !result.ok && /not allowed|not a git command|transport/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_CREDENTIAL'))
const outside = join(fixture, 'outside.txt')
await fs.writeFile(outside, 'HOST_OUTSIDE_MUST_NOT_BE_READ')
result = await run(`git hash-object "${outside}"`)
check('native Git cannot read an ordinary file outside its snapshot', !result.ok && /denied|not permitted/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_OUTSIDE'))
result = await run('powershell.exe -NoProfile -NonInteractive -Command "try {[IO.File]::WriteAllText((Join-Path $env:YCODE_SANDBOX_ROOT \'tools/git/git.exe\'),\'bad\'); exit 9} catch {[Console]::WriteLine(\'GIT_RUNTIME_READONLY\')}"')
check('AppContainer ACLs deny modification of the staged Git image', result.ok && result.data.stdout.includes('GIT_RUNTIME_READONLY'))
const otherWorkspace = join(fixture, 'other-workspace'); await fs.mkdir(otherWorkspace)
await fs.writeFile(join(otherWorkspace, '.git'), 'gitdir: C:/host-repository-must-not-be-opened\n')
result = await run('git log --all --oneline', { workspaceRoot: otherWorkspace })
check('another workspace receives a separate baseline and ignores host worktree gitfiles', result.ok && result.data.git?.initialized && !result.data.stdout.includes('local-sandbox-commit') && !result.data.stdout.includes('hooks-disabled'))
await assert.rejects(importSandboxDependencies(workspace, 'git'), /node\/python/)
check('Git runtime is not accepted as a Node/Python dependency-cache kind', true)
const workspaceKey = createHash('sha256').update((await fs.realpath(workspace)).toLowerCase()).digest('hex')
const privateRoot = join(stateRoot, 'git', workspaceKey)
const active = JSON.parse(await fs.readFile(join(privateRoot, 'active.json'), 'utf8'))
const privateFile = join(privateRoot, 'objects', active.id, 'files/HEAD'), privateBytes = await fs.readFile(privateFile)
await fs.appendFile(privateFile, 'tampered')
await assert.rejects(run('git status'), /metadata digest mismatch/)
check('tampered saved metadata fails closed before execution', true)
await fs.writeFile(privateFile, privateBytes)
const hardlinkTarget = join(fixture, 'git-hardlink.txt'); await fs.writeFile(hardlinkTarget, privateBytes)
await fs.unlink(privateFile); await fs.link(hardlinkTarget, privateFile)
await assert.rejects(run('git status'), /metadata contains a link/)
check('saved metadata hardlinks are rejected even when content digests match', true)
await fs.unlink(privateFile); await fs.writeFile(privateFile, privateBytes)
const runtimeIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
const runtimeFile = join(stateRoot, 'toolchains/objects', runtimeIdentity.id, 'files/git.exe'), runtimeBytes = await fs.readFile(runtimeFile)
await fs.appendFile(runtimeFile, 'tampered')
await assert.rejects(run('git status'), /摘要不匹配/)
check('tampered Git runtime fails before any metadata or worktree access', true)
await fs.writeFile(runtimeFile, runtimeBytes)
await assert.rejects(run('if not exist "%GIT_DIR%\\objects\\info" mkdir "%GIT_DIR%\\objects\\info"\r\necho C:/outside>"%GIT_DIR%\\objects\\info\\alternates"'), /unsafe sandbox Git metadata path/)
check('external object alternates cannot be persisted', true)
await assert.rejects(run('echo stale>"%GIT_DIR%\\index.lock"'), /unsafe sandbox Git metadata path/)
check('incomplete Git lock files are rejected instead of poisoning the next command', true)
const policy = await readSandboxPolicy()
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['tracked.txt'] }, workspaceRoot: workspace })
await assert.rejects(run('git show HEAD:tracked.txt'), /policy changed/)
check('newly denied paths cannot leak through an older private history', true)
await resetSandboxGit(workspace)
result = await run('git ls-files && git log -1 --oneline')
check('explicit reset creates a new filtered baseline after policy changes', result.ok && result.data.git?.initialized && !result.data.stdout.includes('tracked.txt') && result.data.stdout.includes('filtered workspace snapshot'))
check('reset archives earlier revisions without mutating host Git', (await fs.readdir(privateRoot)).some(name => name.startsWith('archived-')) && JSON.stringify(await inventory(join(workspace, '.git'))) === JSON.stringify(originalGit))
check('Git metadata remains outside the host worktree and policy retains network deny', !(await fs.stat(join(workspace, 'repository')).catch(() => null)) && (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('tracked.txt'))
const denyPolicy = await readSandboxPolicy()
await updateSandboxPolicy({ expectedRevision: denyPolicy.revision, rules: { writeback: 'deny' }, workspaceRoot: otherWorkspace })
await resetSandboxGit(otherWorkspace)
result = await run('git status --short', { workspaceRoot: otherWorkspace })
const denyBaseline = await sandboxGitState(otherWorkspace)
await assert.rejects(run('git branch forbidden-by-policy', { workspaceRoot: otherWorkspace }), /禁止写回/)
check('writeback-deny policy also refuses private repository publication', result.ok && (await sandboxGitState(otherWorkspace)).id === denyBaseline.id)
console.log(`SANDBOX_GIT_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, scope: 'Real AppContainer Git for Windows and private filtered repository. No host Git history/index writeback or remote transport is supported.' }, null, 2) + '\n')
console.log(`SANDBOX_GIT_REPORT ${join(fixture, 'report.json')}`)
}
+223
View File
@@ -0,0 +1,223 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { createServer, type ServerResponse, type IncomingMessage } from 'node:http'
import { createServer as createTlsServer } from 'node:https'
import { spawn, execFileSync } from 'node:child_process'
import { generateKeyPairSync } from 'node:crypto'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { gitHostAction } from '../src/main/sandbox/git-host-service'
import { gitRemoteAction, gitRemoteOperation, reconcileGitRemote } from '../src/main/sandbox/git-remote'
import { importGitCredential, listGitCredentials, removeGitCredential, gitRemoteUrl } from '../src/main/sandbox/git-credentials'
import { executeTool } from '../src/main/agent/tools'
import { workspaceAction } from '../src/main/sandbox/workspace-service'
import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { gitRemoteEndpoint } from '../src/main/sandbox/git-credentials'
const require = createRequire(import.meta.url)
const { Server: SshServer } = require(resolve('scripts/fixtures/git-transport/node_modules/ssh2'))
const selfsigned = require(resolve('scripts/fixtures/git-transport/node_modules/selfsigned'))
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-remote-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state'), remote = join(fixture, 'remote.git')
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files')
const environment = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' }
const prefix = ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid']
const hostGit = (...args: string[]): string => execFileSync(git, [...prefix, ...args], { cwd: workspace, env: environment, encoding: 'utf8', windowsHide: true })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
let approvals = 0, httpRequests = 0, sshConnections = 0, requireAuth = false, disconnectPush = false, holdResponses = false
let onHeldRequest: (() => void) | undefined
const heldResponses = new Set<ServerResponse>(), remotePaths = new Map([['/repo.git', remote]])
const context = { workspaceRoot: workspace, sessionId: 'remote-session', runId: 'remote-run', toolCallId: 'remote-call', approve: async () => { approvals++; return true } }
const run = (argv: string[]) => executeTool('sandbox_git', { action: 'run', argv }, context)
const children = new Set<ReturnType<typeof spawn>>()
function serve(request: IncomingMessage, response: ServerResponse): void {
httpRequests++
if (holdResponses) { heldResponses.add(response); response.once('close', () => heldResponses.delete(response)); onHeldRequest?.(); return }
if (requireAuth && request.headers.authorization !== `Basic ${Buffer.from('fixture:DUMMY_GIT_TOKEN_97832').toString('base64')}`) { response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }); response.end(); return }
const url = new URL(request.url!, 'http://fixture.invalid'), service = request.method === 'GET' ? url.searchParams.get('service') : url.pathname.split('/').at(-1)
const repository = remotePaths.get('/' + url.pathname.split('/')[1])
if (!repository || !['git-upload-pack', 'git-receive-pack'].includes(service ?? '')) { response.writeHead(404); response.end(); return }
const advertise = request.method === 'GET', args = [...prefix, service!.slice(4), '--stateless-rpc', ...(advertise ? ['--advertise-refs'] : []), repository]
const child = spawn(git, args, { env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); children.add(child)
child.stderr.on('data', () => undefined); child.on('error', error => response.destroy(error)); child.on('close', () => children.delete(child))
response.writeHead(200, { 'Content-Type': `application/x-${service}-${advertise ? 'advertisement' : 'result'}`, 'Cache-Control': 'no-cache' })
if (advertise) { const header = Buffer.from(`# service=${service}\n`); response.write(Buffer.from((header.length + 4).toString(16).padStart(4, '0'))); response.write(header); response.write('0000'); child.stdin.end() }
else request.pipe(child.stdin)
if (disconnectPush && service === 'git-receive-pack' && !advertise) { child.stdout.resume(); child.once('close', () => response.destroy()) }
else child.stdout.pipe(response)
child.stdin.on('error', () => undefined)
}
const http = createServer(serve), pems = selfsigned.generate([{ name: 'commonName', value: '127.0.0.1' }], { days: 2, keySize: 2048, algorithm: 'sha256', extensions: [
{ name: 'basicConstraints', cA: true }, { name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', serverAuth: true }, { name: 'subjectAltName', altNames: [{ type: 7, ip: '127.0.0.1' }] }
] })
const https = createTlsServer({ key: pems.private, cert: pems.cert }, serve)
const listen = (server: any): Promise<number> => new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => done(server.address().port)) })
function sshPublic(jwk: JsonWebKey): Buffer {
const field = (data: Buffer): Buffer => { const header = Buffer.alloc(4); header.writeUInt32BE(data.length); return Buffer.concat([header, data]) }
const integer = (text: string): Buffer => { const data = Buffer.from(text, 'base64url'); return field(data[0] & 128 ? Buffer.concat([Buffer.from([0]), data]) : data) }
return Buffer.concat([field(Buffer.from('ssh-rsa')), integer(jwk.e!), integer(jwk.n!)])
}
const hostKey = generateKeyPairSync('rsa', { modulusLength: 2048 }), clientKey = generateKeyPairSync('rsa', { modulusLength: 2048 })
const hostPem = hostKey.privateKey.export({ type: 'pkcs1', format: 'pem' }).toString(), clientPem = clientKey.privateKey.export({ type: 'pkcs1', format: 'pem' }).toString()
const clientPublic = sshPublic(clientKey.publicKey.export({ format: 'jwk' })), clients = new Set<any>()
const ssh = new SshServer({ hostKeys: [hostPem] }, (client: any) => {
sshConnections++; clients.add(client); client.on('error', () => undefined); client.on('close', () => clients.delete(client))
client.on('authentication', (auth: any) => { if (auth.method === 'publickey' && auth.key.data.equals(clientPublic)) auth.accept(); else auth.reject() })
client.on('ready', () => client.on('session', (accept: any) => accept().on('exec', (accept: any, reject: any, info: any) => {
const match = /^git-(upload-pack|receive-pack) '\/repo\.git'$/.exec(info.command)
if (!match) { reject(); return }
const stream = accept(), child = spawn(git, [...prefix, match[1], remote], { env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
children.add(child); stream.pipe(child.stdin); child.stdout.pipe(stream, { end: false }); child.stderr.pipe(stream.stderr, { end: false }); child.stdin.on('error', () => undefined)
child.once('close', code => { children.delete(child); stream.exit(code ?? 1); stream.end() })
})))
})
try {
hostGit('init', '-q', '--template=', '--initial-branch=main'); await fs.writeFile(join(workspace, 'file.txt'), 'remote-base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'remote-base')
hostGit('init', '--bare', '-q', '--template=', '--initial-branch=main', remote)
hostGit('-c', 'protocol.file.allow=always', 'push', '-q', remote, 'main')
await gitHostAction('import', context)
const httpPort = await listen(http), tlsPort = await listen(https), sshPort = await listen(ssh)
const httpUrl = `http://127.0.0.1:${httpPort}/repo.git`, httpsUrl = `https://127.0.0.1:${tlsPort}/repo.git`, sshUrl = `ssh://fixture@127.0.0.1:${sshPort}/repo.git`
const beforeDeny = httpRequests
let result = await gitRemoteAction({ action: 'ls-remote', url: httpUrl }, { ...context, approve: async () => false })
check('remote rejection sends no network requests', result.state === 'denied' && httpRequests === beforeDeny)
result = await gitRemoteAction({ action: 'ls-remote', url: httpUrl }, context)
check('approved smart HTTP discovery uses the bundled transport helper', result.state === 'completed' && result.stdout!.includes('refs/heads/main'))
result = await gitRemoteAction({ action: 'fetch', url: httpUrl, branch: 'main' }, context)
check('approved fetch persists remote tracking refs without host metadata writes', result.state === 'completed' && !!result.revision && !hostGit('branch', '-r').includes('origin/main'))
check('isolated commands observe the fetched tracking refs', (await run(['log', '-1', '--oneline', 'refs/remotes/origin/main'])).content.includes('remote-base'))
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl }, context)
check('HTTPS refuses an untrusted self-signed server without an explicit CA', result.state === 'failed' && /certificate|SSL|schannel/i.test(result.reason ?? ''))
const credential = await importGitCredential(httpsUrl, { type: 'https', username: 'fixture', secret: 'DUMMY_GIT_TOKEN_97832', caCertificate: pems.cert })
requireAuth = true
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl, credentialId: credential.id }, context)
check('HTTPS authenticates using an exact-remote DPAPI credential and verifies the explicit CA', result.state === 'completed' && result.stdout!.includes('refs/heads/main'))
const stored = await fs.readFile(join(stateRoot, 'git-credentials', `${credential.id}.json`), 'utf8')
check('credential records and result queries contain no plaintext token', !stored.includes('DUMMY_GIT_TOKEN_97832') && !JSON.stringify(await gitRemoteOperation(context, result.operationId)).includes('DUMMY_GIT_TOKEN_97832'))
check('credential listing exposes metadata only', (await listGitCredentials()).some(value => value.id === credential.id) && !JSON.stringify(await listGitCredentials()).includes('DUMMY_GIT_TOKEN_97832'))
const beforeScope = httpRequests
await assert.rejects(gitRemoteAction({ action: 'ls-remote', url: httpsUrl.replace('repo.git', 'other.git'), credentialId: credential.id }, context), /scoped/)
check('credential scope mismatch is rejected before any connection', httpRequests === beforeScope)
const revokedCredential = await importGitCredential(httpsUrl, { type: 'https', username: 'fixture', secret: 'DUMMY_REVOKED_TOKEN', caCertificate: pems.cert })
const beforeRevoke = httpRequests
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl, credentialId: revokedCredential.id }, { ...context, approve: async () => { await removeGitCredential(revokedCredential.id); return true } })
check('revoking a credential during approval prevents the first connection', result.state === 'failed' && httpRequests === beforeRevoke)
requireAuth = false
await run(['commit', '--allow-empty', '-m', 'remote-push-commit'])
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'] }, context)
check('approved atomic push publishes exact intended refs', result.state === 'completed' && result.writeback === 'remote-committed' && hostGit('--git-dir=' + remote, 'log', '-1', '--oneline', 'feature').includes('remote-push-commit'))
check('remote result survives a fresh owned query', (await gitRemoteOperation(context, result.operationId)).writeback === 'remote-committed')
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/dry-run'], dryRun: true }, context)
check('push dry-run performs negotiation without publishing a ref', result.state === 'completed' && result.writeback === 'not-applied' && !hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/dry-run').trim())
const remoteTree = hostGit('--git-dir=' + remote, 'rev-parse', 'main^{tree}').trim()
const divergent = hostGit('--git-dir=' + remote, 'commit-tree', remoteTree, '-m', 'divergent-remote').trim()
hostGit('--git-dir=' + remote, 'update-ref', 'refs/heads/feature', divergent)
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'], forceWithLease: { 'refs/heads/feature': divergent } }, context)
check('force-with-lease permits a non-fast-forward update only with the approved old object', result.state === 'completed' && hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim() !== divergent)
const protectedFeature = hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim()
await run(['commit', '--allow-empty', '-m', 'stale-lease-attempt'])
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'], forceWithLease: { 'refs/heads/feature': divergent } }, context)
check('a stale force-with-lease is refused without changing the remote ref', result.state !== 'completed' && /stale|rejected/i.test(result.reason ?? '') && hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim() === protectedFeature)
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: [':refs/heads/feature'] }, context)
check('explicit approved deletion removes only the named remote ref', result.state === 'completed' && !hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/feature').trim() && !!hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/main').trim())
await run(['remote', 'add', 'origin', httpUrl]); await run(['config', 'branch.main.remote', 'origin']); await run(['config', 'branch.main.merge', 'refs/heads/main'])
await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/main'] }, context)
const advanceRemote = (message: string): string => {
const parent = hostGit('--git-dir=' + remote, 'rev-parse', 'main').trim(), next = hostGit('--git-dir=' + remote, 'commit-tree', remoteTree, '-p', parent, '-m', message).trim()
hostGit('--git-dir=' + remote, 'update-ref', 'refs/heads/main', next); return next
}
const fastForward = advanceRemote('remote-fast-forward')
let pulled = await executeTool('sandbox_git', { action: 'pull' }, context)
check('pull derives the current upstream and fast-forwards only the private draft by default', pulled.ok && (await run(['rev-parse', 'HEAD'])).content.includes(fastForward) && !hostGit('log', '-1', '--oneline').includes('remote-fast-forward'))
await run(['commit', '--allow-empty', '-m', 'local-divergence'])
const privateHead = (await run(['rev-parse', 'HEAD'])).content
advanceRemote('remote-divergence')
pulled = await executeTool('sandbox_git', { action: 'pull' }, context)
check('default pull refuses divergent history and preserves the local commit', !pulled.ok && (await run(['rev-parse', 'HEAD'])).content === privateHead)
pulled = await executeTool('sandbox_git', { action: 'pull', pullStrategy: 'rebase' }, context)
check('explicit pull rebase integrates divergence inside the isolated draft', pulled.ok && (await run(['log', '-3', '--oneline'])).content.includes('remote-divergence'))
await run(['commit', '--allow-empty', '-m', 'local-merge-divergence']); advanceRemote('remote-merge-divergence')
pulled = await executeTool('sandbox_git', { action: 'pull', pullStrategy: 'merge' }, context)
check('explicit pull merge retains both histories with a real merge commit', pulled.ok && /[a-f0-9]{40} [a-f0-9]{40}/.test((await run(['log', '-1', '--format=%P'])).content))
const sshCredential = await importGitCredential(sshUrl, { type: 'ssh', privateKey: clientPem, knownHosts: `[127.0.0.1]:${sshPort} ssh-rsa ${sshPublic(hostKey.publicKey.export({ format: 'jwk' })).toString('base64')}\n` })
result = await gitRemoteAction({ action: 'ls-remote', url: sshUrl, credentialId: sshCredential.id }, context)
check('bundled SSH authenticates with an explicit key and pinned host key', result.state === 'completed' && result.stdout!.includes('refs/heads/main') && sshConnections > 0)
result = await gitRemoteAction({ action: 'fetch', url: sshUrl, credentialId: sshCredential.id, branch: 'main' }, context)
check('SSH fetch persists objects through the same controlled mirror', result.state === 'completed' && !!result.revision)
result = await gitRemoteAction({ action: 'push', url: sshUrl, credentialId: sshCredential.id, refspecs: ['HEAD:refs/heads/ssh-pushed'] }, context)
check('SSH push updates only the approved reference', result.state === 'completed' && hostGit('--git-dir=' + remote, 'rev-parse', 'ssh-pushed').trim().length === 40)
const badPin = await importGitCredential(sshUrl, { type: 'ssh', privateKey: clientPem, knownHosts: `[127.0.0.1]:${sshPort} ssh-rsa ${clientPublic.toString('base64')}\n` })
result = await gitRemoteAction({ action: 'ls-remote', url: sshUrl, credentialId: badPin.id }, context)
check('a mismatched SSH host key fails closed', result.state === 'failed' && /host key|identification/i.test(result.reason ?? ''))
const cloneWorkspace = join(fixture, 'clone-project'), cloneContext = { ...context, workspaceRoot: cloneWorkspace, sessionId: 'clone-session' }
await fs.mkdir(cloneWorkspace)
const cloned = await executeTool('sandbox_git', { action: 'clone', url: httpUrl }, cloneContext)
await fs.writeFile(join(fixture, 'clone-result.json'), JSON.stringify(cloned, null, 2))
check('clone fetches and checks out the default branch inside an empty draft', cloned.ok && !await fs.lstat(join(cloneWorkspace, 'file.txt')).catch(() => null))
check('cloned worktree bytes are readable through isolated Git commands', (await executeTool('sandbox_git', { action: 'run', argv: ['show', 'HEAD:file.txt'] }, cloneContext)).content.includes('remote-base'))
check('ordinary clone fetches the other remote branches as well as the default branch', (await executeTool('sandbox_git', { action: 'run', argv: ['branch', '-r'] }, cloneContext)).content.includes('origin/ssh-pushed'))
const clonePublished = await workspaceAction('commit', cloneContext)
check('clone publication creates a real host repository with its original remote ancestry', clonePublished.writeback === 'committed' && !!clonePublished.git?.groupId && (await fs.readFile(join(cloneWorkspace, 'file.txt'), 'utf8')).includes('remote-base'))
const maintenanceBundle = join(fixture, 'maintenance.mjs'), cliProject = join(fixture, 'cli-project'), cliRequest = join(fixture, 'cli-request.json')
await require('esbuild').build({ entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: maintenanceBundle, logLevel: 'silent' })
await fs.mkdir(cliProject)
const cli = (command: string): Promise<any> => new Promise((done, reject) => {
const child = spawn(process.execPath, [maintenanceBundle, command, '--workspace', cliProject, '--session', 'cli-session', '--state-root', stateRoot, '--request', cliRequest, '--approve'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = '', stderr = ''; const timer = setTimeout(() => child.kill(), 120000)
child.stdout.on('data', data => { stdout += data }); child.stderr.on('data', data => { stderr += data })
child.once('error', reject); child.once('close', code => { clearTimeout(timer); if (code) reject(new Error(`Git maintenance failed (${code}): ${stderr}`)); else { try { done(JSON.parse(stdout)) } catch (error) { reject(error) } } })
})
await fs.writeFile(cliRequest, JSON.stringify({ action: 'clone', url: httpUrl }))
check('maintenance clone performs the same isolated checkout as the conversation tool', (await cli('git-remote')).ok)
await fs.writeFile(cliRequest, JSON.stringify(['show', 'HEAD:file.txt']))
const cliRead = await cli('git-run')
check('a fresh maintenance process sees the cloned history and retained draft', cliRead.ok && cliRead.content.includes('remote-base'))
const emptyRemote = join(fixture, 'empty.git'), emptyProject = join(fixture, 'empty-project')
hostGit('init', '--bare', '-q', '--template=', '--initial-branch=main', emptyRemote); remotePaths.set('/empty.git', emptyRemote); await fs.mkdir(emptyProject)
const emptyClone = await executeTool('sandbox_git', { action: 'clone', url: `http://127.0.0.1:${httpPort}/empty.git` }, { ...context, workspaceRoot: emptyProject, sessionId: 'empty-clone' })
check('cloning an empty remote retains an unborn private repository', emptyClone.ok && !await fs.lstat(join(emptyProject, '.git')).catch(() => null))
const shaRemote = join(fixture, 'sha256.git'), shaProject = join(fixture, 'sha256-project')
hostGit('init', '--bare', '-q', '--object-format=sha256', '--template=', '--initial-branch=main', shaRemote)
const shaTree = hostGit('--git-dir=' + shaRemote, 'hash-object', '-w', '-t', 'tree', '--stdin').trim(), shaCommit = hostGit('--git-dir=' + shaRemote, 'commit-tree', shaTree, '-m', 'sha256-history').trim()
hostGit('--git-dir=' + shaRemote, 'update-ref', 'refs/heads/main', shaCommit); remotePaths.set('/sha256.git', shaRemote); await fs.mkdir(shaProject)
const shaContext = { ...context, workspaceRoot: shaProject, sessionId: 'sha256-clone' }
const shaClone = await executeTool('sandbox_git', { action: 'clone', url: `http://127.0.0.1:${httpPort}/sha256.git` }, shaContext)
check('clone detects and preserves a real SHA-256 repository', shaClone.ok && shaCommit.length === 64 && (await executeTool('sandbox_git', { action: 'run', argv: ['rev-parse', 'HEAD'] }, shaContext)).content.includes(shaCommit))
await removeGitCredential(credential.id)
check('credential removal revokes subsequent lookup', !(await listGitCredentials()).some(value => value.id === credential.id))
for (const bad of ['ext::echo pwned', 'file:///C:/private.git', 'https://token@example.invalid/repo.git', 'http://example.invalid/repo.git', 'ssh://git@host/repo;bad']) assert.throws(() => gitRemoteUrl(bad))
check('remote URL parsing rejects helper execution, inline secrets and unsafe protocols', true)
const scp = 'fixture@example.invalid:relative/repo.git'
check('SCP remotes retain relative transport paths while credentials use an unambiguous scope', gitRemoteUrl(scp).pathname === '/~/relative/repo.git' && gitRemoteEndpoint(scp, gitRemoteUrl(scp)) === scp)
disconnectPush = true
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/lost-reply'] }, context)
check('lost push acknowledgement remains unknown after real remote refs changed', result.state === 'unknown' && result.writeback === 'unknown' && hostGit('--git-dir=' + remote, 'rev-parse', 'lost-reply').trim().length === 40)
check('durable query never infers lost push success or retries it', (await gitRemoteOperation(context, result.operationId)).state === 'unknown')
disconnectPush = false
const reconciled = await reconcileGitRemote(context, result.operationId)
check('explicit reconciliation confirms current remote refs without repeating the push', reconciled.state === 'completed' && reconciled.reason!.includes('observing the current remote refs'))
const heldOperation = async (cancel: () => Promise<void>, signal?: AbortSignal) => {
holdResponses = true
let timer: ReturnType<typeof setTimeout> | undefined
const requested = new Promise<void>((done, reject) => { onHeldRequest = done; timer = setTimeout(() => reject(new Error('Git did not reach the real transport cancellation boundary')), 15000) })
const operation = gitRemoteAction({ action: 'ls-remote', url: httpUrl }, { ...context, signal })
try { await requested; await cancel(); return await operation }
finally { clearTimeout(timer); holdResponses = false; onHeldRequest = undefined; for (const response of heldResponses) response.destroy() }
}
const controller = new AbortController()
const cancelled = await heldOperation(async () => controller.abort(), controller.signal)
check('cancelling an active real Git transport terminates its Job and saves the failed read outcome', cancelled.state === 'failed' && /cancel/i.test(cancelled.reason ?? '') && (await gitRemoteOperation(context, cancelled.operationId)).state === 'failed')
const revoked = await heldOperation(async () => { const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['revoked-by-test.txt'] } }) })
check('policy revocation terminates an in-flight trusted Git transport', revoked.state === 'failed' && /policy|策略/i.test(revoked.reason ?? ''))
console.log(`SANDBOX_GIT_REMOTE_PASS ${checks.length}/${checks.length}`)
} finally {
for (const child of children) child.kill()
for (const client of clients) client.end()
http.closeAllConnections(); https.closeAllConnections(); http.close(); https.close(); ssh.close()
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, approvals, httpRequests, sshConnections }, null, 2) + '\n')
console.log(`SANDBOX_GIT_REMOTE_REPORT ${join(fixture, 'report.json')}`)
}
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync, spawn } from 'node:child_process'
import { randomBytes, randomUUID } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { join, resolve, relative, isAbsolute } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service'
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { hostGitRoot, loadHostGitBinding } from '../src/main/sandbox/git-host-source'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { executeTool } from '../src/main/agent/tools'
import { modeAllowsTool } from '../src/main/agent/mode-policy'
import { workspaceAction } from '../src/main/sandbox/workspace-service'
import { cleanupGitControls, createGitControl } from '../src/main/sandbox/git-control'
import { withSandboxExecution } from '../src/main/sandbox/execution'
import { PersistentWorkspace, workspaceKey } from '../src/main/sandbox/workspace'
if (process.argv[2] === '--worker-commit') {
configureSandbox({ stateRoot: process.argv[4], resourceDirectory: resolve('resources/sandbox/win32-x64') })
await gitHostAction('commit', { workspaceRoot: process.argv[3], sessionId: process.argv[5], approve: async () => true })
process.exit(0)
}
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-safety-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files')
const env = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' }
const hostGit = (...args: string[]): string => execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args], { cwd: workspace, env, encoding: 'utf8', windowsHide: true })
const context = { workspaceRoot: workspace, sessionId: 'safety-session', runId: 'safety-run', toolCallId: 'safety-call', approve: async () => true }
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent' })
async function savedJson(path: string): Promise<any> { return fs.readFile(path, 'utf8').then(JSON.parse, error => { if (error.code === 'ENOENT') return null; throw error }) }
try {
hostGit('init', '-q', '--template=', '--initial-branch=main')
await fs.writeFile(join(workspace, 'file.txt'), 'base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'base')
const rejected = await gitHostAction('import', { ...context, approve: async () => false })
check('denied history import creates no active host binding', rejected.writeback === 'rejected' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
let prompt = ''
await assert.rejects(gitHostAction('import', { ...context, approve: async title => { prompt = title; hostGit('update-ref', 'refs/heads/concurrent-import', 'HEAD'); return true } }), /changed while import/)
check('import approval is invalidated by an external reference change', prompt.includes('历史可能包含') && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
await gitHostAction('import', context)
const priorRevision = (await workspaceAction('status', context)).revision!
let releaseQueue!: () => void, enteredQueue!: () => void
const queueEntered = new Promise<void>(done => { enteredQueue = done }), queueRelease = new Promise<void>(done => { releaseQueue = done })
const queuedMutation = withSandboxExecution(workspace, undefined, async () => {
enteredQueue(); await queueRelease
const policy = await effectiveSandboxPolicy(workspace), binding = await loadHostGitBinding(policy, context.sessionId)
const draft = await PersistentWorkspace.open(policy, context.sessionId, binding!.layout.projectIdentity)
try { await fs.writeFile(join(workspace, 'queued-external.txt'), 'queued-host-edit\n'); await draft.synchronize() } finally { await draft.close() }
})
await queueEntered
const staleDiscard = workspaceAction('discard', context, priorRevision)
const staleDiscardCheck = assert.rejects(staleDiscard, /版本已变化/)
await new Promise(done => setTimeout(done, 100)); releaseQueue(); await queuedMutation; await staleDiscardCheck
check('workspace actions recheck their expected revision after waiting in the shared Git queue', (await workspaceAction('status', context)).revision !== priorRevision)
const nativeTool = await executeTool('sandbox_git', { action: 'run', argv: ['status', '--short'] }, { ...context, permissionMode: 'full-access' })
check('explicit Git tool keeps native confinement in a full-access context', nativeTool.ok && (nativeTool.data as { isolated: boolean }).isolated)
check('model tool rejects forged project/session ownership', !(await executeTool('sandbox_git', { action: 'status', workspaceRoot: fixture, sessionId: 'other' }, context)).ok)
check('planning mode cannot mutate or import Git state', !modeAllowsTool('plan', 'sandbox_git') && !modeAllowsTool('ask', 'sandbox_git'))
const defaultResult = await executeSandboxCommand('git log -1 --oneline', { ...context, runtimes: ['git'] })
check('ordinary Git runtime calls select the imported session mirror after import', defaultResult.ok && defaultResult.data.git?.mode === 'host-mirror' && defaultResult.data.workspace?.mode === 'persistent')
await run('echo pending>file.txt && git add file.txt && git commit -q -m pending')
const original = hostGit('rev-parse', 'HEAD').trim()
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { hostGit('update-ref', 'refs/heads/raced', 'HEAD'); return true } }), /changed while commit approval/)
check('host metadata edits during commit approval prevent every host file write', hostGit('rev-parse', 'HEAD').trim() === original && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n')
const refreshed = await executeSandboxCommand('git show-ref raced && git log -1 --oneline', { ...context, runtimes: ['git'], workspaceMode: 'persistent' })
check('independent external reference updates merge without losing private commits', refreshed.ok && refreshed.data.stdout.includes('raced') && refreshed.data.stdout.includes('pending'))
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.chmod(join(workspace, '.git/index'), 0o444); return true } }), /read.only|readonly|原生|Transaction/i)
await fs.chmod(join(workspace, '.git/index'), 0o666)
check('a metadata-phase failure compensates the already committed worktree phase', (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n' && hostGit('rev-parse', 'HEAD').trim() === original)
const bindingRoot = hostGitRoot(workspace, context.sessionId)
check('compensated failure clears only its owned Git coordination locks', !await fs.lstat(join(workspace, '.git/index.lock')).catch(() => null) && !await fs.lstat(join(bindingRoot, 'commit.json')).catch(() => null))
const partial = await gitHostAction('commit', { ...context, approve: async () => {
await fs.rename(join(bindingRoot, 'binding.json'), join(bindingRoot, 'binding-before-fault.json')); await fs.mkdir(join(bindingRoot, 'binding.json')); return true
} })
check('private metadata failure after real host writes retains a committed outcome', partial.writeback === 'committed' && partial.state === 'interrupted' && hostGit('log', '-1', '--oneline').includes('pending') && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending'))
const committedHead = hostGit('rev-parse', 'HEAD').trim(), beforeRecoveryTransactions = (await fs.readdir(join(stateRoot, 'transactions'))).length
await fs.rmdir(join(bindingRoot, 'binding.json')); await fs.rename(join(bindingRoot, 'binding-before-fault.json'), join(bindingRoot, 'binding.json'))
const recovered = await gitHostAction('recover', context)
check('group recovery finishes bookkeeping without replaying committed host mutations', recovered.writeback === 'committed' && recovered.state === 'ready' && hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readdir(join(stateRoot, 'transactions'))).length === beforeRecoveryTransactions)
await run('echo root-swap>file.txt && git add file.txt && git commit -q -m root-swap')
const backup = join(workspace, '.git-before-swap')
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.rename(join(workspace, '.git'), backup); await fs.cp(backup, join(workspace, '.git'), { recursive: true }); return true } }), /changed/)
check('same-content .git directory replacement invalidates the approved root identity', hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending'))
const replacement = resolve(workspace, '.git'), rel = relative(fixture, replacement)
assert.ok(rel && !rel.startsWith('..') && !isAbsolute(rel)); await fs.rm(replacement, { recursive: true }); await fs.rename(backup, replacement)
await gitHostAction('discard', context)
await run('git commit --allow-empty -q -m cleanup-acknowledgement')
const workspaceLock = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId), 'workspace.lock')
const alteredLockNonce = randomUUID()
const cleanupFailure = await gitHostAction('commit', { ...context, approve: async () => { await fs.writeFile(workspaceLock, JSON.stringify({ pid: process.pid, nonce: alteredLockNonce }) + '\n'); return true } })
await fs.writeFile(join(fixture, 'cleanup-failure-result.json'), JSON.stringify(cleanupFailure, null, 2))
check('a lock cleanup failure never replaces an already committed host outcome', cleanupFailure.writeback === 'committed' && cleanupFailure.state === 'interrupted' && /cleanup requires maintenance/.test(cleanupFailure.reason ?? '') && hostGit('log', '-1', '--oneline').includes('cleanup-acknowledgement'))
assert.equal((await savedJson(workspaceLock)).nonce, alteredLockNonce); await fs.unlink(workspaceLock)
for (let index = 0; index < 20; index++) await fs.writeFile(join(workspace, `large-${index}.bin`), randomBytes(512 * 1024))
await run('echo crash-draft>file.txt && git add . && git commit -q -m crash-draft && git gc --quiet')
const beforeCrashHead = hostGit('rev-parse', 'HEAD').trim(), beforeCrashBytes = await fs.readFile(join(workspace, 'file.txt'))
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker-commit', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let workerError = ''; worker.stderr.on('data', (data: Buffer) => { workerError = (workerError + data.toString()).slice(-4000) })
const closed = new Promise<void>((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) })
let interrupted = false
for (let attempt = 0; attempt < 2500; attempt++) {
const group = await savedJson(join(bindingRoot, 'commit.json'))
if (group?.status === 'applying' && group.transactions?.worktree && !group.transactions.common) { interrupted = true; worker.kill(); break }
if (worker.exitCode !== null) break
await new Promise(done => setTimeout(done, 5))
}
if (!interrupted) { worker.kill(); await closed; throw new Error(`Did not observe the real mid-group interruption boundary: ${workerError}`) }
await closed
let crashRecovery: Awaited<ReturnType<typeof gitHostAction>> | undefined
for (let attempt = 0; attempt < 50; attempt++) {
try { crashRecovery = await gitHostAction('recover', context); break }
catch (error) { if (!/active|正在|共享|sharing|win32=32/i.test((error as Error).message) || attempt === 49) throw error; await new Promise(done => setTimeout(done, 50)) }
}
check('actual host-process death between native phases recovers a determinate outcome', !!crashRecovery && ['not-applied', 'committed'].includes(crashRecovery.writeback ?? ''))
if (crashRecovery?.writeback === 'not-applied') check('interrupted multi-root publication restores the original host files and refs', hostGit('rev-parse', 'HEAD').trim() === beforeCrashHead && (await fs.readFile(join(workspace, 'file.txt'))).equals(beforeCrashBytes))
else check('an already completed group is acknowledged without compensating its committed state', hostGit('log', '-1', '--oneline').includes('crash-draft'))
check('recovered private Git history remains available for explicit retry or inspection', (await run('git log -1 --oneline')).data.stdout.includes('crash-draft'))
const cleanup = await cleanupGitControls()
check('Git maintenance removes only abandoned controls after verifying owner/native lifetimes', cleanup.some(record => record.state === 'removed'))
const liveControl = await createGitControl(await effectiveSandboxPolicy(workspace))
try { check('maintenance preserves a live owned Git control', (await cleanupGitControls()).some(record => record.id === liveControl.root.split(/[\\/]/).at(-1) && record.state === 'active')) } finally { await liveControl.close() }
if (crashRecovery?.writeback === 'not-applied') await gitHostAction('commit', context)
check('a real pack larger than the ordinary 8 MiB file ceiling publishes through the Git metadata channel', hostGit('log', '-1', '--oneline').includes('crash-draft') && (await fs.readdir(join(workspace, '.git/objects/pack'))).some(name => name.endsWith('.pack') && name.startsWith('pack-')) && hostGit('fsck', '--connectivity-only').trim() === '')
const foreignLock = join(workspace, '.git/index.lock'); await fs.writeFile(foreignLock, 'foreign-lock')
await assert.rejects(run('git status --short'), /busy|lock/)
check('a foreign Git lock is never consumed or removed by refresh', (await fs.readFile(foreignLock, 'utf8')) === 'foreign-lock')
await fs.unlink(foreignLock)
const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['secret.txt'] } })
check('policy revocation makes the full history import stale', (await hostGitStatus(workspace, context.sessionId)).state === 'stale')
await assert.rejects(run('git log -1'), /策略|policy/)
const discarded = await workspaceAction('discard', context)
check('discard under a new policy archives the old full-history grant without silently reimporting it', discarded.writeback === 'not-applied' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
const currentPolicy = await effectiveSandboxPolicy(workspace)
check('the active policy remains in force after Git recovery and discard', currentPolicy.deniedPaths.includes('secret.txt'))
console.log(`SANDBOX_GIT_SAFETY_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
console.log(`SANDBOX_GIT_SAFETY_REPORT ${join(fixture, 'report.json')}`)
}
+15
View File
@@ -0,0 +1,15 @@
import { build } from 'esbuild'
import { promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const project = fileURLToPath(new URL('../', import.meta.url))
const temporary = join(project, '.tmp')
await fs.mkdir(temporary, { recursive: true })
const output = join(temporary, `sandbox-maintenance-cli-${randomUUID()}.mjs`)
try {
// One node entry point avoids npm's double cmd-shim escaping of path arguments.
await build({ absWorkingDir: project, entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: output, logLevel: 'warning' })
await import(pathToFileURL(output).href)
} finally { await fs.unlink(output).catch(() => undefined) }
+135
View File
@@ -0,0 +1,135 @@
import { promises as fs } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { sandboxStatus, verifiedBinary } from '../src/main/sandbox/service'
import { releaseRunLease, sandboxRecords, sandboxOperation, sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
import { restoreTransaction } from '../src/main/sandbox/transactions'
import { readSandboxPolicy, effectiveSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
import { sandboxGitState, resetSandboxGit } from '../src/main/sandbox/git-repository'
import { workspaceAction } from '../src/main/sandbox/workspace-service'
import type { SandboxWorkspaceAction } from '../src/shared/sandbox'
import { gitHostAction } from '../src/main/sandbox/git-host-service'
import { gitRemoteOperation, reconcileGitRemote } from '../src/main/sandbox/git-remote'
import { sandboxGitTool } from '../src/main/agent/tools/sandbox-git'
import { importGitCredential, listGitCredentials, removeGitCredential, type GitCredential } from '../src/main/sandbox/git-credentials'
import { regularBytes } from '../src/main/sandbox/workspace-files'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { cleanupGitControls } from '../src/main/sandbox/git-control'
const args = process.argv.slice(2)
function option(name: string): string | undefined {
const index = args.indexOf(name)
if (index < 0) return undefined
const value = args[index + 1]
if (!value || value.startsWith('--')) throw new Error(`Missing ${name} value`)
args.splice(index, 2)
return value
}
const stateRoot = option('--state-root'), resourceDirectory = option('--resources') ?? resolve('resources/sandbox/win32-x64')
const workspace = option('--workspace')
const expectedRevision = option('--expected-revision'), rulesFile = option('--rules')
const source = option('--source')
const sessionId = option('--session'), draftRevision = option('--draft-revision')
const requestFile = option('--request'), credentialFile = option('--credential-file'), remoteUrl = option('--url')
const credentialId = option('--credential-id')
const approvedIndex = args.indexOf('--approve')
const explicitlyApproved = approvedIndex >= 0
if (explicitlyApproved) args.splice(approvedIndex, 1)
const command = args.shift() ?? 'status'
if (stateRoot && !isAbsolute(stateRoot)) throw new Error('--state-root must be an absolute directory')
if (workspace && !isAbsolute(workspace)) throw new Error('--workspace must be an absolute directory')
if (!['status', 'self-test'].includes(command) && !stateRoot) throw new Error('Stateful maintenance requires an explicit --state-root (the app userData/sandbox directory)')
const temporary = !stateRoot ? await fs.mkdtemp(resolve('.tmp/sandbox-maintenance-')) : stateRoot
configureSandbox({ stateRoot: temporary, resourceDirectory })
if (command === 'status') console.log(JSON.stringify(await sandboxStatus(), null, 2))
else if (command === 'toolchains') console.log(JSON.stringify(await listSandboxToolchains(), null, 2))
else if (command.startsWith('git-host-')) {
const action = command.slice('git-host-'.length)
if (!workspace || !sessionId || !['status', 'init', 'import', 'changes', 'commit', 'discard', 'recover'].includes(action)) throw new Error('Usage: git-host-status|init|import|changes|commit|discard|recover --workspace ROOT --session ID --state-root ROOT [--approve]')
console.log(JSON.stringify(await gitHostAction(action as 'status' | 'init' | 'import' | 'changes' | 'commit' | 'discard' | 'recover', { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }, draftRevision), null, 2))
}
else if (command === 'git-remote' || command === 'git-run' || command === 'git-remote-operation' || command === 'git-remote-reconcile') {
if (!workspace || !sessionId) throw new Error('Git requires explicit --workspace and --session')
const context = { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }
if (command === 'git-remote-operation' || command === 'git-remote-reconcile') console.log(JSON.stringify(await (command === 'git-remote-reconcile' ? reconcileGitRemote(context, args.shift() ?? '', credentialId) : gitRemoteOperation(context, args.shift() ?? '')), null, 2))
else {
if (!requestFile || !isAbsolute(requestFile)) throw new Error('Git requires an absolute --request JSON file')
const request = JSON.parse((await regularBytes(requestFile, 64 * 1024)).toString('utf8'))
if (command === 'git-run') {
if (!Array.isArray(request) || !request.length || request.some(value => typeof value !== 'string')) throw new Error('git-run request must be an argv array')
console.log(JSON.stringify(await executeSandboxCommand(`git ${request.join(' ')}`, { ...context, runtimes: ['git'], workspaceMode: 'persistent', gitArguments: request, preserveGitConflicts: true }), null, 2))
} else {
if (!request || typeof request !== 'object' || Array.isArray(request) || !['ls-remote', 'clone', 'fetch', 'pull', 'push'].includes(request.action)) throw new Error('git-remote request requires a supported remote action')
console.log(JSON.stringify(await sandboxGitTool.run(request, context), null, 2))
}
}
}
else if (command === 'git-credential-add') {
if (!remoteUrl || !credentialFile || !isAbsolute(credentialFile)) throw new Error('Usage: git-credential-add --url REMOTE_URL --credential-file ABSOLUTE_JSON --state-root ROOT')
const credential = JSON.parse((await regularBytes(credentialFile, 64 * 1024)).toString('utf8')) as GitCredential
console.log(JSON.stringify(await importGitCredential(remoteUrl, credential), null, 2))
}
else if (command === 'git-credentials') console.log(JSON.stringify(await listGitCredentials(), null, 2))
else if (command === 'git-cleanup') console.log(JSON.stringify(await cleanupGitControls(), null, 2))
else if (command === 'git-credential-remove') { await removeGitCredential(args.shift() ?? ''); console.log(JSON.stringify({ removed: true })) }
else if (command.startsWith('workspace-')) {
const action = command.slice('workspace-'.length) as SandboxWorkspaceAction
if (!['status', 'changes', 'commit', 'discard', 'recover'].includes(action) || !workspace || !sessionId) throw new Error('Usage: workspace-status|changes|commit|discard|recover --workspace ROOT --session ID --state-root ROOT [--draft-revision UUID] [--approve]')
console.log(JSON.stringify(await workspaceAction(action, { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }, draftRevision), null, 2))
}
else if (command === 'toolchain-add') {
const kind = args.shift()
if (!kind || !source) throw new Error('Usage: toolchain-add node|python|git --source ABSOLUTE_EXE --state-root ROOT')
console.log(JSON.stringify(await importSandboxToolchain(kind, source), null, 2))
}
else if (command === 'git-state' || command === 'git-reset') {
if (!workspace) throw new Error('Usage: git-state|git-reset --workspace ABSOLUTE_ROOT --state-root ROOT')
if (command === 'git-reset') await resetSandboxGit(workspace)
console.log(JSON.stringify(await sandboxGitState(workspace), null, 2))
}
else if (command === 'dependency-add') {
const kind = args.shift()
if (!kind || !workspace || !isAbsolute(workspace)) throw new Error('Usage: dependency-add node|python --workspace ABSOLUTE_ROOT --state-root ROOT')
console.log(JSON.stringify(await importSandboxDependencies(workspace, kind), null, 2))
}
else if (command === 'policy') console.log(JSON.stringify(workspace ? await effectiveSandboxPolicy(workspace) : await readSandboxPolicy(), null, 2))
else if (command === 'operation') {
const id = args.shift()
if (!id) throw new Error('Usage: operation RUN_UUID --state-root ROOT')
console.log(JSON.stringify(await sandboxOperation(id), null, 2))
}
else if (command === 'broker-operation') {
const runId = args.shift(), requestId = args.shift()
if (!runId || !requestId) throw new Error('Usage: broker-operation RUN_UUID REQUEST_UUID --state-root ROOT')
console.log(JSON.stringify(await sandboxBrokerOperation(runId, requestId), null, 2))
}
else if (command === 'policy-set') {
if (!expectedRevision || !rulesFile) throw new Error('Usage: policy-set --expected-revision REVISION --rules RULES_JSON --state-root ROOT [--workspace ROOT]')
const stat = await fs.stat(rulesFile)
if (!stat.isFile() || stat.size > 64 * 1024) throw new Error('Rules input must be a JSON file smaller than 64 KiB')
const rules = JSON.parse(await fs.readFile(rulesFile, 'utf8'))
console.log(JSON.stringify(await updateSandboxPolicy({ expectedRevision, rules, ...(workspace ? { workspaceRoot: workspace } : {}) }), null, 2))
}
else if (command === 'audit' || command === 'transactions') console.log(JSON.stringify(await sandboxRecords(command === 'audit' ? 'runs' : 'transactions'), null, 2))
else if (command === 'restore') {
const id = args.shift()
if (!id || !workspace || !isAbsolute(workspace)) throw new Error('Usage: restore <transaction-id> --workspace <absolute-root> --state-root <absolute-state>')
await restoreTransaction(id, workspace)
console.log(JSON.stringify({ restored: id, workspace }))
} else if (command === 'cleanup') {
const binary = await verifiedBinary()
const root = join(temporary, 'runs')
const directories = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
const results = []
for (const entry of directories) {
if (entry.isDirectory() && !entry.isSymbolicLink() && /^[0-9a-f-]{36}$/.test(entry.name)) results.push({ id: entry.name, status: await releaseRunLease(binary, join(root, entry.name)) })
}
console.log(JSON.stringify(results, null, 2))
} else if (command === 'self-test') {
const result = await runSandboxSelfTest()
if (!result.passed) throw new Error('Native sandbox self-test failed')
console.log(JSON.stringify({ ...result, stateRoot: temporary }))
} else throw new Error('Commands: status, self-test, policy, policy-set, toolchains, toolchain-add, dependency-add, git-state, git-reset, operation, broker-operation, audit, transactions, restore, cleanup')
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { readSandboxPolicy, updateSandboxPolicy, effectiveSandboxPolicy, validatePolicyOverride } from '../src/main/sandbox/policy-store'
import { authorizeSandboxWrite } from '../src/main/sandbox/authorizations'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
import { executeTool } from '../src/main/agent/tools'
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
import { digest, restoreTransaction } from '../src/main/sandbox/transactions'
import { sandboxOperation } from '../src/main/sandbox/maintenance'
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
import type { ChatEvent } from '../src/shared/types'
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-policy-'))
const workspace = join(fixture, 'workspace'), other = join(fixture, 'other'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.mkdir(other)
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
await fs.mkdir(join(workspace, 'private')); await fs.writeFile(join(workspace, 'private/file.txt'), 'private-dummy')
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
let document = await readSandboxPolicy()
const context = { workspaceRoot: workspace, sessionId: 'policy-session', runId: 'policy-run', toolCallId: 'policy-tool', approve: async () => true }
async function update(rules: Parameters<typeof updateSandboxPolicy>[0]['rules'], root?: string): Promise<void> {
document = await updateSandboxPolicy({ expectedRevision: document.revision, rules, ...(root ? { workspaceRoot: root } : {}) })
}
try {
check('missing policy loads safe deterministic defaults without writing state', document.defaults.commandsEnabled && document.defaults.writeback === 'ask' && document.updatedAt === 0 && !await fs.stat(join(stateRoot, 'policy.json')).catch(() => null))
for (const value of [{ network: 'allow' }, { writeback: 'automatic' }, { limits: { files: -1 } }, { limits: { timeoutMs: Infinity } }, { deniedPaths: ['../escape'] }, { deniedPaths: ['C:/host'] }, { deniedPaths: ['a\\..\\escape'] }]) assert.throws(() => validatePolicyOverride(value))
check('unsupported permissions and malformed policies cannot be enabled', true)
const initialRevision = document.revision
await update({ deniedPaths: ['private'], limits: { files: 500 } })
check('policy is atomically persisted with a new revision', document.revision !== initialRevision && (await readSandboxPolicy()).revision === document.revision)
await assert.rejects(updateSandboxPolicy({ expectedRevision: initialRevision, rules: { commandsEnabled: false } }), /版本冲突/)
check('stale policy compare-and-swap is rejected', (await readSandboxPolicy()).defaults.commandsEnabled)
await update({ commandsEnabled: false, deniedPaths: ['workspace-only'], limits: { files: 10 } }, workspace)
check('workspace policy applies only to its canonical workspace', !(await effectiveSandboxPolicy(workspace)).commandsEnabled && (await effectiveSandboxPolicy(other)).commandsEnabled)
await update({ commandsEnabled: true, deniedPaths: [] }, workspace)
check('workspace overrides cannot remove global path restrictions', (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('private'))
check('workspace limits cannot widen global limits', (await effectiveSandboxPolicy(workspace)).limits.files === 10)
let tool = await executeTool('read_file', { path: 'private/file.txt' }, context)
check('host file reads enforce the effective denied-path policy', !tool.ok && !tool.content.includes('private-dummy'))
tool = await executeTool('write_file', { path: 'private/file.txt', content: 'unsafe' }, context)
check('host writes cannot bypass denied paths', !tool.ok && await fs.readFile(join(workspace, 'private/file.txt'), 'utf8') === 'private-dummy')
await update({ limits: { files: 500 } }, workspace)
const targets = digest(Buffer.from('target-and-content'))
let policy = await effectiveSandboxPolicy(workspace)
let ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
assert.ok(ticket)
await assert.rejects(ticket.consume(digest(Buffer.from('another-target'))), /目标或内容/)
await ticket.consume(targets)
await assert.rejects(ticket.consume(targets), /已使用/)
check('authorization binds the exact operation and is single use', true)
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
context.toolCallId = 'another-call'
await assert.rejects(ticket!.consume(targets), /归属/)
context.toolCallId = 'policy-tool'
check('authorization rejects changed call ownership', true)
const substitutedOwner = { ...context, approve: async () => { substitutedOwner.toolCallId = 'substituted-during-approval'; return true } }
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedOwner, targets), /归属/)
check('approval captures ownership before waiting for the user', true)
const substitutedWorkspace = { ...context, approve: async () => { substitutedWorkspace.workspaceRoot = other; return true } }
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedWorkspace, targets), /归属/)
check('approval cannot migrate to a different workspace while pending', true)
const abort = new AbortController()
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, signal: abort.signal, approve: async () => { abort.abort(); return true } }, targets), /取消/)
check('cancellation during approval never grants a ticket', true)
const now = Date.now
try {
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, approve: async () => { Date.now = () => now() + 11 * 60_000; return true } }, targets), /过期/)
} finally { Date.now = now }
check('an approval arriving after its deadline is rejected', true)
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
await update({ maxChanges: 3 })
await assert.rejects(ticket!.consume(targets), /策略已变化/)
check('policy updates revoke existing authorization', true)
policy = await effectiveSandboxPolicy(workspace)
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
const unchangedRevisionBytes = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
const editedWithoutRevision = JSON.parse(unchangedRevisionBytes); editedWithoutRevision.defaults.commandsEnabled = false
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(editedWithoutRevision))
await assert.rejects(ticket!.consume(targets), /策略已变化/)
await fs.writeFile(join(stateRoot, 'policy.json'), unchangedRevisionBytes)
await assert.rejects(ticket!.consume(targets), /过期|归属/)
check('policy content changes without a new revision still revoke approval permanently', true)
let approvals = 0
tool = await executeTool('write_file', { path: 'sample.txt', content: 'after' }, { ...context, approve: async () => { approvals++; await update({ maxChanges: 4 }); return true } })
check('a policy update while the user approves a host write prevents mutation', !tool.ok && approvals === 1 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
const args = { path: 'sample.txt', content: 'approved-content' }
tool = await executeTool('write_file', args, { ...context, approve: async () => { args.content = 'substituted'; return true } })
check('approved host content cannot be substituted by mutating the request', tool.ok && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'approved-content')
const transactionId = (tool.data as { transactionId: string }).transactionId
const transaction = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId, 'manifest.json'), 'utf8'))
check('transaction persists policy, authorization and session identity', transaction.identity.sessionId === context.sessionId && transaction.identity.policyRevision === document.revision && !!transaction.identity.authorizationId)
await restoreTransaction(transactionId, workspace)
await update({ writeback: 'deny' })
approvals = 0
tool = await executeTool('write_file', { path: 'sample.txt', content: 'denied' }, { ...context, approve: async () => { approvals++; return true } })
check('deny-writeback policy applies before prompting', !tool.ok && approvals === 0 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
await update({ writeback: 'ask' })
if (process.platform !== 'win32') throw new Error('This suite includes real Windows native checks; unsupported hosts cannot report a pass')
let result = await executeSandboxCommand('type private\\file.txt', context)
check('command snapshots enforce the same denied paths as host tools', !result.ok && !result.data.stdout.includes('private-dummy'))
await assert.rejects(executeSandboxCommand('mkdir private & echo denied>private\\new.txt', context), /受保护路径|策略/)
check('command-generated denied paths cannot reach the host', !await fs.stat(join(workspace, 'private/new.txt')).catch(() => null))
await update({ commandsEnabled: false })
await assert.rejects(executeSandboxCommand('echo unsafe>disabled.txt', context), /禁用/)
check('command-disable policy is enforced by the native route', !await fs.stat(join(workspace, 'disabled.txt')).catch(() => null))
await update({ commandsEnabled: true, limits: { fileBytes: 4 } })
await assert.rejects(executeSandboxCommand('echo unsafe>size.txt', context), /上限/)
check('configured snapshot byte limits are enforced before execution', !await fs.stat(join(workspace, 'size.txt')).catch(() => null))
await update({ limits: { fileBytes: 8 * 1024 * 1024 } })
result = await executeSandboxCommand('echo approved>sample.txt', context)
check('native writeback records policy and authorization identities', result.ok && result.data.policyRevision === document.revision && !!result.data.authorizationId)
const operation = await sandboxOperation(result.data.runId)
check('host commit outcome can be queried independently of the chat response', operation.writeback === 'committed' && operation.transactionId === result.data.transactionId && operation.identity.sessionId === context.sessionId)
await assert.rejects(sandboxOperation('../outside'), /标识无效/)
check('operation queries reject path traversal identities', true)
await restoreTransaction(result.data.transactionId!, workspace)
const selfTest = await runSandboxSelfTest()
check('self-test persists a binary and policy-bound native observation', selfTest.passed && (await sandboxStatus()).selfTest?.runId === selfTest.runId)
const beforeManualEdit = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
const modified = JSON.parse(beforeManualEdit); modified.defaults.maxChanges = 9
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(modified))
check('same-revision manual policy edits invalidate a self-test observation', !(await sandboxStatus()).selfTest)
await fs.writeFile(join(stateRoot, 'policy.json'), beforeManualEdit)
await update({ maxChanges: 2 })
check('policy changes invalidate an old self-test observation', !(await sandboxStatus()).selfTest)
policy = await effectiveSandboxPolicy(workspace)
const liveCommand = executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', context)
const cancelByPolicy = setTimeout(() => { void update({ commandsEnabled: false }).catch(() => undefined) }, 900)
await assert.rejects(liveCommand, /策略已变化/)
clearTimeout(cancelByPolicy)
check('an external policy revision stops an active native process', true)
await update({ commandsEnabled: true })
const approvalController = new AbortController(), events: ChatEvent[] = []
let pendingReady!: (id: string) => void
const ready = new Promise<string>(done => { pendingReady = done })
const emit = (event: ChatEvent): void => { events.push(event); if (event.type === 'interaction' && event.interaction.status === 'pending') pendingReady(event.interaction.id) }
const broker = createInteractionBroker(context.runId, approvalController.signal, emit)
const pendingWrite = executeTool('write_file', { path: 'revoked.txt', content: 'must-not-write' }, { ...context, signal: approvalController.signal,
approve: async (title, cancellation) => (await broker.approval(context.toolCallId, title, [{ optionId: 'allow-once', name: 'allow', kind: 'allow_once' }], undefined, cancellation)).optionId === 'allow-once' })
const interactionId = await ready
await update({ maxChanges: 3 })
const revokedWrite = await pendingWrite
check('policy revocation cancels the actual pending conversation interaction', !revokedWrite.ok && events.some(event => event.type === 'interaction' && event.interaction.id === interactionId && event.interaction.status === 'cancelled'))
check('a revoked interaction cannot accept a later approval response', !respondToInteraction(context.runId, interactionId, { kind: 'approval', optionId: 'allow-once' }, emit) && !await fs.stat(join(workspace, 'revoked.txt')).catch(() => null))
let releaseApproval!: () => void, approvalStarted!: () => void
const waitApproval = new Promise<boolean>(done => { releaseApproval = () => done(true) }), approvalIsPending = new Promise<void>(done => { approvalStarted = done })
const runCount = (await fs.readdir(join(stateRoot, 'runs'))).length
const first = executeSandboxCommand('echo serialized-first>serialized.txt', { ...context, approve: async () => { approvalStarted(); return waitApproval } })
await approvalIsPending
const second = executeSandboxCommand('type serialized.txt', context)
const queuedAbort = new AbortController()
const third = executeSandboxCommand('echo should-not-run>queued-cancelled.txt', { ...context, signal: queuedAbort.signal })
const thirdRejected = assert.rejects(third, /取消/)
queuedAbort.abort(); await thirdRejected
await new Promise(done => setTimeout(done, 50))
check('same-workspace commands and cancelled queued requests do not start stale snapshots', (await fs.readdir(join(stateRoot, 'runs'))).length === runCount + 1)
releaseApproval()
const firstResult = await first, secondResult = await second
check('a queued command observes the previous approved host commit', firstResult.ok && secondResult.ok && secondResult.data.stdout.includes('serialized-first') && !await fs.stat(join(workspace, 'queued-cancelled.txt')).catch(() => null))
await restoreTransaction(firstResult.data.transactionId!, workspace)
const original = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
await fs.writeFile(join(stateRoot, 'policy.json'), '{malformed')
tool = await executeTool('execute_command', { command: 'echo unsafe>corrupt.txt' }, context)
check('corrupt policy fails closed without host execution', !tool.ok && !await fs.stat(join(workspace, 'corrupt.txt')).catch(() => null))
await fs.writeFile(join(stateRoot, 'policy.json'), original)
await fs.writeFile(join(stateRoot, 'policy.lock'), 'existing-foreign-maintenance-lock')
await assert.rejects(updateSandboxPolicy({ expectedRevision: document.revision, rules: {} }), /其他维护进程/)
check('policy updates respect an existing cross-process maintenance lock', await fs.readFile(join(stateRoot, 'policy.lock'), 'utf8') === 'existing-foreign-maintenance-lock')
await fs.unlink(join(stateRoot, 'policy.lock'))
console.log(`SANDBOX_POLICY_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Trusted-process authorization contracts and real Windows command integration; child IPC authentication and original WorkBuddy parity remain separate.'] }, null, 2) + '\n')
console.log(`SANDBOX_POLICY_REPORT ${join(fixture, 'report.json')}`)
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-process-')), workspace = join(fixture, 'workspace')
await fs.mkdir(workspace)
const outside = join(fixture, 'outside.txt'); await fs.writeFile(outside, 'OUTSIDE_FIXTURE')
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const context = { workspaceRoot: workspace, runtimes: ['node'], timeoutMs: 10_000 }
const source = String.raw`
const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');
const labels=[];function check(label,fn){fn();labels.push(label);console.log('PROCESS_CHECK '+label)}
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'node-runtime-'));
try{
check('private-temp',()=>{fs.writeFileSync(path.join(scratch,'x.txt'),'private');assert.equal(fs.readFileSync(path.join(scratch,'x.txt'),'utf8'),'private');fs.unlinkSync(path.join(scratch,'x.txt'))});
check('binary-stdout-stderr',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(Buffer.from([0,255,1,2]));process.stderr.write("diagnostic")']);assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,1,2]));assert.equal(r.stderr.toString(),'diagnostic')});
check('stdin',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdin.on("data",x=>process.stdout.write(x))'],{input:Buffer.from([0,255,19])});assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,19]))});
check('cwd-and-env',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(process.cwd()+"|"+process.env.YCODE_FIXTURE)'],{cwd:scratch,env:{...process.env,YCODE_FIXTURE:'private-value'},encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,scratch+'|private-value')});
check('exec-file',()=>assert.equal(cp.execFileSync(process.execPath,['-e','process.stdout.write("file-result")'],{encoding:'utf8'}),'file-result'));
check('exec-shell',()=>assert.equal(cp.execSync('echo shell-result',{encoding:'utf8'}).trim(),'shell-result'));
check('omitted-args-options',()=>assert.equal(cp.execFileSync(process.execPath,undefined,{input:'process.stdout.write("stdin-script")',encoding:'utf8'}),'stdin-script'));
check('nonzero-status',()=>{assert.throws(()=>cp.execFileSync(process.execPath,['-e','process.stderr.write("expected-error");process.exit(7)'],{encoding:'utf8'}),e=>e.status===7&&e.stderr==='expected-error')});
check('missing-executable',()=>{const r=cp.spawnSync('definitely-missing-ycode-program.exe');assert.equal(r.error.code,'ENOENT');assert.equal(r.status,null)});
check('timeout',()=>{const r=cp.spawnSync(process.execPath,['-e','setTimeout(()=>console.log("late"),3000)'],{timeout:150,encoding:'utf8'});assert.equal(r.error.code,'ETIMEDOUT');assert.ok(!r.stdout.includes('late'))});
check('max-buffer',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write("x".repeat(2048))'],{maxBuffer:128,encoding:'utf8'});assert.equal(r.error.code,'ENOBUFS');assert.equal(r.stdout.length,128)});
check('inherited-stdio',()=>assert.equal(cp.spawnSync(process.execPath,['-e','console.log("INHERITED_STDIO_PASS")'],{stdio:'inherit'}).status,0));
check('esm-exports',()=>{const r=cp.spawnSync(process.execPath,['--require',process.env.YCODE_SANDBOX_PROCESS,'--input-type=module','-e','import {execSync} from "node:child_process";console.log(execSync("echo ESM_CAPTURE_PASS",{encoding:"utf8"}).trim())'],{encoding:'utf8'});assert.equal(r.status,0);assert.ok(r.stdout.includes('ESM_CAPTURE_PASS'))});
check('adapter-readonly',()=>{assert.throws(()=>fs.appendFileSync(process.env.YCODE_SANDBOX_PROCESS,'x'),e=>['EACCES','EPERM'].includes(e.code))});
check('host-boundary',()=>{const r=cp.spawnSync(process.execPath,['-e',__HOST_PROBE__],{encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,'HOST_DENIED')});
check('scratch-cleanup',()=>assert.deepEqual(fs.readdirSync(os.tmpdir()).filter(name=>name.startsWith('ycode-process-')),[]));
console.log('PROCESS_LABELS '+JSON.stringify(labels));
}finally{fs.rmdirSync(scratch)}
`
try {
await importSandboxToolchain('node', await fs.realpath(process.execPath))
const hostProbe = `try{require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9)}catch(e){if(!['EACCES','EPERM'].includes(e.code))throw e;process.stdout.write('HOST_DENIED')}`
await fs.writeFile(join(workspace, 'process.cjs'), source.replace('__HOST_PROBE__', JSON.stringify(hostProbe)))
const result = await executeSandboxCommand('node --require "%YCODE_SANDBOX_PROCESS%" process.cjs', context)
assert.ok(result.ok, result.content)
const labels = JSON.parse(result.data.stdout.split(/\r?\n/).find(line => line.startsWith('PROCESS_LABELS '))!.slice(15)) as string[]
for (const label of labels) check(`actual AppContainer Node ${label}`, true)
check('process adapter creates no host writeback or network authority', result.data.writeback === 'unchanged' && result.data.profileRemoved && result.data.driveRemoved)
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
await importSandboxToolchain('python', await fs.realpath(pythonSource))
const outsideDirectory = join(fixture, 'outside-directory')
await fs.writeFile(join(workspace, 'temp.py'), [
'import tempfile, pathlib, subprocess, sys, os, sitecustomize',
'with tempfile.TemporaryDirectory() as directory:',
' path = pathlib.Path(directory) / "data.txt"',
' path.write_text("private-python", encoding="utf-8")',
' assert path.read_text() == "private-python"',
' result = subprocess.run([sys.executable, "-c", "print(43)"], capture_output=True, text=True, timeout=3)',
' assert result.returncode == 0 and result.stdout.strip() == "43"',
' private = pathlib.Path(directory) / "mode700"',
' os.mkdir(private, 0o700)',
' (private / "child.txt").write_text("accessible")',
' assert (private / "child.txt").read_text() == "accessible"',
`try: os.mkdir(${JSON.stringify(outsideDirectory)}, 0o700)`,
'except PermissionError: pass',
'else: raise AssertionError("outside directory unexpectedly created")',
'try: pathlib.Path(sitecustomize.__file__).write_text("tamper")',
'except PermissionError: pass',
'else: raise AssertionError("adapter unexpectedly writable")',
'print("PYTHON_TEMP_CAPTURE_PASS")', ''
].join('\n'))
const python = await executeSandboxCommand('python temp.py', { ...context, runtimes: ['python'] })
assert.ok(python.ok, python.content)
check('actual Python private tempfile and anonymous-pipe subprocess capture work', python.ok && python.data.stdout.includes('PYTHON_TEMP_CAPTURE_PASS') && python.data.writeback === 'unchanged')
check('Python mode700 compatibility retains native host denial and read-only adapter', !await fs.stat(outsideDirectory).catch(() => null))
const components = join(fixture, 'components')
await fs.mkdir(components)
for (const name of ['ycode-sandbox.exe', 'manifest.json', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) await fs.copyFile(resolve('resources/sandbox/win32-x64', name), join(components, name))
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: components })
assert.equal((await sandboxStatus()).state, 'installed')
for (const name of ['node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) {
const original = await fs.readFile(join(components, name))
await fs.appendFile(join(components, name), 'tamper')
assert.equal((await sandboxStatus()).state, 'unavailable')
await assert.rejects(executeSandboxCommand('echo MUST_NOT_RUN', context), /摘要/)
await fs.writeFile(join(components, name), original)
}
check('a tampered shipped adapter invalidates installed status and fails commands closed', true)
console.log(`SANDBOX_PROCESS_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Actual AppContainer Node explicit synchronous file-stdio adapter and Python temporary-directory/capture regression. Default Node pipes and asynchronous spawn/fork compatibility remain unverified/unsupported.' }, null, 2) + '\n')
console.log(`SANDBOX_PROCESS_REPORT ${join(fixture, 'report.json')}`)
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { randomUUID, createHash } from 'node:crypto'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
import { executeSandboxCommand, verifiedBinary } from '../src/main/sandbox/service'
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
import { executeTool } from '../src/main/agent/tools'
if (process.platform !== 'win32') throw new Error('Read bridge regression requires actual Windows/NTFS/AppContainer execution')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-bridge-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.mkdir(join(fixture, 'outside'))
const outside = join(fixture, 'outside', 'host.txt')
await fs.writeFile(outside, 'OUTSIDE_MUST_NEVER_BE_RETURNED')
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const bytes = Buffer.alloc(83_171); for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251
const expectedHash = createHash('sha256').update(bytes).digest('hex')
await fs.writeFile(join(workspace, 'data.bin'), bytes)
await fs.writeFile(join(workspace, '.env'), 'SECRET_MUST_NOT_BE_LISTED_OR_RETURNED')
await fs.mkdir(join(workspace, '\u76ee\u5f55 with spaces')); await fs.writeFile(join(workspace, '\u76ee\u5f55 with spaces', 'text.txt'), 'hello-unicode-path')
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const pause = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
const context = { workspaceRoot: workspace, runId: 'host-bound-run', toolCallId: 'host-bound-tool', sessionId: 'host-bound-session', runtimes: ['node'], readBridge: true, timeoutMs: 10_000 }
let caseId = 0
async function run(source: string) {
const name = `case-${++caseId}.cjs`
await fs.writeFile(join(workspace, name), source)
return executeSandboxCommand(`node ${name}`, context)
}
const load = "const assert=require('node:assert/strict');const mod=require(process.env.YCODE_SANDBOX_BRIDGE);"
try {
const policy = await effectiveSandboxPolicy(workspace), binary = await verifiedBinary(), reader = await NativeWorkspaceReader.create(binary, policy)
let result = await reader.read('read', 'data.bin')
check('native pinned file reads preserve exact binary bytes and root identity', Buffer.from(result.data!, 'base64').equals(bytes) && result.rootIdentity === reader.rootIdentity)
result = await reader.read('read', '\u76ee\u5f55 with spaces/text.txt')
check('native canonical paths support Unicode and spaces', Buffer.from(result.data!, 'base64').toString() === 'hello-unicode-path')
result = await reader.read('list', '.')
check('directory enumeration uses its held native handle and filters protected names', result.entries!.some(entry => entry.name === 'data.bin') && !result.entries!.some(entry => entry.name === '.env'))
for (const path of ['.env', '../outside/host.txt', outside, 'data.bin:stream', '\\\\localhost\\C$\\host.txt', '\\\\?\\C:\\host.txt', 'CON', 'data.bin.', 'data.bin ', '.git/config']) await assert.rejects(reader.read('read', path))
check('credentials, traversal, absolute/device/UNC/ADS and ambiguous names are denied', true)
await fs.symlink(join(fixture, 'outside'), join(workspace, 'junction'), 'junction')
await assert.rejects(reader.read('read', 'junction/host.txt'))
await fs.unlink(join(workspace, 'junction'))
await fs.link(outside, join(workspace, 'hard.txt')); await assert.rejects(reader.read('read', 'hard.txt')); await fs.unlink(join(workspace, 'hard.txt'))
check('native reads reject junction escape and multiple hard links', true)
const writer = await fs.open(join(workspace, 'data.bin'), 'r+')
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'EBUSY') }
finally { await writer.close() }
check('an already-open writable handle prevents a read lock rather than weakening sharing', true)
const replacement = join(fixture, 'replacement'), original = join(fixture, 'original')
await fs.mkdir(replacement); await fs.writeFile(join(replacement, 'data.bin'), 'different-workspace')
await fs.rename(workspace, original); await fs.rename(replacement, workspace)
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'ESTALE') }
finally { await fs.rename(workspace, replacement); await fs.rename(original, workspace) }
check('replacing the workspace root invalidates the native identity even at the same path', true)
await fs.mkdir(join(workspace, 'swap')); await fs.writeFile(join(workspace, 'swap/good.txt'), 'inside')
await fs.writeFile(join(fixture, 'outside/good.txt'), 'OUTSIDE_MUST_NEVER_BE_RETURNED')
let attacking = true, swaps = 0
async function retry(action: () => Promise<void>): Promise<void> {
for (let attempt = 0; ; attempt++) {
try { await action(); return }
catch (error) { if (attempt >= 500 || !['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error; await pause(2) }
}
}
const attacker = (async () => {
while (attacking) {
let moved = false
try {
await fs.rename(join(workspace, 'swap'), join(workspace, 'swap-held'))
moved = true
await fs.symlink(join(fixture, 'outside'), join(workspace, 'swap'), 'junction'); swaps++; await pause(1)
} catch (error) { if (!['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error }
finally {
// Windows may leave an empty directory when setting the junction races our read lock.
if (moved) await retry(async () => {
const stat = await fs.lstat(join(workspace, 'swap')).catch(error => { if (error.code === 'ENOENT') return null; throw error })
if (stat?.isSymbolicLink()) await fs.unlink(join(workspace, 'swap'))
else if (stat) await fs.rmdir(join(workspace, 'swap'))
})
if (moved) await retry(() => fs.rename(join(workspace, 'swap-held'), join(workspace, 'swap')))
}
await pause(1)
}
})()
try {
for (let index = 0; index < 40; index++) {
try { const read = await reader.read('read', 'swap/good.txt'); assert.equal(Buffer.from(read.data!, 'base64').toString(), 'inside') }
catch (error) { if (error instanceof assert.AssertionError) throw error; assert.ok(['ENOENT', 'EACCES', 'EBUSY'].includes((error as any).code)) }
}
} finally { attacking = false; await attacker }
check('concurrent ancestor replacement with a real junction never returns outside bytes', swaps > 0)
await importSandboxToolchain('node', await fs.realpath(process.execPath))
await fs.copyFile(binary, join(workspace, 'host-reader.exe'))
const sync = await run(load + `const c=mod.connectSync(); process.stdout.write('NORMAL_NO_NEWLINE');const b=c.readFileSync('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});assert.equal(c.statSync('data.bin').kind,'file');assert.ok(c.readdirSync('.').some(e=>e.name==='data.bin'));assert.throws(()=>c.readFileSync('.env'),{code:'EACCES'});assert.throws(()=>c.call('workspace.writeFile',{path:'data.bin'}),{code:'EACCES'});assert.throws(()=>c.call('workspace.stat',{path:'data.bin',workspaceRoot:'fake'}),{code:'EACCES'});c.close();console.log('SYNC_BRIDGE_PASS')`)
check('real AppContainer Node sync client reads multiple binary chunks with backpressure', sync.ok && sync.data.stdout.includes('SYNC_BRIDGE_PASS') && sync.data.stdout.includes('NORMAL_NO_NEWLINE') && sync.data.writeback === 'unchanged')
check('host writes remain unavailable over the read bridge', (await fs.readFile(join(workspace, 'data.bin'))).equals(bytes))
const nested = await run(load + `const result=require('child_process').spawnSync('.\\\\host-reader.exe',['--workspace-read','identity',${JSON.stringify(workspace)},'.','-','1024'],{stdio:'inherit'});assert.equal(result.status,125);try{require('fs').readFileSync(${JSON.stringify(outside)});throw Error('host path unexpectedly readable')}catch(e){assert.ok(['EACCES','EPERM'].includes(e.code))}console.log('NATIVE_HOST_ONLY_PASS')`)
check('a descendant cannot invoke the host-only reader or use ordinary fs to bypass the bridge', nested.ok && nested.data.stdout.includes('NATIVE_HOST_ONLY_PASS') && nested.data.stderr.includes('host-only'))
const queryId = randomUUID()
const asynchronous = await run(load + `(async()=>{const c=mod.connect();try{const results=await Promise.all(Array.from({length:12},()=>c.stat('data.bin')));assert.ok(results.every(r=>r.kind==='file'));const b=await c.readFile('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});await c.call('workspace.stat',{path:'data.bin'},${JSON.stringify(queryId)});assert.equal((await c.query(${JSON.stringify(queryId)})).state,'succeeded');await assert.rejects(c.query(${JSON.stringify(randomUUID())}),{code:'ENOENT'});console.log('ASYNC_BRIDGE_PASS')}finally{c.close()}})().catch(e=>{console.error(e);process.exitCode=1})`)
check('async Node client queues requests and supports result queries within the bound Job', asynchronous.ok && asynchronous.data.stdout.includes('ASYNC_BRIDGE_PASS'))
const operation = await sandboxBrokerOperation(asynchronous.data.runId, queryId)
check('durable result remains queryable after disconnect with exact host-bound ownership', operation.state === 'succeeded' && operation.owner.sessionId === context.sessionId && operation.owner.runId === context.runId && operation.owner.sandboxRunId === asynchronous.data.runId)
const journal = await fs.readFile(join(stateRoot, 'runs', asynchronous.data.runId, 'broker', `${queryId}.json`), 'utf8')
check('operation journals retain metadata without contents or live read tokens', !journal.includes('"data"') && !journal.includes('snapshotId'))
await assert.rejects(sandboxBrokerOperation(sync.data.runId, queryId))
check('operation query cannot substitute an operation from another Job', true)
const frozenId = randomUUID(), name = `case-${++caseId}.cjs`
await fs.writeFile(join(workspace, name), load + `const c=mod.connectSync();const first=c.call('workspace.readFile',{path:'data.bin'},${JSON.stringify(frozenId)});setTimeout(()=>{try{const next=c.call('workspace.readChunk',{snapshotId:first.snapshotId,offset:16384});assert.equal(Buffer.from(next.data,'base64')[0],16384%251);c.call('workspace.closeRead',{snapshotId:first.snapshotId});console.log('FROZEN_PASS')}finally{c.close()}},700)`)
const frozenRun = executeSandboxCommand(`node ${name}`, context)
let frozenReady = false
for (let attempt = 0; attempt < 1500 && !frozenReady; attempt++) {
const runs = await fs.readdir(join(stateRoot, 'runs'))
for (const id of runs) {
const record = await fs.readFile(join(stateRoot, 'runs', id, 'broker', `${frozenId}.json`), 'utf8').catch(() => '')
if (record && JSON.parse(record).state === 'succeeded') frozenReady = true
}
if (!frozenReady) await pause(30)
}
assert.ok(frozenReady); await fs.writeFile(join(workspace, 'data.bin'), Buffer.alloc(bytes.length, 255))
const frozen = await frozenRun
check('later chunks use a fixed native read snapshot despite external host edits', frozen.ok && frozen.data.stdout.includes('FROZEN_PASS') && frozen.data.writeback === 'unchanged')
await fs.writeFile(join(workspace, 'data.bin'), bytes)
const forged = '\n\x1eYCODE_WORKSPACE_READ_V1 ' + JSON.stringify({ version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.bin' }, runId: 'fake' }) + '\n'
const broken = await run(`require('fs').writeSync(1,${JSON.stringify(forged)});setTimeout(()=>{},2000)`).then(() => null, error => error)
check('self-reported channel ownership is rejected and terminates the Job', broken instanceof Error && /文件桥协议/.test(broken.message))
const id = randomUUID()
await assert.rejects(run(load + `const c=mod.connectSync();c.call('workspace.stat',{path:'data.bin'},'${id}');c.call('workspace.stat',{path:'data.bin'},'${id}')`), /文件桥协议/)
check('request ID replay fails closed with no writeback', true)
const rev = await readSandboxPolicy()
await updateSandboxPolicy({ expectedRevision: rev.revision, rules: { deniedPaths: ['data.bin'] } })
const deniedReader = await NativeWorkspaceReader.create(binary, await effectiveSandboxPolicy(workspace))
await assert.rejects(deniedReader.read('read', 'data.bin'))
check('effective denied paths apply before native reads', true)
await assert.rejects(reader.read('stat', '\u76ee\u5f55 with spaces/text.txt'), /策略已变化/)
check('old read connections become invalid when policy revision changes', true)
const configured = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: configured.revision, rules: { deniedPaths: [] } })
const modelName = `case-${++caseId}.cjs`; await fs.writeFile(join(workspace, modelName), load + "const c=mod.connectSync();assert.equal(c.statSync('data.bin').kind,'file');c.close();console.log('MODEL_BRIDGE_PASS')")
const model = await executeTool('execute_command', { command: `node ${modelName}`, runtimes: ['node'], bridge: 'workspace-read' }, { workspaceRoot: workspace, sessionId: context.sessionId })
check('model-facing command explicitly selects the real read bridge', model.ok && model.content.includes('MODEL_BRIDGE_PASS'))
console.log(`SANDBOX_READ_BRIDGE_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, limitations: ['NTFS/local fixed volumes only; one stdio client per Job; read-only API, not per-descendant PID authentication, full fs shims, host write broker or WorkBuddy equivalence.'] }, null, 2) + '\n')
console.log(`SANDBOX_READ_BRIDGE_REPORT ${join(fixture, 'report.json')}`)
}
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
import { WorkspaceReadBroker, READ_BRIDGE_PREFIX } from '../src/main/sandbox/operation-broker'
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { verifiedBinary } from '../src/main/sandbox/service'
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-protocol-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'data.txt'), 'pinned-workspace-data\n'.repeat(4000))
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const policy = await effectiveSandboxPolicy(workspace), reader = await NativeWorkspaceReader.create(await verifiedBinary(), policy)
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
async function harness(failReply = false) {
const runId = randomUUID(), directory = join(stateRoot, 'runs', runId), replies: any[] = [], output: Buffer[] = []
const identity = { sessionId: 'protocol-session', runId: 'protocol-run', toolCallId: 'protocol-call', policyRevision: policy.revision, policyFingerprint: policy.fingerprint }
await fs.mkdir(join(directory, 'broker'), { recursive: true })
await fs.writeFile(join(directory, 'request.json'), JSON.stringify({ protocol: 1, id: runId, workspace, identity, createdAt: Date.now() }))
let stopped = false
const broker = new WorkspaceReadBroker(reader, join(directory, 'broker'), { ...identity, sandboxRunId: runId }, bytes => output.push(bytes), async line => {
if (failReply) throw new Error('simulated broken reply pipe')
replies.push(JSON.parse(line))
}, () => { stopped = true })
function send(operation: string, args: Record<string, unknown>, requestId = randomUUID()): string {
broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify({ version: 1, requestId, operation, args }) + '\n'))
return requestId
}
async function reply(id: string): Promise<any> {
for (let attempt = 0; attempt < 300; attempt++) {
const response = replies.find(item => item.requestId === id)
if (response) return response
if (broker.failure) throw broker.failure
await new Promise(done => setTimeout(done, 10))
}
throw new Error('Timed out waiting for broker response')
}
return { runId, directory, broker, send, reply, replies, output, stopped: () => stopped }
}
try {
let h = await harness()
h.broker.accept(Buffer.from('ordinary-output'.repeat(30_000)))
const request = { version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.txt' } }, frame = Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify(request) + '\n')
h.broker.accept(frame.subarray(0, 12)); h.broker.accept(frame.subarray(12))
assert.ok((await h.reply(request.requestId)).ok)
check('stream parser preserves long ordinary output and split control prefixes', Buffer.concat(h.output).toString() === 'ordinary-output'.repeat(30_000) + '\n')
let snapshot = (await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))).value
const chunk = await h.reply(h.send('workspace.readChunk', { snapshotId: snapshot.snapshotId, offset: 16384 }))
check('chunk replies stay bounded while preserving one immutable native read', chunk.ok && chunk.value.sha256 === snapshot.sha256 && Buffer.from(chunk.value.data, 'base64').length === 16384)
const fake = await h.reply(h.send('workspace.readChunk', { snapshotId: randomUUID(), offset: 0 }))
check('unknown or cross-connection read token is denied', !fake.ok && fake.error.code === 'ENOENT')
await h.reply(h.send('workspace.closeRead', { snapshotId: snapshot.snapshotId }))
for (let index = 0; index < 8; index++) await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))
const tooMany = await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))
check('open immutable read snapshots have an enforced count limit', !tooMany.ok && tooMany.error.code === 'EBUSY')
const unknown = await h.reply(h.send('network.fetch', { url: 'http://127.0.0.1' }))
check('network and host write operations have no dispatch implementation', !unknown.ok && unknown.error.code === 'EACCES')
await h.broker.finish(true)
h = await harness()
h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify({ ...request, runId: 'forged' }) + '\n'))
await h.broker.finish(true)
check('caller-supplied ownership cannot rebind the channel', h.stopped() && !!h.broker.failure)
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + ' '.repeat(8193))); await h.broker.finish(true)
check('oversized request frames fail closed before JSON dispatch', h.stopped() && !!h.broker.failure)
h = await harness(); for (let index = 0; index < 9; index++) h.send('workspace.stat', { path: 'data.txt' }); await h.broker.finish(true)
check('request floods cannot grow the native work queue beyond eight', h.stopped() && !!h.broker.failure && h.replies.length === 0)
h = await harness(); const replayId = h.send('workspace.stat', { path: 'data.txt' }); await h.reply(replayId); h.send('workspace.stat', { path: 'data.txt' }, replayId); await h.broker.finish(true)
check('duplicate request IDs terminate the channel instead of repeating operations', h.stopped() && !!h.broker.failure)
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + '{')); await h.broker.finish(false)
check('a truncated final control frame is a protocol failure', h.stopped() && !!h.broker.failure)
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX.slice(0, 12))); await h.broker.finish(false)
check('a truncated protocol prefix cannot be silently discarded on normal exit', h.stopped() && !!h.broker.failure)
h = await harness(); const cancelledId = h.send('workspace.readFile', { path: 'data.txt' }); await h.broker.finish(true)
const cancelled = await sandboxBrokerOperation(h.runId, cancelledId)
check('disconnect cancels pending work and persists cancellation without a reply', cancelled.state === 'cancelled' && h.replies.length === 0)
h = await harness(true); const lostId = h.send('workspace.stat', { path: 'data.txt' })
for (let attempt = 0; attempt < 300 && !h.stopped(); attempt++) await new Promise(done => setTimeout(done, 10))
await h.broker.finish(true)
const durable = await sandboxBrokerOperation(h.runId, lostId)
check('lost reply after durable completion retains a queryable successful result', h.stopped() && durable.state === 'succeeded' && durable.result?.kind === 'file')
const raw = JSON.parse(await fs.readFile(join(h.directory, 'broker', `${lostId}.json`), 'utf8'))
raw.state = 'running'; delete raw.completedAt; delete raw.result
await fs.writeFile(join(h.directory, 'broker', `${lostId}.json`), JSON.stringify(raw))
check('an interrupted running record is queried as unknown rather than success', (await sandboxBrokerOperation(h.runId, lostId)).state === 'unknown')
h = await harness(); snapshot = (await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))).value
const document = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: document.revision, rules: { commandsEnabled: false } })
const revoked = await h.reply(h.send('workspace.readChunk', { snapshotId: snapshot.snapshotId, offset: 16384 }))
check('policy revocation also blocks bytes cached before revocation', !revoked.ok && revoked.error.code === 'EACCES')
await h.broker.finish(true)
console.log(`SANDBOX_READ_PROTOCOL_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Protocol fault harness with actual native workspace reader; process isolation is covered separately by read-bridge integration.' }, null, 2) + '\n')
console.log(`SANDBOX_READ_PROTOCOL_REPORT ${join(fixture, 'report.json')}`)
}
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { createServer } from 'node:net'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
import { commitChanges, digest, restoreTransaction } from '../src/main/sandbox/transactions'
import { executeTool } from '../src/main/agent/tools'
import { releaseRunLease } from '../src/main/sandbox/maintenance'
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
if (process.platform !== 'win32') throw new Error('Native sandbox regression must run on Windows; unsupported hosts must not report a pass')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-regression-'))
const workspace = join(fixture, 'workspace with spaces'), outside = join(fixture, 'outside.txt')
await fs.mkdir(workspace)
await fs.writeFile(outside, 'outside-native-fixture')
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
await fs.writeFile(join(workspace, '.env'), 'DUMMY_SECRET=not-a-real-secret')
await fs.copyFile(resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), join(workspace, 'sandbox-probe.exe'))
const stateRoot = join(fixture, 'state'), resourceDirectory = resolve('resources/sandbox/win32-x64')
configureSandbox({ stateRoot, resourceDirectory })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void {
checks.push({ label, pass: !!condition })
assert.ok(condition, label)
console.log(`PASS ${label}`)
}
const context = { workspaceRoot: workspace, runId: 'test-run', toolCallId: 'test-tool', approve: async () => true }
try {
check('native binary and manifest identity are installed', (await sandboxStatus()).state === 'installed')
process.env.YCODE_FAKE_SECRET = 'dummy-sensitive-fixture'
let result = await executeSandboxCommand('sandbox-probe.exe --info', context)
const initialAuditRunId = result.data.runId
check('real child is an AppContainer in a Job and has no inherited dummy credential', result.ok && result.data.stdout.includes('"appContainer":true,"job":true,"secretInherited":false'))
check('read-only command needs no writeback and native profile is removed', result.data.writeback === 'unchanged' && result.data.profileRemoved)
check('temporary drive mapping is removed on normal completion', result.data.driveRemoved)
const cleanup = JSON.parse(await fs.readFile(join(stateRoot, 'runs', result.data.runId, 'cleanup.json'), 'utf8'))
check('completed scratch copy is removed while audit remains', cleanup.stageRemoved && !(await fs.stat(join(stateRoot, 'runs', result.data.runId, 'stage')).catch(() => null)))
result = await executeSandboxCommand('dir /b', context)
check('cmd directory enumeration works in mapped scratch drive', result.ok && result.data.stdout.includes('sample.txt'))
result = await executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Get-Content sample.txt; Get-ChildItem -Name"', context)
check('PowerShell can read and enumerate the scratch workspace', result.ok && result.data.stdout.includes('before') && result.data.stdout.includes('sample.txt'))
delete process.env.YCODE_FAKE_SECRET
result = await executeSandboxCommand(`sandbox-probe.exe --read "${outside}"`, context)
check('native process cannot read an ordinary host file outside the snapshot', !result.ok && result.data.stdout.includes('READ_DENIED'))
result = await executeSandboxCommand(`sandbox-probe.exe --write "${outside}"`, context)
check('native process cannot overwrite outside file', !result.ok && result.data.stdout.includes('WRITE_DENIED'))
check('outside file remains byte-identical', await fs.readFile(outside, 'utf8') === 'outside-native-fixture')
result = await executeSandboxCommand(`sandbox-probe.exe --spawn-write "${outside}"`, context)
check('descendant process inherits isolation', !result.ok && result.data.stdout.includes('WRITE_DENIED'))
result = await executeSandboxCommand('sandbox-probe.exe --breakaway escaped.txt', context)
check('Job denies explicit process breakaway', !result.ok && result.data.stdout.includes('SPAWN_DENIED'))
result = await executeSandboxCommand('type .env', context)
check('dummy credential file is excluded from command snapshot', !result.ok && !result.data.stdout.includes('DUMMY_SECRET'))
let approvals = 0
result = await executeSandboxCommand('echo approved>sample.txt', { ...context, approve: async () => { approvals++; return true } })
check('workspace changes are written back after exactly one approval', result.ok && approvals === 1 && result.data.writeback === 'committed')
check('before/after identity and independent transaction are returned', result.data.changes[0]?.operation === 'update' && !!result.data.transactionId)
check('approved native write reaches actual workspace', (await fs.readFile(join(workspace, 'sample.txt'), 'utf8')).trim() === 'approved')
await restoreTransaction(result.data.transactionId!, workspace)
await restoreTransaction(result.data.transactionId!, workspace)
check('independent backup restores exact bytes and recovery is idempotent', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
result = await executeSandboxCommand('echo rejected>sample.txt', { ...context, approve: async () => false })
check('rejected writeback reports failure without host mutation', !result.ok && result.data.writeback === 'rejected' && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
const conflict = await executeSandboxCommand('echo pending>sample.txt', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-change'); return true } }).then(() => null, error => error as Error)
check('approval-time external edit causes a conflict', conflict instanceof Error)
check('conflict does not overwrite external changes', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-change')
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
result = await executeSandboxCommand('del sample.txt', context)
check('delete is an approved backed-up transaction', result.ok && result.data.changes[0]?.operation === 'delete' && !(await fs.stat(join(workspace, 'sample.txt')).catch(() => null)))
await restoreTransaction(result.data.transactionId!, workspace)
check('deleted file restores from independent backup', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
result = await executeSandboxCommand('echo new>created.txt', context)
check('new file is recorded as create', result.ok && result.data.changes.some(change => change.operation === 'create'))
await restoreTransaction(result.data.transactionId!, workspace)
check('restoring creation removes only the unchanged created file', !(await fs.stat(join(workspace, 'created.txt')).catch(() => null)))
result = await executeSandboxCommand('echo uncommitted>sample.txt & exit /b 7', context)
check('unsuccessful command never writes back', !result.ok && result.data.exitCode === 7 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
const cancelled = new AbortController()
const pending = executeSandboxCommand('sandbox-probe.exe --delay 4000 late.txt', { ...context, signal: cancelled.signal })
setTimeout(() => cancelled.abort(), 700)
result = await pending
check('cancellation terminates native command and removes profile', !result.ok && result.data.status === 'cancelled' && result.data.profileRemoved)
result = await executeSandboxCommand('sandbox-probe.exe --delay 4000 timed-out.txt', { ...context, timeoutMs: 300 })
check('native timeout is enforced', !result.ok && result.data.status === 'timeout' && result.data.profileRemoved)
result = await executeSandboxCommand('sandbox-probe.exe --spawn-delayed descendant-late.txt', context)
await new Promise(done => setTimeout(done, 1600))
const descendantPath = join(stateRoot, 'runs', result.data.runId, 'stage/workspace/descendant-late.txt')
check('background descendants are terminated when the root command exits', !(await fs.stat(descendantPath).catch(() => null)))
const orphanId = randomUUID(), orphanDirectory = join(stateRoot, 'runs', orphanId), orphanStage = join(orphanDirectory, 'stage')
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(orphanStage, part), { recursive: true })
await fs.copyFile(join(workspace, 'sandbox-probe.exe'), join(orphanStage, 'workspace/sandbox-probe.exe'))
await fs.writeFile(join(orphanStage, 'command.cmd'), '@echo off\r\nsandbox-probe.exe --delay 5000 crash-late.txt\r\n')
const crashed = spawn(join(resourceDirectory, 'ycode-sandbox.exe'), ['--run', `YCode.Run.${orphanId.replaceAll('-', '')}`, orphanStage, join(orphanDirectory, 'result.json'), '10000'], { windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'] })
for (let attempt = 0; attempt < 100 && !await fs.stat(join(orphanDirectory, 'lease.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
check('native leases are persisted outside the child writable area', !!await fs.stat(join(orphanDirectory, 'lease.json')).catch(() => null))
const closed = new Promise(done => crashed.once('close', done))
crashed.kill(); await closed
check('maintenance releases crash-orphaned AppContainer and exact drive mapping', await releaseRunLease(join(resourceDirectory, 'ycode-sandbox.exe'), orphanDirectory) === 'released')
const parentId = randomUUID(), parentDirectory = join(stateRoot, 'runs', parentId), parentStage = join(parentDirectory, 'stage')
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(parentStage, part), { recursive: true })
await fs.copyFile(join(workspace, 'sandbox-probe.exe'), join(parentStage, 'workspace/sandbox-probe.exe'))
await fs.writeFile(join(parentStage, 'command.cmd'), '@echo off\r\nsandbox-probe.exe --delay 5000 parent-late.txt\r\n')
const parentScript = join(fixture, 'parent-exit.cjs')
await fs.writeFile(parentScript, `const fs=require('node:fs');const cp=require('node:child_process');const a=process.argv.slice(2);cp.spawn(a[0],a.slice(1),{windowsHide:true,detached:true,stdio:'ignore'});const t=setInterval(()=>{if(fs.existsSync(${JSON.stringify(join(parentDirectory, 'lease.json'))})){clearInterval(t);setTimeout(()=>process.exit(0),250)}},20);setTimeout(()=>process.exit(2),5000).unref();`)
const shortParent = spawn(process.execPath, [parentScript, join(resourceDirectory, 'ycode-sandbox.exe'), '--run', `YCode.Run.${parentId.replaceAll('-', '')}`, parentStage, join(parentDirectory, 'result.json'), '10000'], { windowsHide: true, stdio: 'ignore' })
await new Promise(done => shortParent.once('close', done))
for (let attempt = 0; attempt < 250 && !await fs.stat(join(parentDirectory, 'result.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
const parentReport = JSON.parse(await fs.readFile(join(parentDirectory, 'result.json'), 'utf8'))
check('launch-parent exit terminates the Job and cleans native resources', parentReport.status === 'parent-exited' && parentReport.profileRemoved && parentReport.driveRemoved)
let connections = 0
const server = createServer(socket => { connections++; socket.end('local-test') })
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
try {
const address = server.address(); assert.ok(address && typeof address !== 'string')
result = await executeSandboxCommand(`sandbox-probe.exe --tcp ${address.port}`, { ...context, timeoutMs: 4000 })
check('real local TCP connection is denied by capability-free AppContainer', !result.ok && result.data.stdout.includes('TCP_DENIED'))
check('local TCP listener observes no sandbox connection', connections === 0)
} finally { await new Promise<void>(done => server.close(() => done())) }
const defaultContext = { ...context, permissionMode: 'default-sandbox' as const }
let tool = await executeTool('read_file', { path: '.env' }, defaultContext)
check('host file tool shares the sensitive-file policy', !tool.ok)
tool = await executeTool('write_file', { path: '.env', content: 'blocked' }, defaultContext)
check('host writes cannot replace a protected credential file', !tool.ok && (await fs.readFile(join(workspace, '.env'), 'utf8')).includes('DUMMY_SECRET'))
tool = await executeTool('read_file', { path: outside }, defaultContext)
check('host file tool rejects outside reads', !tool.ok)
await fs.symlink(join(fixture), join(workspace, 'outside-link'), 'junction')
tool = await executeTool('read_file', { path: 'outside-link/outside.txt' }, defaultContext)
check('host file tool rejects junction escape', !tool.ok)
tool = await executeTool('write_file', { path: 'outside-link/outside.txt', content: 'explicit-full-access' }, { ...defaultContext, permissionMode: 'full-access' })
check('explicit full access preserves existing link-target write behavior', tool.ok && (tool.data as { path: string }).path === outside && await fs.readFile(outside, 'utf8') === 'explicit-full-access')
await fs.writeFile(outside, 'outside-native-fixture')
await fs.link(outside, join(workspace, 'outside-hardlink.txt'))
tool = await executeTool('read_file', { path: 'outside-hardlink.txt' }, defaultContext)
check('host file tool rejects multi-link alias', !tool.ok)
await fs.unlink(join(workspace, 'outside-hardlink.txt'))
await fs.unlink(join(workspace, 'outside-link'))
tool = await executeTool('write_file', { path: 'host.txt', content: 'protected-write' }, defaultContext)
const data = tool.data as { transactionId?: string }
check('host write records a persistent independent transaction', tool.ok && !!data.transactionId)
await restoreTransaction(data.transactionId!, workspace)
check('host write backup supports recovery', !(await fs.stat(join(workspace, 'host.txt')).catch(() => null)))
const signal = new AbortController()
tool = await executeTool('write_file', { path: 'cancelled-host.txt', content: 'blocked' }, { ...defaultContext, signal: signal.signal, approve: async () => { signal.abort(); return true } })
check('cancellation during host approval does not write', !tool.ok && !(await fs.stat(join(workspace, 'cancelled-host.txt')).catch(() => null)))
tool = await executeTool('execute_command', { command: 'echo host-route' }, defaultContext)
check('actual default host command uses native sandbox route', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer')
tool = await executeTool('execute_command', { command: 'mkdir empty-only' }, defaultContext)
check('empty-directory writeback uses an approved directory transaction', tool.ok && (await fs.stat(join(workspace, 'empty-only'))).isDirectory())
await restoreTransaction((tool.data as { transactionId: string }).transactionId, workspace)
tool = await executeTool('execute_command', { command: 'echo bypass>.env' }, defaultContext)
check('command-generated protected file never reaches the host', !tool.ok && (await fs.readFile(join(workspace, '.env'), 'utf8')).includes('DUMMY_SECRET'))
result = await executeSandboxCommand('echo unicode>unicode-\u4e2d\u6587.txt', context)
check('UTF-8 script and Unicode file writeback work', result.ok && (await fs.readFile(join(workspace, 'unicode-\u4e2d\u6587.txt'), 'utf8')).trim() === 'unicode')
await restoreTransaction(result.data.transactionId!, workspace)
result = await executeSandboxCommand('echo committed>audit-fault.txt', { ...context, toolCallId: 'audit-fault', approve: async () => {
for (const entry of await fs.readdir(join(stateRoot, 'runs'))) {
const record = await fs.readFile(join(stateRoot, 'runs', entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), () => null)
if (record?.identity?.toolCallId === 'audit-fault') await fs.mkdir(join(stateRoot, 'runs', entry, 'audit.json'))
}
return true
} })
check('audit storage failure preserves the actual committed outcome', result.ok && result.data.writeback === 'committed' && !result.data.auditSaved && (await fs.readFile(join(workspace, 'audit-fault.txt'), 'utf8')).trim() === 'committed')
await restoreTransaction(result.data.transactionId!, workspace)
const transaction = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context)
await fs.writeFile(join(workspace, 'sample.txt'), 'new-external-change')
await assert.rejects(restoreTransaction(transaction, workspace), /外部修改/)
check('recovery refuses to overwrite subsequent external edit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'new-external-change')
configureSandbox({ stateRoot, resourceDirectory: join(fixture, 'missing-backend') })
tool = await executeTool('execute_command', { command: 'echo unsafe>fallback.txt' }, defaultContext)
check('missing native backend fails closed without shell fallback', !tool.ok && !(await fs.stat(join(workspace, 'fallback.txt')).catch(() => null)))
const tampered = join(fixture, 'tampered-backend')
await fs.mkdir(tampered)
await fs.copyFile(join(resourceDirectory, 'ycode-sandbox.exe'), join(tampered, 'ycode-sandbox.exe'))
const badManifest = JSON.parse(await fs.readFile(join(resourceDirectory, 'manifest.json'), 'utf8')); badManifest.binarySha256 = '0'.repeat(64)
await fs.writeFile(join(tampered, 'manifest.json'), JSON.stringify(badManifest))
configureSandbox({ stateRoot, resourceDirectory: tampered })
check('manifest mismatch marks the backend unavailable', (await sandboxStatus()).state === 'unavailable')
tool = await executeTool('execute_command', { command: 'echo unsafe>fallback.txt' }, defaultContext)
check('manifest mismatch cannot enable a host fallback', !tool.ok && !(await fs.stat(join(workspace, 'fallback.txt')).catch(() => null)))
configureSandbox({ stateRoot, resourceDirectory })
const audit = JSON.parse(await fs.readFile(join(stateRoot, 'runs', initialAuditRunId, 'audit.json'), 'utf8'))
check('audit persists identity and excludes raw stdout/stderr', audit.identity.runId === 'test-run' && !Object.hasOwn(audit, 'stdout') && !Object.hasOwn(audit, 'stderr'))
console.log(`SANDBOX_REGRESSION_PASS ${checks.length}/${checks.length}`)
} finally {
delete process.env.YCODE_FAKE_SECRET
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Windows AppContainer boundary and YCode host tools; not WorkBuddy equivalence, arbitrary native hooks, registry parity or all possible network protocols.'] }, null, 2) + '\n')
console.log(`SANDBOX_REPORT ${join(fixture, 'report.json')}`)
}
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { createServer, createConnection } from 'node:net'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { executeTool } from '../src/main/agent/tools'
if (process.platform !== 'win32') throw new Error('Bound stdio probe requires actual Windows processes')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-stdio-')), workspace = join(fixture, 'workspace')
await fs.mkdir(workspace); await fs.copyFile(resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), join(workspace, 'sandbox-probe.exe'))
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
const context = { workspaceRoot: workspace, runId: 'bound-host-run', toolCallId: 'bound-host-call', stdioProbe: true }
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
try {
let result = await executeSandboxCommand(`sandbox-probe.exe --broker-echo ${randomUUID()}`, context)
check('real capability-free AppContainer exchanges a bounded reply over its inherited stdio', result.ok && result.data.stdout.includes('BROKER_ECHO_RECEIVED') && result.data.brokerTransport === 'bound-stdio-v1')
check('control frames are not reinterpreted as ordinary model output', !result.data.stdout.includes('YCODE_BROKER_V1') && result.data.writeback === 'unchanged')
result = await executeSandboxCommand(`sandbox-probe.exe --broker-spoof ${randomUUID()}`, context)
check('child-supplied run identity is denied instead of rebinding the request', result.ok && result.data.stdout.includes('BROKER_DENIAL_RECEIVED'))
result = await executeSandboxCommand(`sandbox-probe.exe --broker-deny ${randomUUID()}`, context)
check('experimental transport cannot perform host filesystem or network operations', result.ok && result.data.stdout.includes('BROKER_DENIAL_RECEIVED') && result.data.writeback === 'unchanged')
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-replay ${randomUUID()}`, context), /代理协议/)
check('replayed request IDs cancel the experiment without writeback', true)
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-oversize ${randomUUID()}`, context), /代理协议/)
check('oversized frames fail closed', true)
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-flood ${randomUUID()}`, context), /代理协议/)
check('excess concurrent requests are bounded and rejected', true)
let accepted = 0
const server = createServer(socket => { accepted++; socket.destroy() })
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
try {
const address = server.address(); assert.ok(address && typeof address === 'object')
await new Promise<void>((done, reject) => { const client = createConnection(address.port, '127.0.0.1'); client.once('connect', () => { client.destroy(); done() }); client.once('error', reject) })
const before = accepted
result = await executeSandboxCommand(`sandbox-probe.exe --tcp ${address.port}`, context)
check('enabling the inherited transport leaves actual network egress denied', !result.ok && result.data.stdout.includes('TCP_DENIED') && accepted === before && before > 0)
} finally { await new Promise<void>(done => server.close(() => done())) }
const abort = new AbortController()
const pending = executeSandboxCommand('sandbox-probe.exe --delay 5000 late.txt', { ...context, signal: abort.signal })
setTimeout(() => abort.abort(), 500)
result = await pending
check('cancellation terminates the stdio-enabled Job and cleans its resources', !result.ok && result.data.status === 'cancelled' && result.data.profileRemoved && result.data.driveRemoved && !await fs.stat(join(workspace, 'late.txt')).catch(() => null))
const tool = await executeTool('execute_command', { command: `sandbox-probe.exe --broker-echo ${randomUUID()}`, stdioProbe: true }, { workspaceRoot: workspace })
check('model arguments cannot enable the experimental broker transport', !tool.ok && !(tool.data as { brokerTransport?: string })?.brokerTransport)
console.log(`SANDBOX_STDIO_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Bound inherited channel and echo-only dispatch; not a per-descendant PID-authenticated broker, filesystem broker, network proxy or WorkBuddy equivalence. Model-facing tools cannot enable this experiment.'] }, null, 2) + '\n')
console.log(`SANDBOX_STDIO_REPORT ${join(fixture, 'report.json')}`)
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { join, resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
import { restoreTransaction } from '../src/main/sandbox/transactions'
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
if (process.platform !== 'win32') throw new Error('Toolchain regression requires real Windows isolation')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-toolchain-'))
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state'), outside = join(fixture, 'outside.txt')
await fs.mkdir(workspace); await fs.writeFile(outside, 'host-only-fixture')
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const context = { workspaceRoot: workspace, sessionId: 'toolchain-session', runId: 'toolchain-run', toolCallId: 'toolchain-call', approve: async () => true }
try {
check('Node/Python start missing instead of inheriting host PATH', (await listSandboxToolchains()).filter(item => item.kind !== 'git').every(item => item.state === 'missing'))
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'] }), /尚未注册/)
check('requesting a missing runtime fails closed', true)
await assert.rejects(importSandboxToolchain('node', 'relative-node.exe'), /绝对路径/)
const node = await importSandboxToolchain('node', await fs.realpath(process.execPath))
check('explicit Node import persists content-addressed file identities and npm', node.state === 'registered' && !!node.id && !!node.files && node.features!.includes('npm'))
process.env.YCODE_DUMMY_MODEL_TOKEN = 'not-a-real-credential'
let result = await executeSandboxCommand('node -e "console.log(process.version); console.log(process.env.YCODE_DUMMY_MODEL_TOKEN || \'NO_TOKEN\'); console.log(process.env.NPM_CONFIG_OFFLINE)"', { ...context, runtimes: ['node'] })
check('real Node executes inside the native sandbox with a clean environment', result.ok && result.data.stdout.includes(process.version) && result.data.stdout.includes('NO_TOKEN') && !result.data.stdout.includes('not-a-real-credential'))
check('native execution returns the exact staged runtime identity', result.data.toolchains?.[0]?.id === node.id)
await fs.writeFile(join(workspace, 'host-read.cjs'), `try { require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9) } catch(error) { if(!['EACCES','EPERM'].includes(error.code))throw error; console.log('HOST_READ_DENIED') }`)
result = await executeSandboxCommand('node host-read.cjs', { ...context, runtimes: ['node'] })
check('real Node cannot read the ordinary host fixture', result.ok && result.data.stdout.includes('HOST_READ_DENIED'))
await fs.writeFile(join(workspace, 'runtime-ro.cjs'), "const fs=require('fs'),path=require('path');const target=path.join(path.dirname(process.execPath),'node_modules/npm/package.json');fs.readFileSync(target);try {fs.appendFileSync(target,'x');process.exit(9)}catch(error){if(!['EACCES','EPERM'].includes(error.code))throw error;console.log('RUNTIME_READONLY')}")
result = await executeSandboxCommand('node runtime-ro.cjs', { ...context, runtimes: ['node'] })
check('native ACLs prevent modification of the staged interpreter', result.ok && result.data.stdout.includes('RUNTIME_READONLY'))
await fs.writeFile(join(workspace, 'package.json'), JSON.stringify({ name: 'ycode-sandbox-fixture', version: '1.0.0', private: true, scripts: { build: 'node build.cjs', test: 'node test.cjs' } }))
await fs.writeFile(join(workspace, 'build.cjs'), "require('fs').writeFileSync('artifact.txt','built-in-sandbox')")
await fs.writeFile(join(workspace, 'test.cjs'), "require('assert').equal(require('fs').readFileSync('artifact.txt','utf8'),'built-in-sandbox');console.log('NODE_PROJECT_PASS')")
result = await executeSandboxCommand('npm run build && npm test', { ...context, runtimes: ['node'] })
check('npm builds and tests a real offline project in the snapshot', result.ok && result.data.stdout.includes('NODE_PROJECT_PASS') && result.data.writeback === 'committed')
check('npm build artifact reaches the host only through approved transaction', await fs.readFile(join(workspace, 'artifact.txt'), 'utf8') === 'built-in-sandbox' && !!result.data.transactionId)
await restoreTransaction(result.data.transactionId!, workspace)
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
const python = await importSandboxToolchain('python', await fs.realpath(pythonSource))
check('Python standard library and pip are imported without executing source on the host', python.state === 'registered' && python.features!.includes('pip'))
result = await executeSandboxCommand('python -c "import sys,json,ssl,sqlite3; print(\'PYTHON_STDLIB_PASS\'); print(sys.prefix)"', { ...context, runtimes: ['python'] })
check('real Python loads its private standard library and extension modules', result.ok && result.data.stdout.includes('PYTHON_STDLIB_PASS') && !result.data.stdout.includes(pythonSource))
result = await executeSandboxCommand('python -m pip --version', { ...context, runtimes: ['python'] })
check('pip runs from the controlled Python runtime', result.ok && /pip \d/.test(result.data.stdout))
await fs.writeFile(join(workspace, 'test_fixture.py'), "from pathlib import Path\nimport unittest\nclass Fixture(unittest.TestCase):\n def test_write(self):\n Path('python-artifact.txt').write_text('python-built',encoding='utf-8')\n self.assertEqual(Path('python-artifact.txt').read_text(), 'python-built')\n")
result = await executeSandboxCommand('python -m unittest test_fixture', { ...context, runtimes: ['python'] })
check('Python tests execute and write approved artifacts', result.ok && result.data.writeback === 'committed' && await fs.readFile(join(workspace, 'python-artifact.txt'), 'utf8') === 'python-built')
await restoreTransaction(result.data.transactionId!, workspace)
check('runtime status exposes separate registered Node and Python capabilities', (await sandboxStatus()).toolchains?.filter(item => item.kind !== 'git').every(item => item.state === 'registered'))
await fs.mkdir(join(workspace, 'node_modules/fixture-package'), { recursive: true })
await fs.writeFile(join(workspace, 'node_modules/fixture-package/package.json'), JSON.stringify({ name: 'fixture-package', version: '1.0.0', exports: { require: './index.cjs', import: './esm.mjs' } }))
await fs.writeFile(join(workspace, 'node_modules/fixture-package/index.cjs'), "module.exports={value:'DEPENDENCY_CJS_PASS'}")
await fs.writeFile(join(workspace, 'node_modules/fixture-package/esm.mjs'), "export const value='DEPENDENCY_ESM_PASS'")
await fs.writeFile(join(workspace, 'node_modules/fixture-package/.env'), 'DUMMY_PACKAGE_CREDENTIAL')
const nodeDependencies = await importSandboxDependencies(workspace, 'node')
check('dependency import binds workspace lock inputs and runtime identity while filtering credentials', nodeDependencies.excluded === 1 && nodeDependencies.identity.runtimeId === node.id && nodeDependencies.files === 3)
result = await executeSandboxCommand('node -e "console.log(require(\'fixture-package\').value)" && node --input-type=module -e "import {value} from \'fixture-package\';console.log(value)"', { ...context, runtimes: ['node'], dependencies: ['node'] })
check('controlled node_modules supports real CommonJS and ESM resolution', result.ok && result.data.stdout.includes('DEPENDENCY_CJS_PASS') && result.data.stdout.includes('DEPENDENCY_ESM_PASS') && result.data.writeback === 'unchanged')
check('execution records the exact workspace dependency layer identity', result.data.dependencies?.[0]?.id === nodeDependencies.identity.id)
await fs.writeFile(join(workspace, 'dep-ro.cjs'), "const fs=require('fs'),p=require.resolve('fixture-package');fs.readFileSync(p);try{fs.appendFileSync(p,'x');process.exit(9)}catch(error){if(!['EACCES','EPERM'].includes(error.code))throw error;console.log('DEPENDENCY_READONLY')}")
result = await executeSandboxCommand('node dep-ro.cjs', { ...context, runtimes: ['node'], dependencies: ['node'] })
check('native ACLs enforce dependency-layer immutability', result.ok && result.data.stdout.includes('DEPENDENCY_READONLY'))
await fs.writeFile(join(workspace, 'package-lock.json'), '{}')
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'], dependencies: ['node'] }), /锁文件已变化/)
check('changed project lock inputs invalidate a cached dependency layer', true)
await fs.unlink(join(workspace, 'package-lock.json'))
const anotherWorkspace = join(fixture, 'another-workspace'); await fs.mkdir(anotherWorkspace)
await assert.rejects(executeSandboxCommand('node --version', { ...context, workspaceRoot: anotherWorkspace, runtimes: ['node'], dependencies: ['node'] }), /ENOENT/)
check('one workspace cannot accidentally select another workspace dependency cache', true)
await fs.mkdir(join(workspace, '.venv/Lib/site-packages/fixture_module'), { recursive: true })
await fs.writeFile(join(workspace, '.venv/Lib/site-packages/fixture_module/__init__.py'), "value='PYTHON_DEPENDENCY_PASS'\n")
const pythonDependencies = await importSandboxDependencies(workspace, 'python')
result = await executeSandboxCommand('python -c "import fixture_module;print(fixture_module.value)"', { ...context, runtimes: ['python'], dependencies: ['python'] })
check('Python imports a workspace-scoped controlled site-packages layer', result.ok && result.data.stdout.includes('PYTHON_DEPENDENCY_PASS') && result.data.dependencies?.[0]?.id === pythonDependencies.identity.id)
const cacheKey = (await fs.readdir(join(stateRoot, 'dependencies')))[0]
const cacheFile = join(stateRoot, 'dependencies', cacheKey, 'objects', nodeDependencies.identity.id, 'files/fixture-package/index.cjs')
const cacheBytes = await fs.readFile(cacheFile)
await fs.appendFile(cacheFile, 'tampered')
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'], dependencies: ['node'] }), /依赖文件摘要不匹配/)
check('a tampered dependency layer fails closed before execution', true)
await fs.writeFile(cacheFile, cacheBytes)
const alias = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/node.json'), 'utf8'))
const executable = join(stateRoot, 'toolchains/objects', alias.id, 'files/node.exe')
const bytes = await fs.readFile(executable)
await fs.appendFile(executable, 'tampered')
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'] }), /摘要不匹配/)
check('a tampered registered interpreter is rejected before native execution', true)
await fs.writeFile(executable, bytes)
await assert.rejects(executeSandboxCommand('echo bad', { ...context, runtimes: ['node', 'node'] }), /选择无效/)
await assert.rejects(executeSandboxCommand('echo bad', { ...context, runtimes: ['../host'] }), /只支持/)
check('runtime IDs cannot be duplicated or used as arbitrary source paths', true)
console.log(`SANDBOX_TOOLCHAIN_PASS ${checks.length}/${checks.length}`)
} finally {
delete process.env.YCODE_DUMMY_MODEL_TOKEN
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['This suite covers Node/npm, Python/pip and immutable dependency layers; private Git repository coverage is in test:sandbox:git. Host Git metadata integration, network installs, persistent mutable workspace, pnpm link layouts and full native-addon compatibility remain pending.'] }, null, 2) + '\n')
console.log(`SANDBOX_TOOLCHAIN_REPORT ${join(fixture, 'report.json')}`)
}
+225
View File
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { workspaceAction } from '../src/main/sandbox/workspace-service'
import { workspaceKey, workspaceRecord, type WorkspaceRevision } from '../src/main/sandbox/workspace'
import { sandboxOperation } from '../src/main/sandbox/maintenance'
import { executeTool } from '../src/main/agent/tools'
import { executeConversationTool } from '../src/main/agent/conversation-tools'
import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
import { spawn, execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { restoreTransaction } from '../src/main/sandbox/transactions'
if (process.argv[2] === '--worker') {
const [, , , childWorkspace, childState, childSession] = process.argv
configureSandbox({ stateRoot: childState, resourceDirectory: resolve('resources/sandbox/win32-x64') })
await executeSandboxCommand('git branch lost-on-crash && echo interrupted>crash.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 25"',
{ workspaceRoot: childWorkspace, sessionId: childSession, workspaceMode: 'persistent', runtimes: ['git'] })
process.exit(0)
}
if (process.platform !== 'win32') throw new Error('Persistent workspace requires real Windows isolation')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-workspace-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'file.txt'), 'host-before')
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
let approvals = 0
const context = { workspaceRoot: workspace, sessionId: 'persistent-session', runId: 'persistent-run', toolCallId: 'persistent-call', approve: async () => { approvals++; return true } }
const run = (command: string, extra: Partial<Parameters<typeof executeSandboxCommand>[1]> = {}) => executeSandboxCommand(command, { ...context, workspaceMode: 'persistent', ...extra })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const root = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId))
async function revision(): Promise<WorkspaceRevision> {
const active = await workspaceRecord<{ id: string }>(join(root, 'active.json'))
return workspaceRecord(join(root, 'revisions', `${active.id}.json`))
}
try {
let result = await run('echo draft-value>file.txt')
check('successful command saves a draft without host writes or approval', result.ok && result.data.writeback === 'deferred' && approvals === 0 && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-before')
const first = await revision()
result = await run('type file.txt')
const second = await revision()
check('next real native command reads the retained draft', result.ok && result.data.stdout.includes('draft-value') && result.data.workspace?.changes.length === 1)
check('normal command boundaries preserve the exact physical worktree identity', first.physicalIdentity === second.physicalIdentity && first.id !== second.id)
const outcome = await sandboxOperation(result.data.runId)
check('durable run outcome distinguishes a deferred draft from host commit', outcome.writeback === 'deferred' && outcome.workspace?.sessionId === context.sessionId && outcome.workspace.revision === second.id)
let status = await workspaceAction('changes', context)
check('change inspection reports the exact pending host change', status.state === 'ready' && status.changes.length === 1 && status.changes[0].path === 'file.txt')
let committed = await workspaceAction('commit', context, status.revision)
check('explicit commit consumes one approval and uses a native host transaction', committed.writeback === 'committed' && !!committed.transactionId && approvals === 1 && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).trim() === 'draft-value')
check('commit advances the host baseline and clears pending changes', committed.changes.length === 0 && committed.lastTransactionId === committed.transactionId)
result = await run('type file.txt')
check('read-only command after commit leaves the draft clean', result.ok && result.data.writeback === 'unchanged' && approvals === 1)
await run('echo pending>pending.txt')
committed = await workspaceAction('commit', { ...context, approve: async () => false })
check('rejected commit keeps draft bytes and leaves host untouched', committed.writeback === 'rejected' && committed.changes.some(change => change.path === 'pending.txt') && !await fs.stat(join(workspace, 'pending.txt')).catch(() => null))
status = await workspaceAction('discard', context)
result = await run('if exist pending.txt (exit /b 9) else (type file.txt)')
check('discard archives the draft and rebuilds from the current host', status.state === 'ready' && status.changes.length === 0 && result.ok && result.data.stdout.includes('draft-value') && (await fs.readdir(join(root, 'archives'))).some(name => name.startsWith('discarded-')))
await run('echo private-again>file.txt')
await fs.writeFile(join(workspace, 'external.txt'), 'external-clean')
result = await run('type file.txt && type external.txt')
check('three-way synchronization imports unrelated host edits without losing draft changes', result.ok && result.data.stdout.includes('private-again') && result.data.stdout.includes('external-clean') && result.data.workspace?.changes.length === 1)
await fs.writeFile(join(workspace, 'file.txt'), 'host-conflict')
await assert.rejects(run('echo must-not-run>blocked.txt'), /编辑冲突/)
status = await workspaceAction('changes', context)
check('same-file host and draft edits block execution and commit with explicit conflicts', status.state === 'conflict' && status.conflicts?.includes('file.txt') && !await fs.stat(join(workspace, 'blocked.txt')).catch(() => null) && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-conflict')
await workspaceAction('discard', context)
await run('echo saved-before-failure>checkpoint.txt')
result = await run('echo unsuccessful>checkpoint.txt && echo unsuccessful>failed.txt && exit /b 7')
check('failed commands do not publish their private filesystem changes', !result.ok && result.data.exitCode === 7 && !await fs.stat(join(workspace, 'failed.txt')).catch(() => null))
result = await run('type checkpoint.txt && if exist failed.txt exit /b 9')
check('failed command rollback restores the last durable draft checkpoint', result.ok && result.data.stdout.includes('saved-before-failure'))
const controller = new AbortController(), cancel = setTimeout(() => controller.abort(), 1100)
try { result = await run('echo cancelled>checkpoint.txt && echo CANCELLATION_MUTATED && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 12"', { signal: controller.signal }) }
finally { clearTimeout(cancel) }
check('cancelled command returns native cancellation and retains a draft view', !result.ok && result.data.status === 'cancelled' && result.data.workspace?.state === 'ready')
result = await run('type checkpoint.txt')
check('cancellation restores the last saved private bytes', result.ok && result.data.stdout.includes('saved-before-failure'))
result = await run('echo timed-out>checkpoint.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', { timeoutMs: 1000 })
check('native timeout discards private changes without writing the host', !result.ok && result.data.status === 'timeout' && !await fs.stat(join(workspace, 'checkpoint.txt')).catch(() => null))
await assert.rejects(run('echo forbidden>.env'), /受保护|链接/)
result = await run('if exist .env (exit /b 9) else (type checkpoint.txt)')
check('protected private outputs reject the whole draft candidate and roll back', result.ok && result.data.stdout.includes('saved-before-failure'))
const other = await run('if exist checkpoint.txt (exit /b 9) else (echo OTHER_SESSION_CLEAN)', { sessionId: 'other-session' })
check('another session starts from the host and cannot inherit pending private files', other.ok && other.data.stdout.includes('OTHER_SESSION_CLEAN'))
const privateRead = await executeSandboxCommand(`type "${join(root, 'worktree/checkpoint.txt')}"`, { ...context, sessionId: 'other-session' })
check('a different AppContainer cannot read the sealed retained tree by absolute path', !privateRead.ok && !privateRead.data.stdout.includes('saved-before-failure'))
const current = await revision(), privateBlob = current.tree.find(entry => entry.path === 'checkpoint.txt')!
result = await run(`type "${join(root, 'blobs', privateBlob.hash!)}"`)
check('even a new owner-session Job cannot directly read host-only checkpoint blobs', !result.ok && !result.data.stdout.includes('saved-before-failure'))
await fs.rename(join(root, 'worktree'), join(root, 'replaced-physical'))
await fs.cp(join(root, 'replaced-physical'), join(root, 'worktree'), { recursive: true, force: false, errorOnExist: true })
await assert.rejects(run('echo must-not-start>root-replaced.txt'), /物理目录身份/)
await workspaceAction('recover', context)
result = await run('type checkpoint.txt')
check('physical tree replacement is rejected and explicit recovery restores a verified checkpoint', result.ok && result.data.stdout.includes('saved-before-failure') && !await fs.stat(join(workspace, 'root-replaced.txt')).catch(() => null))
let tool = await executeTool('execute_command', { command: 'echo tool-route>tool.txt', workspace: 'persistent' }, context)
check('model-facing command selects the real persistent backend', tool.ok && (tool.data as { writeback?: string }).writeback === 'deferred' && !await fs.stat(join(workspace, 'tool.txt')).catch(() => null))
tool = await executeTool('sandbox_workspace', { action: 'changes' }, context)
check('model-facing workspace tool inspects pending draft changes', tool.ok && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'tool.txt'))
tool = await executeTool('sandbox_workspace', { action: 'status', sessionId: 'other-session', workspaceRoot: stateRoot }, context)
check('workspace tool rejects caller-supplied ownership and path overrides', !tool.ok && /归属参数/.test(tool.content))
tool = await executeTool('execute_command', { command: 'echo explicit-native>explicit-mode.txt', workspace: 'persistent' }, { ...context, permissionMode: 'full-access' })
check('explicit persistent mode keeps native confinement even with full-access host context', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer' && !await fs.stat(join(workspace, 'explicit-mode.txt')).catch(() => null))
const deniedMode = await executeConversationTool('sandbox_workspace', { action: 'commit' }, { ...context, agentMode: 'plan', signal: new AbortController().signal, emit: () => undefined })
check('planning mode cannot invoke workspace commit or recovery operations', !deniedMode.ok && /模式不允许/.test(deniedMode.content))
await assert.rejects(workspaceAction('discard', context, first.id), /版本已变化/)
check('stale revision cannot discard a newer private draft', (await workspaceAction('status', context)).changes.some(change => change.path === 'tool.txt'))
const cli = JSON.parse(execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'workspace-changes', '--workspace', workspace, '--session', context.sessionId, '--state-root', stateRoot],
{ windowsHide: true, encoding: 'utf8', timeout: 30_000 }))
check('fresh maintenance process reloads the exact saved owner and revision at Unicode caret paths', cli.sessionId === context.sessionId && cli.revision === (await revision()).id && cli.changes.some((change: { path: string }) => change.path === 'tool.txt'))
await run('git status --short', { runtimes: ['git'] })
result = await run('echo git-draft>git.txt && git add git.txt && git commit -q -m persistent-private-commit', { runtimes: ['git'] })
check('draft Git commits persist without a host write approval', result.ok && result.data.git?.scope === 'draft' && result.data.git.persistence === 'saved' && !await fs.stat(join(workspace, 'git.txt')).catch(() => null))
result = await run('git log -1 --oneline && type git.txt', { runtimes: ['git'] })
check('next command sees both private Git history and private worktree bytes', result.ok && result.data.stdout.includes('persistent-private-commit') && result.data.stdout.includes('git-draft'))
result = await run('git log --all --oneline', { runtimes: ['git'], sessionId: 'other-session' })
check('private Git history is isolated by session as well as project', result.ok && !result.data.stdout.includes('persistent-private-commit'))
const beforeCrash = await revision()
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let workerError = ''; worker.stderr.on('data', (bytes: Buffer) => { workerError = (workerError + bytes.toString()).slice(-4000) })
const workerClosed = new Promise<void>((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) })
let marker: { runId: string } | undefined
for (let attempt = 0; attempt < 240; attempt++) {
marker = await workspaceRecord<{ runId: string }>(join(root, 'checkout.json')).catch(() => undefined)
if (marker && await fs.stat(join(stateRoot, 'runs', marker.runId, 'stage/workspace/crash.txt')).catch(() => null)) break
if (worker.exitCode !== null) throw new Error(`Worker exited before mutation: ${workerError}`)
await new Promise(done => setTimeout(done, 40))
}
assert.ok(marker, workerError); worker.kill(); await workerClosed
let recovered = false
for (let attempt = 0; attempt < 60; attempt++) {
try { await workspaceAction('recover', context); recovered = true; break }
catch (error) { if (!/活跃|活动|未清理/.test((error as Error).message)) throw error; await new Promise(done => setTimeout(done, 60)) }
}
check('explicit recovery reclaims a dead main-process lock and checks the native lease', recovered && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
result = await run('git branch --list lost-on-crash && if exist crash.txt (exit /b 9) else (type git.txt)', { runtimes: ['git'] })
check('actual host-process death restores prior draft and private Git revision', result.ok && result.data.stdout.includes('git-draft') && !result.data.stdout.includes('lost-on-crash') && !await fs.stat(join(workspace, 'crash.txt')).catch(() => null) && beforeCrash.tree.some(entry => entry.path === 'git.txt'))
await workspaceAction('discard', context)
result = await run('git log --all --oneline && if exist git.txt exit /b 9', { runtimes: ['git'] })
check('discard archives private history and removes uncommitted private files', result.ok && !result.data.stdout.includes('persistent-private-commit'))
await run('echo committed-despite-metadata-fault>recovery-host.txt')
const activePath = join(root, 'active.json'), savedActive = join(root, 'held-active.json')
committed = await workspaceAction('commit', { ...context, approve: async () => { await fs.rename(activePath, savedActive); await fs.mkdir(activePath); return true } })
check('post-host-commit metadata failure preserves the actual committed outcome', committed.writeback === 'committed' && committed.state === 'interrupted' && (await fs.readFile(join(workspace, 'recovery-host.txt'), 'utf8')).includes('committed-despite-metadata-fault'))
await fs.rmdir(activePath); await fs.rename(savedActive, activePath)
status = await workspaceAction('recover', context)
check('recovery resolves the native committed transaction before advancing the draft baseline', status.state === 'ready' && status.changes.length === 0 && status.lastTransactionId === committed.transactionId)
await fs.writeFile(join(workspace, 'private.txt'), 'DUMMY_POLICY_FILTERED_CONTENT')
await run('type private.txt', { runtimes: ['git'] })
const policy = await readSandboxPolicy()
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['private.txt'] } })
status = await workspaceAction('status', context)
await assert.rejects(run('type private.txt'), /策略.*已变化/)
check('policy change makes the old draft stale without returning denied filenames', status.state === 'stale' && status.changes.length === 0)
await workspaceAction('discard', context)
result = await run('if exist private.txt (exit /b 9) else (git log --all --oneline)', { runtimes: ['git'] })
check('discard under new policy rebuilds filtered files and resets old private history', result.ok && !result.data.stdout.includes('DUMMY_POLICY_FILTERED_CONTENT'))
const policy2 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy2.revision, rules: { deniedPaths: [], writeback: 'deny' } })
await workspaceAction('discard', context)
result = await run('echo policy-private>denied-host.txt')
await assert.rejects(workspaceAction('commit', context), /禁止写回/)
check('writeback-deny permits private drafting while refusing host commit', result.ok && result.data.writeback === 'deferred' && !await fs.stat(join(workspace, 'denied-host.txt')).catch(() => null))
const policy3 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy3.revision, rules: { writeback: 'ask' } })
await workspaceAction('discard', context)
await fs.writeFile(join(workspace, 'package.json'), '{"name":"workspace-test","version":"1.0.0"}')
await fs.writeFile(join(workspace, 'package-lock.json'), '{"name":"workspace-test","lockfileVersion":3}')
await fs.mkdir(join(workspace, 'node_modules/offline-fixture'), { recursive: true })
await fs.writeFile(join(workspace, 'node_modules/offline-fixture/index.js'), 'module.exports="DEPENDENCY_OK"')
await importSandboxToolchain('node', process.execPath)
await importSandboxDependencies(workspace, 'node')
result = await run('node -e "require(\'fs\').writeFileSync(\'node-result.txt\',require(\'offline-fixture\'))"', { runtimes: ['node'], dependencies: ['node'] })
check('read-only dependency injection works in the retained physical tree', result.ok && result.data.toolchains?.[0].kind === 'node' && !await fs.stat(join(root, 'worktree/node_modules')).catch(() => null))
result = await run('type node-result.txt')
check('dependency layers detach before checkpoint without losing project outputs', result.ok && result.data.stdout.includes('DEPENDENCY_OK'))
const privateCheckpoint = await revision()
await fs.writeFile(join(root, 'worktree/node-result.txt'), 'incomplete-private-sync')
await fs.writeFile(join(root, 'mutation.json'), JSON.stringify({ version: 1, revision: privateCheckpoint.id }))
await assert.rejects(run('echo unsafe>must-not-run.txt'), /中断|未确定/)
await workspaceAction('recover', context)
result = await run('type node-result.txt')
check('private sync intent blocks reuse until explicit checkpoint recovery', result.ok && result.data.stdout.includes('DEPENDENCY_OK') && !await fs.stat(join(workspace, 'must-not-run.txt')).catch(() => null))
const completed = await revision()
await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: result.data.runId, revision: completed.previousRevision, physicalIdentity: completed.physicalIdentity }))
await workspaceAction('recover', context)
check('recovery accepts a completed checkpoint whose checkout acknowledgement was interrupted', (await revision()).id === completed.id && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
const failedAgain = await run('echo incomplete>node-result.txt && exit /b 7'), restored = await revision()
await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: failedAgain.data.runId, revision: restored.previousRevision, physicalIdentity: privateCheckpoint.physicalIdentity }))
await workspaceAction('recover', context)
check('recovery is repeatable after rollback checkpoint publication before marker removal', (await revision()).id === restored.id)
await fs.mkdir(join(workspace, 'source-tree/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'source-tree/sub/data.bin'), Buffer.from([0, 1, 128, 255]))
result = await run('move source-tree renamed-tree && mkdir empty\\nested')
committed = await workspaceAction('commit', context)
check('persistent tree rename and empty directories commit through the native batch', result.ok && committed.writeback === 'committed' && !await fs.stat(join(workspace, 'source-tree')).catch(() => null) && (await fs.readFile(join(workspace, 'renamed-tree/sub/data.bin'))).equals(Buffer.from([0, 1, 128, 255])) && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
await restoreTransaction(committed.transactionId!, workspace)
result = await run('if exist renamed-tree (exit /b 9) else (dir source-tree\\sub)')
check('native host restore is imported as an external change into a clean retained tree', result.ok && result.data.workspace?.changes.length === 0)
await assert.rejects(run('rmdir /s /q source-tree && echo changed-type>source-tree'), /文件\/目录互换/)
result = await run('dir source-tree\\sub')
check('unsupported private file-directory swaps are rejected before publishing a poisoned revision', result.ok && result.data.stdout.includes('data.bin'))
const activeCommand = run('echo READY_FOR_STATUS && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 2"')
for (let attempt = 0; attempt < 100 && !await fs.stat(join(root, 'checkout.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
const observedAt = Date.now(), busy = await workspaceAction('status', context)
check('status remains responsive while its owner is running a native command', busy.state === 'busy' && Date.now() - observedAt < 1500)
await activeCommand
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'owned-runner.exe'))
result = await run('owned-runner.exe --seal-workspace "%CD%"')
check('sandbox code cannot invoke the host-only ACL sealing entry point', !result.ok && /host-only/.test(result.data.stderr))
await fs.unlink(join(workspace, 'owned-runner.exe'))
const livePolicy = await readSandboxPolicy()
const revoke = setTimeout(() => { void updateSandboxPolicy({ expectedRevision: livePolicy.revision, rules: { deniedPaths: ['file.txt'] } }) }, 1100)
try { await assert.rejects(run('echo revoked>node-result.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"'), /策略已变化/) }
finally { clearTimeout(revoke) }
status = await workspaceAction('status', context)
check('policy revocation interrupts a live draft command and leaves old checkpoint stale', status.state === 'stale' && status.changes.length === 0 && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
console.log(`SANDBOX_WORKSPACE_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
console.log(`SANDBOX_WORKSPACE_REPORT ${join(fixture, 'report.json')}`)
}
+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { NativeWorkspaceWriter } from '../src/main/sandbox/filesystem-writer'
import { digest } from '../src/main/sandbox/transactions'
import { commitChanges, restoreTransaction } from '../src/main/sandbox/transactions'
import { executeSandboxCommand } from '../src/main/sandbox/service'
import { executeTool } from '../src/main/agent/tools'
import { execFileSync, type ChildProcess } from 'node:child_process'
import { randomUUID } from 'node:crypto'
if (process.platform !== 'win32') throw new Error('Native write integration requires Windows')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-write-'))
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
try {
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
const writer = await NativeWorkspaceWriter.create(workspace)
try {
const before = await writer.inspect('sample.txt', true)
check('native transaction reads exact bytes under a pinned root', before.bytes?.toString() === 'before')
await writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('after'))
check('replacement succeeds while original target remains pinned', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
await assert.rejects(writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('wrong')), /changed/)
check('stale expected content cannot overwrite the target', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
} finally { await writer.close() }
await fs.mkdir(join(workspace, 'nested'))
await fs.writeFile(join(workspace, 'nested/data.bin'), Buffer.from([0, 255, 1, 127]))
const outside = join(fixture, 'outside'); await fs.mkdir(outside)
await fs.writeFile(join(outside, 'data.bin'), 'OUTSIDE_UNCHANGED')
const pinned = await NativeWorkspaceWriter.create(workspace)
try {
await pinned.inspect('nested/data.bin', true)
const moved = await fs.rename(join(workspace, 'nested'), join(workspace, 'moved')).then(() => true, () => false)
check('pinned ancestor cannot be renamed into a junction attack', !moved)
const rootMoved = await fs.rename(workspace, join(fixture, 'replaced-root')).then(() => true, () => false)
check('workspace root remains pinned for the whole transaction session', !rootMoved)
await pinned.replace('nested/data.bin', digest(Buffer.from([0, 255, 1, 127])), Buffer.alloc(0))
check('nested binary-to-empty replacement preserves byte semantics', (await fs.stat(join(workspace, 'nested/data.bin'))).size === 0)
await pinned.replace('created.bin', null, Buffer.from([0, 128, 255]))
await assert.rejects(pinned.replace('created.bin', null, Buffer.from('overwrite')), /changed/)
check('creation refuses to overwrite a target that already exists', (await fs.readFile(join(workspace, 'created.bin'))).equals(Buffer.from([0, 128, 255])))
for (const path of ['../outside/data.bin', '.env', 'stream.txt:ads', '.ycode-sandbox-owned.tmp']) await assert.rejects(pinned.replace(path, null, Buffer.from('blocked')))
check('outside, sensitive, ADS and reserved transaction paths are denied', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
await fs.symlink(outside, join(workspace, 'junction'), 'junction')
await assert.rejects(pinned.replace('junction/data.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')))
check('native relative opens reject a junction target', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
await fs.unlink(join(workspace, 'junction'))
await fs.link(join(outside, 'data.bin'), join(workspace, 'hardlink.bin'))
await assert.rejects(pinned.replace('hardlink.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')), /hard link/)
check('native replacement rejects hard-linked files', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
await fs.unlink(join(workspace, 'hardlink.bin'))
const busy = await fs.open(join(workspace, 'sample.txt'), 'r+')
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /win32=32/) }
finally { await busy.close() }
check('existing writable handles fail closed without relaxed sharing', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
await fs.chmod(join(workspace, 'sample.txt'), 0o444)
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /read-only/) }
finally { await fs.chmod(join(workspace, 'sample.txt'), 0o666) }
check('read-only targets are rejected before handoff', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
await pinned.replace('created.bin', digest(Buffer.from([0, 128, 255])), null)
check('native deletion removes only the verified file object', !await fs.stat(join(workspace, 'created.bin')).catch(() => null))
} finally { await pinned.close() }
await fs.rename(join(workspace, 'nested'), join(workspace, 'released'))
check('session close releases ancestor pins', (await fs.stat(join(workspace, 'released'))).isDirectory())
const context = { workspaceRoot: workspace, sessionId: 'write-session', runId: 'write-run', toolCallId: 'write-call', approve: async () => true }
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'runner-copy.exe'))
const hostOnly = await executeSandboxCommand('runner-copy.exe --workspace-write "%CD%" -', context)
check('sandbox descendants cannot invoke the host transaction entry point', !hostOnly.ok && /host-only/.test(hostOnly.data.stderr))
await fs.unlink(join(workspace, 'runner-copy.exe'))
const tool = await executeTool('write_file', { path: 'implicit/a/b/file.txt', content: 'implicit-content' }, context)
const transactionId = (tool.data as { transactionId?: string })?.transactionId
check('default file tool commits through the native transaction backend', tool.ok && !!transactionId && await fs.readFile(join(workspace, 'implicit/a/b/file.txt'), 'utf8') === 'implicit-content')
const journal = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId!, 'manifest.json'), 'utf8'))
check('journal binds root identity and records implicit parents and mutation intent', journal.version === 3 && journal.workspaceIdentity && journal.directories.length === 3 && journal.mutations.length === 1)
await restoreTransaction(transactionId!, workspace)
check('recovery removes implicitly created parents as well as file bytes', !await fs.stat(join(workspace, 'implicit')).catch(() => null))
let attempts = 0
const guardedId = await commitChanges(workspace, [{ path: 'released/data.bin', beforeHash: digest(Buffer.alloc(0)), after: Buffer.from('guarded') }], context, undefined, {
beforeApply: async () => {
for (let i = 0; i < 20; i++) if (!await fs.rename(join(workspace, 'released'), join(workspace, 'attack-moved')).then(() => true, () => false)) attempts++
}, check: async () => undefined
})
check('real commit rejects repeated ancestor replacement attempts after preflight', attempts === 20 && await fs.readFile(join(workspace, 'released/data.bin'), 'utf8') === 'guarded' && await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
await restoreTransaction(guardedId, workspace)
await fs.mkdir(join(workspace, 'delete-tree')); await fs.writeFile(join(workspace, 'delete-tree/original.txt'), 'original')
await assert.rejects(commitChanges(workspace, [{ path: 'delete-tree/original.txt', beforeHash: digest(Buffer.from('original')), after: null }], context, undefined,
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'delete-tree/external.txt'), 'external') }, check: async () => undefined }, [{ path: 'delete-tree', before: true, after: false }]))
check('late directory additions stop deletion and compensate earlier file removals', await fs.readFile(join(workspace, 'delete-tree/original.txt'), 'utf8') === 'original' && await fs.readFile(join(workspace, 'delete-tree/external.txt'), 'utf8') === 'external')
await assert.rejects(commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('after')), after: Buffer.from('approved') }], context, undefined,
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-edit') }, check: async () => undefined }))
check('post-preflight external file edits survive a rejected commit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-edit')
for (const route of ['command', 'file-tool']) {
const targetRoot = join(fixture, `approval-${route}`), movedRoot = `${targetRoot}-old`
await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval')
const approve = async (): Promise<boolean> => { await fs.rename(targetRoot, movedRoot); await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval'); return true }
if (route === 'command') await assert.rejects(executeSandboxCommand('echo approved>sample.txt', { ...context, workspaceRoot: targetRoot, approve }), /identity changed|被替换/)
else assert.equal((await executeTool('write_file', { path: 'sample.txt', content: 'approved' }, { ...context, workspaceRoot: targetRoot, approve })).ok, false)
check(`${route} approval cannot authorize a replacement root with identical contents`, await fs.readFile(join(targetRoot, 'sample.txt'), 'utf8') === 'before-approval' && await fs.readFile(join(movedRoot, 'sample.txt'), 'utf8') === 'before-approval')
}
const rootRecovery = join(fixture, 'root-recovery'); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'before')
const rootTransaction = await commitChanges(rootRecovery, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }])
await fs.rename(rootRecovery, `${rootRecovery}-old`); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'after')
await assert.rejects(restoreTransaction(rootTransaction, rootRecovery), /identity changed/)
check('recovery refuses a same-path replacement root with matching file hashes', await fs.readFile(join(rootRecovery, 'sample.txt'), 'utf8') === 'after')
await fs.writeFile(join(workspace, 'first.txt'), 'first-before'); await fs.writeFile(join(workspace, 'second.txt'), 'second-before')
let phase = 0
const killed = await commitChanges(workspace, [
{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('first-after') },
{ path: 'second.txt', beforeHash: digest(Buffer.from('second-before')), after: Buffer.from('second-after') }
], context, undefined, { beforeApply: async () => undefined, check: async () => {
if (++phase !== 2) return
const query = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${process.pid}' | Where-Object { $_.Name -eq 'ycode-sandbox.exe' } | Select-Object -ExpandProperty ProcessId`
const pids = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { encoding: 'utf8', windowsHide: true }).trim().split(/\s+/).map(Number).filter(Number.isSafeInteger)
assert.equal(pids.length, 1); process.kill(pids[0]); await new Promise(done => setTimeout(done, 40))
} }).then(() => null, error => error as Error & { transactionId?: string; recoveryRequired?: boolean })
check('native backend death reports recovery-required after a partial batch', killed?.recoveryRequired && !!killed.transactionId && await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-after')
await restoreTransaction(killed!.transactionId!, workspace)
check('a new native session recovers a batch after actual backend death', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && await fs.readFile(join(workspace, 'second.txt'), 'utf8') === 'second-before')
const handoff = await commitChanges(workspace, [{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('handoff-after') }])
const manifestPath = join(stateRoot, 'transactions', handoff, 'manifest.json'), manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'))
const intent = manifest.mutations[0], oldScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-old.tmp`), newScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-new.tmp`)
await fs.unlink(join(workspace, 'first.txt')); await fs.writeFile(oldScratch, 'first-before'); await fs.writeFile(newScratch, 'handoff-after')
manifest.status = 'applying'; manifest.mutations = []; await fs.writeFile(manifestPath, JSON.stringify(manifest))
await restoreTransaction(handoff, workspace)
check('interrupted-handoff disk fixture restores absent basename and cleans verified scratch', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && !await fs.stat(oldScratch).catch(() => null) && !await fs.stat(newScratch).catch(() => null))
const blocked = await NativeWorkspaceWriter.create(workspace)
try {
const nonce = randomUUID().replaceAll('-', ''), scratch = join(workspace, `.ycode-sandbox-${nonce}-old.tmp`)
await fs.writeFile(scratch, 'foreign-content')
await assert.rejects(blocked.cleanup('first.txt', nonce, digest(Buffer.from('first-before')), null), /changed externally/)
check('scratch cleanup refuses unrelated or externally changed contents', await fs.readFile(scratch, 'utf8') === 'foreign-content')
} finally { await blocked.close() }
const dying = await NativeWorkspaceWriter.create(workspace)
const child = Reflect.get(dying, 'child') as ChildProcess
child.kill(); await new Promise(done => setTimeout(done, 30))
await assert.rejects(dying.replace('first.txt', digest(Buffer.from('first-before')), Buffer.from('never')))
await dying.close()
check('dead native channel cannot fall back to an ordinary host write', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before')
const cliRoot = join(fixture, 'CLI work \u4e2d\u6587 ^'); await fs.mkdir(cliRoot); await fs.writeFile(join(cliRoot, 'sample.txt'), 'cli-before')
const cliId = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('cli-after') }])
execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'restore', cliId, '--workspace', cliRoot, '--state-root', stateRoot], { windowsHide: true, timeout: 30_000, stdio: 'pipe' })
check('actual maintenance CLI restores v3 at a space Unicode caret path', await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before')
for (const version of [1, 2]) {
const id = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('legacy-after') }])
const path = join(stateRoot, 'transactions', id, 'manifest.json'), legacy = JSON.parse(await fs.readFile(path, 'utf8'))
legacy.version = version; delete legacy.workspaceIdentity; delete legacy.mutationJournal; delete legacy.mutations
if (version === 1) delete legacy.directories
await fs.writeFile(path, JSON.stringify(legacy))
await restoreTransaction(id, cliRoot)
const upgraded = JSON.parse(await fs.readFile(path, 'utf8'))
check(`legacy v${version} recovery uses native backend and upgrades root identity`, await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before' && upgraded.version === 3 && !!upgraded.workspaceIdentity)
}
console.log(`SANDBOX_WRITE_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
console.log(`SANDBOX_WRITE_REPORT ${join(fixture, 'report.json')}`)
}
+2
View File
@@ -96,6 +96,7 @@ async function main(): Promise<void> {
external: ['node-pty'],
platform: 'node',
format: 'esm',
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
logLevel: 'silent',
plugins: [
testTerminalHostPlugin,
@@ -122,6 +123,7 @@ async function main(): Promise<void> {
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
export class BrowserWindow {}
export class WebContentsView {}
`
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { resolve } from 'node:path'
import {
computeNotificationsPanelPosition
} from '../src/renderer/src/components/NotificationsPanel'
import {
computeProjectMenuPosition
} from '../src/renderer/src/projects/ProjectMenu'
async function main(): Promise<void> {
const notificationPosition = computeNotificationsPanelPosition(
{ left: 240, right: 272, top: 854, bottom: 886 },
{ width: 300, height: 380 },
{ width: 1336, height: 902 }
)
assert.deepEqual(notificationPosition, { left: 8, top: 466 })
const rightEdgePosition = computeNotificationsPanelPosition(
{ left: 1200, right: 1232, top: 600, bottom: 632 },
{ width: 300, height: 280 },
{ width: 1280, height: 720 }
)
assert.deepEqual(rightEdgePosition, { left: 932, top: 312 })
const shortViewportPosition = computeNotificationsPanelPosition(
{ left: 120, right: 152, top: 690, bottom: 720 },
{ width: 300, height: 380 },
{ width: 360, height: 720 }
)
assert.equal(shortViewportPosition.left, 8)
assert.equal(shortViewportPosition.top, 302)
const sourceProjectMenuPosition = computeProjectMenuPosition(
{ left: 100, right: 271, top: 266 },
{ width: 162, height: 64 },
{ width: 1336, height: 902 }
)
assert.deepEqual(sourceProjectMenuPosition, { left: 275, top: 258 })
const narrowProjectMenuPosition = computeProjectMenuPosition(
{ left: 20, right: 200, top: 80 },
{ width: 162, height: 64 },
{ width: 320, height: 240 }
)
assert.equal(narrowProjectMenuPosition.left, 4)
assert.equal(narrowProjectMenuPosition.top, 72)
const sidebar = await fs.readFile(
resolve('src/renderer/src/components/Sidebar.tsx'),
'utf8'
)
assert.match(sidebar, /onContextMenu=\{\(event\) => \{[\s\S]*?openTaskMenu\(session, event\.currentTarget, \{ left: event\.clientX, top: event\.clientY \}\)/)
assert.match(sidebar, /event\.key === 'ContextMenu' \|\| \(event\.shiftKey && event\.key === 'F10'\)/)
assert.match(sidebar, /aria-haspopup="dialog"/)
assert.match(sidebar, /<NotificationsPanel anchor=\{notifAnchor\}/)
const notifications = await fs.readFile(
resolve('src/renderer/src/components/NotificationsPanel.tsx'),
'utf8'
)
assert.match(notifications, /createPortal\(/)
assert.match(notifications, /role="dialog"/)
assert.match(notifications, /computeNotificationsPanelPosition/)
assert.match(notifications, /event\.key === 'Escape'/)
assert.match(notifications, /anchor\?\.trigger\.focus/)
const sidebarCss = await fs.readFile(
resolve('src/renderer/src/styles/sidebar.css'),
'utf8'
)
assert.match(sidebarCss, /\.notif-panel \{[\s\S]*?position: fixed;[\s\S]*?z-index: 10020;/)
assert.match(sidebarCss, /width: min\(300px, calc\(100vw - 16px\)\);/)
assert.match(sidebarCss, /max-height: min\(380px, calc\(100vh - 16px\)\);/)
assert.match(sidebarCss, /html\[data-theme='dark'\] \.notif-panel \{[\s\S]*?--yc-notif-bg: #242424;/)
const projectMenuCss = await fs.readFile(
resolve('src/renderer/src/projects/project-menu.css'),
'utf8'
)
assert.match(projectMenuCss, /--yc-project-menu-bg: #fff;/)
assert.match(projectMenuCss, /background: var\(--yc-project-menu-bg\);/)
assert.match(projectMenuCss, /html\[data-theme="dark"\] \.yc-project-more \{[\s\S]*?--yc-project-menu-bg: #242424;/)
const taskMenuQa = await fs.readFile(
resolve('scripts/verify-task-menu.cjs'),
'utf8'
)
assert.match(taskMenuQa, /context-menu-same-as-more/)
assert.match(taskMenuQa, /dispatchEvent\(new MouseEvent\('contextmenu'/)
console.log('sidebar-notification-menu-regression: ok')
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+37
View File
@@ -0,0 +1,37 @@
/**
* 按《国旗制法说明》的比例计算五角星布局。
*
* 制式(旗面 30 × 20 单位):
* 大星:中心 (5,5),外接圆半径 3
* 小星:中心 (10,2) (12,4) (12,7) (10,9),外接圆半径 1
* 四颗小星各有一角尖正对大星中心
*
* 输出:可直接粘进 SVG 的 <use> 列表
*/
const BIG = { x: 5, y: 5, r: 3 }
const SMALL = [
{ x: 10, y: 2 },
{ x: 12, y: 4 },
{ x: 12, y: 7 },
{ x: 10, y: 9 }
]
function layout({ k, ox, oy, sizes }) {
const B = { x: ox + BIG.x * k, y: oy + BIG.y * k }
const lines = []
lines.push(`<use href="#star" transform="translate(${B.x.toFixed(1)} ${B.y.toFixed(1)}) scale(${(BIG.r * k).toFixed(1)}) rotate(-90)"/>`)
for (const s of SMALL) {
const S = { x: ox + s.x * k, y: oy + s.y * k }
const deg = (Math.atan2(B.y - S.y, B.x - S.x) * 180) / Math.PI + 90
lines.push(`<use href="#star" transform="translate(${S.x.toFixed(1)} ${S.y.toFixed(1)}) scale(${(1 * k).toFixed(1)}) rotate(${deg.toFixed(1)})"/>`)
}
return { markup: lines.join('\n '), B, smallR: k, bigR: BIG.r * k }
}
const k = Number(process.argv[2] ?? 7.2)
const ox = Number(process.argv[3] ?? -8)
const oy = Number(process.argv[4] ?? -14)
const out = layout({ k, ox, oy })
console.log(`k=${k} origin=(${ox},${oy}) bigR=${out.bigR.toFixed(1)} smallR=${out.smallR.toFixed(1)} ratio=1:3`)
console.log(' ' + out.markup)
+327
View File
@@ -0,0 +1,327 @@
// Actual Electron preload/IPC + installed OMP and HTTP workers, using loopback model responses only.
const fs = require('node:fs/promises'), path = require('node:path')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/subagent-app-'))
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
await fs.writeFile(path.join(directory, 'workspace', 'fixture.txt'), 'PROJECT_FIXTURE_CONTENT')
require('esbuild').buildSync({ stdin: { resolveDir: process.cwd(), contents: [
"export * from './src/main/subagents/scheduler';", "export * from './src/main/subagents/worker';",
"export * from './src/main/agent/loop';", "export * from './src/main/gateway';",
"export * from './src/shared/message-parts';"
].join('\n') }, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], outfile: path.join(directory, 'host.cjs'), logLevel: 'warning' })
const env = { ...process.env, YCODE_AGENTS_TEST_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'),
YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_SYSTEM_INTEGRATION: '1', YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data; process.stdout.write(data) })
child.stderr.on('data', data => { output += data })
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output)
const report = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8').catch(() => '{"result":"MISSING"}'))
console.log(JSON.stringify({ directory, result: report.result, exitCode: code }))
if (code || report.result !== 'PASS') { console.error(output.slice(-8000)); process.exitCode = 1 }
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron'), assert = require('node:assert/strict'), http = require('node:http')
const directory = process.env.YCODE_AGENTS_TEST_DIRECTORY, workspaceRoot = path.join(directory, 'workspace')
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
app.on('window-all-closed', () => {})
BrowserWindow.prototype.show = function () {}
const checks = [], requests = [], models = new Map(), rendererErrors = []
let server, main, phase = 'boot', apiBase, host, direct
const pause = ms => new Promise(done => setTimeout(done, ms))
function check(label, condition = true) { assert.ok(condition, label); checks.push(label); console.log('AGENT_CHECK ' + label) }
function sse(res, model, call, final) {
res.writeHead(200, { 'content-type': 'text/event-stream' })
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: model + '-call-' + Math.random(), type: 'function', function: { name: call.name, arguments: JSON.stringify(call.args) } }] }
: { role: 'assistant', content: final }
res.end([{ id: 'controlled', object: 'chat.completion.chunk', model, choices: [{ index: 0, delta, finish_reason: null }] },
{ id: 'controlled', object: 'chat.completion.chunk', model, choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 } }]
.map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
}
const read = (source, window = main) => window.webContents.executeJavaScript(source)
const invoke = (namespace, method, ...args) => read('window.ycode[' + JSON.stringify(namespace) + '][' + JSON.stringify(method) + '](...' + JSON.stringify(args) + ')')
async function until(operation, label, attempts = 600) {
for (let index = 0; index < attempts; index++) { const value = await operation(); if (value) return value; await pause(30) }
throw Error('Timeout: ' + label)
}
const profile = model => ({ id: model, name: model, model, providerId: 'custom', protocol: 'openai', baseUrl: apiBase + '/v1', apiKey: 'loopback-only',
endpoint: { api: 'openai-completions', baseUrl: apiBase + '/v1', checkedAt: Date.now() }, supportsToolCall: true,
supportsImages: false, supportsReasoning: false, contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 })
async function start(model, permissionMode = 'full-access') {
models.set(model, { stage: 0, childActive: 0, peak: 0, held: false, rootAfterOff: false })
await invoke('models', 'save', profile(model))
const workspace = await invoke('workspace', 'attach', workspaceRoot)
const session = await invoke('session', 'create', model, { modelId: model, workspaceId: workspace.attachedWorkspaceId })
const run = await invoke('chat', 'send', { sessionId: session.id, text: 'ROOT_ONLY_SECRET_MARKER ' + model, permissionMode })
return { session, run }
}
async function finish(session) {
return until(async () => {
const assistant = (await invoke('session', 'messages', session.id)).filter(message => message.role === 'assistant').at(-1)
return assistant && assistant.status !== 'streaming' && !await invoke('chat', 'activeRun', session.id) ? assistant : null
}, 'terminal assistant ' + session.id, 1600)
}
async function taskWindow(session) {
await invoke('session', 'openWindow', session.id)
const window = await until(() => BrowserWindow.getAllWindows().find(item => item !== main && item.getTitle() === 'YCode 任务'), 'task window')
window.setContentSize(1440, 900)
await until(() => read('!!document.querySelector(".composer__area .conversation-team-collaboration__trigger")', window), 'input-area members')
window.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
return window
}
async function handler(req, res) {
try {
let raw = ''; for await (const chunk of req) raw += chunk
const body = JSON.parse(raw || '{}'), model = body.model, state = models.get(model)
assert.ok(state, 'known controlled model')
if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end('{}'); return }
const users = body.messages.filter(message => message.role === 'user')
const worker = users.some(message => JSON.stringify(message.content).includes('你是 YCode 主智能体委派的执行成员'))
const serialized = JSON.stringify(body.messages), toolResults = body.messages.filter(message => message.role === 'tool')
requests.push({ model, worker, body })
if (worker) {
assert.ok(!serialized.includes('ROOT_ONLY_SECRET_MARKER'), 'child does not inherit private root history')
const names = body.tools.map(tool => tool.function.name)
assert.ok(!names.includes('spawn_agent') && !names.includes('execute_command') && !names.includes('browser'), 'worker schema is bounded')
const isA = serialized.includes('CHILD_A'), write = isA || model.includes('approval')
const marker = isA ? 'CHILD_A_RESULT' : 'CHILD_B_RESULT'
if (model.includes('held')) {
state.held = true
return // Held response is aborted by the application's child cancellation.
}
if (toolResults.length === 0) {
state.childActive++; state.peak = Math.max(state.peak, state.childActive)
await pause(250)
state.childActive--
if (write) {
assert.ok(names.includes('write_file'), 'declared writer receives write tools')
return sse(res, model, { name: 'write_file', args: { path: model.includes('approval') ? 'approval.txt' : 'agent-a.txt', content: 'WRITTEN_BY_CHILD_A' } })
}
assert.ok(!names.includes('write_file'), 'read-only child never receives write tools')
return sse(res, model, { name: 'read_file', args: { path: 'fixture.txt' } })
}
assert.ok(write ? model.includes('approval') ? serialized.includes('拒绝') || serialized.includes('未批准') : serialized.includes('已写入')
: serialized.includes('PROJECT_FIXTURE_CONTENT'), 'actual child tool result returns to model')
return sse(res, model, undefined, marker)
}
if (model.includes('disabled')) {
assert.ok(!body.tools.some(tool => tool.function.name === 'spawn_agent'), 'disabled switch hides scheduling')
if (state.stage++ === 0) return sse(res, model, { name: 'spawn_agent', args: { name: 'forbidden', task: 'forbidden' } })
assert.ok(serialized.includes('禁用') || serialized.includes('Tool spawn_agent not found'), 'stale spawn name is rejected by host or native tool registry')
return sse(res, model, undefined, 'DISABLED_PARENT_COMPLETE')
}
const held = model.includes('held'), approval = model.includes('approval')
if (model.includes('followup')) {
const next = state.stage++
if (next === 0) return sse(res, model, { name: 'spawn_agent', args: { name: '持续核对', task: 'CHILD_B 读取 fixture.txt 并报告结果' } })
if (next === 1 || next === 3) return sse(res, model, { name: 'wait_agent', args: { timeoutMs: 30000 } })
if (next === 2) {
const content = toolResults[0].content
const result = JSON.parse(typeof content === 'string' ? content : content.map(part => part.text || '').join(''))
return sse(res, model, { name: 'send_agent_message', args: { id: result.id, message: 'FOLLOWUP_CONTEXT 再核对一次,沿用先前结果' } })
}
assert.ok(serialized.includes('CHILD_B_RESULT'), 'follow-up result reaches parent')
return sse(res, model, undefined, 'FOLLOWUP_PARENT_COMPLETE')
}
if (state.stage++ === 0) {
assert.ok(body.tools.some(tool => tool.function.name === 'spawn_agent'), 'enabled root sees scheduling')
return sse(res, model, { name: 'spawn_agent', args: { name: '文件分析 A', task: 'CHILD_A 独立完成任务并报告验收结果',
access: held ? 'read-only' : 'write', ...(held ? {} : { files: [approval ? 'approval.txt' : 'agent-a.txt'] }) } })
}
if (held || approval) {
if (state.stage === 2) return sse(res, model, { name: 'wait_agent', args: { timeoutMs: 30000 } })
if (model.includes('toggle-held')) {
state.rootAfterOff = !body.tools.some(tool => tool.function.name === 'spawn_agent')
assert.ok(state.rootAfterOff, 'next native model request loses spawn after disabling')
}
return sse(res, model, undefined, 'CONTROL_PARENT_COMPLETE')
}
if (state.stage === 2) return sse(res, model, { name: 'spawn_agent', args: { name: '资料核对 B', task: 'CHILD_B 读取 fixture.txt 并报告结果' } })
if (state.stage === 3) return sse(res, model, undefined, 'EARLY_CANDIDATE')
assert.ok(serialized.includes('本地调度器已等待子任务') && serialized.includes('CHILD_A_RESULT') && serialized.includes('CHILD_B_RESULT'), 'host requires synthesis with actual child results')
return sse(res, model, undefined, 'MAIN_INTEGRATED_RESULT')
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: String(error) } }))
}
}
app.whenReady().then(async () => {
host = require(path.join(directory, 'host.cjs'))
server = http.createServer(handler)
await new Promise(done => server.listen(0, '127.0.0.1', done))
apiBase = 'http://127.0.0.1:' + server.address().port
phase = 'http-root-and-workers'
const model = 'agent-http', config = { api: 'openai-completions', baseUrl: apiBase + '/v1', model, apiKey: 'loopback-only', temperature: 0, maxToolRounds: 10 }
models.set(model, { stage: 0, childActive: 0, peak: 0 })
const controller = new AbortController(), events = []
direct = new host.SubagentScheduler({ runId: 'http-parent', workspaceRoot, modelName: model, enabled: true, signal: controller.signal,
emit: event => events.push(event), capabilities: { specs: [], execute: async () => undefined },
worker: (task, tools) => host.createSubagentWorker({ task, tools, model: { profile: profile(model), config }, workspaceRoot,
parentRunId: 'http-parent', permissionMode: 'full-access', native: false, emitParent: event => events.push(event) })
})
const result = await host.runTurn({ runId: 'http-parent', sessionId: 'http-session', workspaceRoot, history: [], userText: 'ROOT_ONLY_SECRET_MARKER',
gateway: new host.Gateway(config), model, maxToolRounds: 10, signal: controller.signal, permissionMode: 'full-access',
capabilityTools: direct.tools, beforeFinish: () => direct.beforeFinish(), emit: event => events.push(event) })
check('HTTP: separate model requests execute and integrate both child results', result.content.includes('MAIN_INTEGRATED_RESULT'))
check('HTTP: actual child provider requests overlap', models.get(model).peak >= 2)
check('HTTP: real delegated write reaches the declared file', await fs.readFile(path.join(workspaceRoot, 'agent-a.txt'), 'utf8') === 'WRITTEN_BY_CHILD_A')
await direct.close(); direct = undefined
phase = 'native-cancelled-write-drain'
const drainModel = 'agent-drain', drainEvents = [], drainController = new AbortController()
models.set(drainModel, { stage: 0, childActive: 0, peak: 0 })
let finishWrite, writeStarted = false, workerSettled = false
const drainWorker = await host.createSubagentWorker({
task: { id: 'drain-child', name: 'write drain', task: 'CHILD_A', access: 'write', files: ['agent-a.txt'], modelName: drainModel, status: 'running', createdAt: Date.now(), events: [], tools: [] },
tools: { specs: [], filterSpecs: specs => specs.filter(spec => ['read_file', 'list_dir', 'write_file', 'edit_file'].includes(spec.function.name)),
async execute(name, args) {
if (name !== 'write_file') return undefined
writeStarted = true
return new Promise(resolve => { finishWrite = async () => {
const target = path.join(workspaceRoot, args.path), oldContent = await fs.readFile(target, 'utf8')
await fs.writeFile(target, 'DRAINED_WRITE')
resolve({ ok: true, content: '已写入', data: { path: target, oldContent, newContent: 'DRAINED_WRITE' } })
} })
}
},
model: { profile: profile(drainModel), config: { ...config, model: drainModel } }, workspaceRoot,
parentRunId: 'drain-parent', native: true, permissionMode: 'full-access', emitParent() {}
})
const draining = drainWorker.run('CHILD_A 写入后报告', drainController.signal, event => drainEvents.push(event))
draining.then(() => { workerSettled = true }, () => { workerSettled = true })
await until(() => writeStarted, 'write already in progress')
drainController.abort(); await pause(80)
check('native child cancellation does not release an unfinished local write', !workerSettled)
await finishWrite(); await draining
check('cancelled native child retains actual file before/after data for rewind', drainEvents.some(event => event.type === 'tool_result' && event.call.result?.newContent === 'DRAINED_WRITE'))
await drainWorker.dispose()
phase = 'production-boot'
delete process.env.YCODE_DISABLE_GPU; delete process.env.YCODE_DISABLE_GPU_SANDBOX
await import(require('node:url').pathToFileURL(path.resolve('out/main/index.mjs')).href)
main = await until(() => BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode' && !window.webContents.isLoading()), 'main app')
await until(() => read('!!window.ycode'), 'preload')
await read('window.__agentEvents=[];window.ycode.chat.onEvent(event=>window.__agentEvents.push({runId:event.runId,type:event.type,sequence:event.sequence}));true')
main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
phase = 'production-native'
await invoke('agents', 'update', { teamManagement: true })
const normal = await start('agent-native')
const assistant = await finish(normal.session)
check('APP: installed OMP completes the root and both child model conversations', assistant.status === 'done' && assistant.content.includes('MAIN_INTEGRATED_RESULT'))
const emitted = await read('window.__agentEvents.filter(event=>event.runId===' + JSON.stringify(normal.run.runId) + ')')
check('APP: terminal event follows every child snapshot with a strictly newer sequence', emitted.at(-1).type === 'done'
&& emitted.every((event, index) => index === 0 || event.sequence > emitted[index - 1].sequence))
const agents = assistant.parts.filter(part => part.type === 'subagent').map(part => part.agent)
check('APP: two completed children retain separate transcripts and tool results', agents.length === 2 && agents.every(agent => agent.status === 'completed' && agent.tools.some(tool => tool.status === 'ok')))
check('APP: root and children keep the same selected custom model', agents.every(agent => agent.modelName === 'agent-native'))
const nativeCount = requests.filter(request => request.model === 'agent-native').length
check('APP: reported usage sums all root and child requests without replacing context usage', assistant.runUsage?.total.totalTokens === nativeCount * 110
&& assistant.runUsage.main.totalTokens > 0 && assistant.runUsage.subagents.totalTokens > 0 && assistant.contextUsage?.usage.totalTokens === 110)
check('APP: isolated observer transport still reports prepared request categories', assistant.contextBreakdown?.counts.system > 0 && assistant.contextBreakdown?.counts.tools > 0)
const disk = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
check('APP: agent details and aggregated usage survive disk persistence', disk.sessions.find(session => session.id === normal.session.id).messages.some(message => message.id === assistant.id && message.runUsage?.total.totalTokens === nativeCount * 110 && message.parts.filter(part => part.type === 'subagent').length === 2))
const window = await taskWindow(normal.session)
check('UI: completed team has one input-area entry and no reply duplicates', await read('document.querySelectorAll(".composer__area .conversation-team-collaboration__trigger").length===1&&!document.querySelector(".chat-scroll .conversation-team-collaboration")&&document.querySelector(".conversation-team-collaboration__trigger").textContent.includes("2 位团队成员")&&!document.querySelector(".yc-subagent")&&!!document.querySelector(".yc-run-usage")', window))
await read('document.querySelector(".conversation-team-collaboration__trigger").click();document.querySelector(".yc-run-usage>summary").click();true', window)
await until(() => read('document.querySelectorAll(".conversation-team-collaboration__member").length===2', window), 'two members')
await read('document.querySelector(".conversation-team-collaboration__member").click();true', window)
await until(() => read('!!document.querySelector(".conversation-member-detail .msg--assistant")', window), 'member conversation detail')
check('UI: selected member opens its child conversation in the existing right detail surface', await read('!!document.querySelector("[data-view-id=detail-panel] .conversation-member-detail")&&document.querySelector(".conversation-team-collaboration__member[aria-current=true]")?.dataset.subagentId===document.querySelector(".conversation-member-detail")?.dataset.subagentId', window))
await read('document.querySelector(".conversation-member-detail__assignment-toggle").click();true', window)
check('UI: task assignment is separately expandable and child tools use the normal renderer', await read('document.querySelector(".conversation-member-detail__assignment-detail").textContent.includes("CHILD_A")&&!!document.querySelector(".conversation-member-detail .cr-captured-tool")&&!document.querySelector(".yc-subagent__event")', window))
await pause(350)
await fs.writeFile(path.join(directory, 'agents-light.png'), (await window.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
await read('localStorage.setItem("ycode:theme","dark");window.dispatchEvent(new StorageEvent("storage",{key:"ycode:theme",newValue:"dark"}));true', window)
await until(() => read('document.documentElement.dataset.theme==="dark"&&document.querySelector(".chat-pane")?.dataset.crTheme==="dark"', window), 'dark conversation theme')
await pause(400)
await fs.writeFile(path.join(directory, 'agents-dark.png'), (await window.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
window.destroy()
phase = 'disabled-host-gate'
await invoke('agents', 'update', { teamManagement: false })
const disabled = await start('agent-disabled')
const disabledMessage = await finish(disabled.session)
check('APP: disabled switch hides tools and rejects an explicit stale tool name', disabledMessage.status === 'done'
&& disabledMessage.content.includes('DISABLED_PARENT_COMPLETE') && !disabledMessage.parts.some(part => part.type === 'subagent'))
phase = 'toggle-during-run'
await invoke('agents', 'update', { teamManagement: true })
const toggled = await start('agent-toggle-held')
await until(() => models.get('agent-toggle-held').held, 'held child request')
await invoke('agents', 'update', { teamManagement: false })
const toggledMessage = await finish(toggled.session)
check('APP: switch-off cancels the running native child while the parent continues', toggledMessage.status === 'done'
&& toggledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled') && models.get('agent-toggle-held').rootAfterOff)
phase = 'parent-cancel'
await invoke('agents', 'update', { teamManagement: true })
const cancelled = await start('agent-cancel-held')
await until(() => models.get('agent-cancel-held').held, 'cancellable child')
await invoke('chat', 'cancel', cancelled.run.runId)
const cancelledMessage = await finish(cancelled.session)
check('APP: cancelling the parent also cancels its native children', cancelledMessage.status === 'cancelled'
&& cancelledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled'))
phase = 'individual-cancel'
const single = await start('agent-single-held')
await until(() => models.get('agent-single-held').held, 'individual child')
const snapshot = await invoke('chat', 'activeRun', single.session.id)
const childId = snapshot.messages.at(-1).parts.find(part => part.type === 'subagent').agent.id
check('APP: child cancel IPC rejects an unknown identifier', !(await invoke('chat', 'cancelAgent', single.run.runId, 'unknown')).cancelled)
const liveWindow = await taskWindow(single.session)
await read('document.querySelector(".conversation-team-collaboration__trigger").click();true', liveWindow)
await until(() => read('!!document.querySelector(".conversation-team-collaboration__member[data-status=running]")', liveWindow), 'running member')
await read('document.querySelector(".conversation-team-collaboration__member[data-status=running]").click();true', liveWindow)
await until(() => read('!!document.querySelector(".conversation-member-detail[data-status=running] .yc-member-stop")', liveWindow), 'live member detail')
check('UI: running child is reachable above the input before the parent finishes', await read('!!document.querySelector(".composer__area .conversation-team-collaboration__member[data-status=running]")', liveWindow))
await read('document.querySelector(".yc-member-stop").click();true', liveWindow)
const singleMessage = await finish(single.session)
check('APP: individual cancellation leaves the parent able to finish', singleMessage.status === 'done' && singleMessage.content.includes('CONTROL_PARENT_COMPLETE'))
await until(() => read('!!document.querySelector(".conversation-member-detail[data-status=cancelled]")&&!document.querySelector(".yc-member-stop")&&document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow), 'live terminal UI')
check('UI: stopping a child updates the open member detail and completes the main reply')
await read('document.querySelector(".conversation-member-detail__close").click();true', liveWindow)
await until(() => read('!document.querySelector(".conversation-member-detail")', liveWindow), 'close member detail')
check('UI: closing member detail leaves the completed parent conversation intact', await read('document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow))
liveWindow.destroy()
phase = 'default-permission-approval'
const approval = await start('agent-approval', 'default-sandbox')
const pending = await until(async () => {
const message = (await invoke('session', 'messages', approval.session.id)).at(-1)
return message.interactions?.find(item => item.kind === 'approval' && item.status === 'pending')
}, 'child write approval')
check('APP: child write approval appears under the parent run with the child name', pending.runId === approval.run.runId && pending.title.includes('文件分析 A'))
await invoke('chat', 'respond', approval.run.runId, pending.id, { kind: 'approval', optionId: 'reject-once' })
const approvedMessage = await finish(approval.session)
check('APP: refusing child approval prevents the write and returns control', approvedMessage.status === 'done'
&& !await fs.stat(path.join(workspaceRoot, 'approval.txt')).then(() => true, () => false))
phase = 'cancel-pending-approval'
const approvalCancel = await start('agent-approval-cancel', 'default-sandbox')
await until(async () => (await invoke('session', 'messages', approvalCancel.session.id)).at(-1).interactions?.some(item => item.status === 'pending'), 'approval to cancel')
await invoke('agents', 'update', { teamManagement: false })
const approvalCancelledMessage = await finish(approvalCancel.session)
check('APP: disabling during child approval cancels the prompt without hanging the parent', approvalCancelledMessage.status === 'done'
&& approvalCancelledMessage.interactions.every(item => item.status !== 'pending')
&& approvalCancelledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled'))
phase = 'native-followup'
await invoke('agents', 'update', { teamManagement: true })
const followup = await start('agent-followup')
const followupMessage = await finish(followup.session)
check('APP: a completed native child accepts follow-up instructions in its own context', followupMessage.status === 'done'
&& followupMessage.content.includes('FOLLOWUP_PARENT_COMPLETE')
&& requests.some(request => request.model === 'agent-followup' && request.worker && JSON.stringify(request.body.messages).includes('FOLLOWUP_CONTEXT')
&& JSON.stringify(request.body.messages).includes('CHILD_B_RESULT')))
phase = 'delete-native-history'
const transcriptDirectory = path.join(directory, 'data', 'subagent-sessions', 'session-' + Buffer.from(normal.session.id).toString('base64url'))
check('APP: native child transcripts belong to the parent session directory', await fs.stat(transcriptDirectory).then(info => info.isDirectory(), () => false))
await invoke('session', 'remove', normal.session.id)
check('APP: deleting the parent removes its native child transcripts', !await fs.stat(transcriptDirectory).then(() => true, () => false))
check('UI: no renderer errors', rendererErrors.length === 0)
phase = 'done'
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requestCounts: [...models.keys()].map(model => ({ model, requests: requests.filter(row => row.model === model).length })), rendererErrors }, null, 2))
console.log('SUBAGENT_APP_PASS ' + directory)
server.closeAllConnections(); server.close(); app.quit()
}).catch(async error => {
console.error(error)
await direct?.close().catch(() => undefined)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', phase, error: String(error.stack || error), checks, requests }, null, 2))
server?.closeAllConnections(); server?.close(); app.exit(1)
})
}
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../src/shared/types'
import type { SubagentSnapshot } from '../src/shared/subagents'
import { collectTurnMembers, projectMemberMessage, selectComposerTeam, sortMembers } from '../src/renderer/src/workbuddy/subagents/member-view'
import { useWorkbenchStore } from '../src/renderer/src/stores/workbench'
let checks = 0
const check = (name: string, condition: unknown): void => { assert.ok(condition, name); checks++; console.log('PASS ' + name) }
const base: SubagentSnapshot = { id: 'a', name: '分析', task: '检查消息', status: 'running', access: 'read-only', files: [], modelName: 'local', createdAt: 1, events: [], tools: [] }
const parent: ChatMessage = { id: 'parent-a', role: 'assistant', content: 'parent', createdAt: 1, parts: [
{ type: 'subagent', agent: { ...base, status: 'completed' } },
{ type: 'subagent', agent: { ...base, id: 'b', name: '验收', status: 'queued' } },
{ type: 'subagent', agent: base }
] }
const members = collectTurnMembers(parent)
check('same child snapshot upserts without duplicating membership', members.length === 2 && members[0].agent === base)
check('membership remains scoped to its own assistant turn', collectTurnMembers({ ...parent, id: 'next-turn', parts: [] }).length === 0)
const next: ChatMessage = { ...parent, id: 'next-turn', parts: [], status: 'streaming' }
check('input activity belongs to the active assistant rather than an older team', selectComposerTeam([parent, next], next.id) === undefined)
check('an idle latest reply without children does not resurrect an earlier team', selectComposerTeam([parent, { ...next, status: 'done' }]) === undefined)
check('hydration with a missing active assistant never falls back to stale members', selectComposerTeam([parent], 'not-loaded') === undefined)
check('latest completed team remains reachable from the input area', selectComposerTeam([{ ...parent, status: 'done' }])?.assistantId === parent.id && selectComposerTeam([parent])?.members.length === 2)
const completed = [{ ...members[0], agent: { ...base, status: 'completed' as const } }, members[1]]
const sorted = sortMembers(completed)
check('running or queued members sort first without changing task ordinals', sorted[0].agent.id === 'b' && sorted[0].ordinal === 2 && completed[0].agent.id === 'a')
const tool = { id: '1:tool', name: 'read_file', args: { path: 'src/a.ts' }, argsRaw: '{"path":"src/a.ts"}', status: 'ok' as const, output: 'REAL_FILE_OUTPUT' }
const child = { ...base, events: [
{ id: 'thought', kind: 'reasoning' as const, text: '先核对入口' },
{ id: tool.id, kind: 'tool' as const, name: tool.name, status: 'ok' as const, text: 'DISPLAY_SUMMARY' },
{ id: 'reply', kind: 'text' as const, text: 'partial reply' }
], tools: [tool], result: 'complete reply', status: 'completed' as const, finishedAt: 2000 }
const before = JSON.stringify(child)
const projected = projectMemberMessage(child)
check('detail preserves thinking, tool and reply order', projected.parts?.map(part => part.type).join(',') === 'reasoning,tool,text')
check('detail uses actual child tool arguments and output', projected.tools?.[0] === tool && projected.tools[0].output === 'REAL_FILE_OUTPUT')
check('complete child result replaces its truncated streaming tail exactly once', projected.content === 'complete reply' && projected.parts?.filter(part => part.type === 'text').length === 1)
check('projection never mutates the persisted child snapshot', JSON.stringify(child) === before)
check('member reasoning is finished only for a terminal child', projected.parts?.[0].type === 'reasoning' && projected.parts[0].finished === true && projectMemberMessage({ ...child, status: 'running' }).status === 'streaming')
check('legacy snapshots retain real tool records when events are absent', projectMemberMessage({ ...base, tools: [tool] }).tools?.[0] === tool)
check('cancelled children remain cancelled in the detail view', projectMemberMessage({ ...base, status: 'cancelled' }).status === 'cancelled')
const api = useWorkbenchStore.getState()
api.openView('session-a', 'fileTree')
const tab = useWorkbenchStore.getState().sessions['session-a'].activeTabId!
api.openMember('session-a', 'parent-a', 'a')
check('opening a member preserves existing workbench tabs and opens its surface', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-a'].open && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 1)
api.openMember('session-b', 'parent-b', 'b')
check('member selection is isolated between conversations', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
api.select('session-a', tab)
check('selecting a workbench tab exits the member surface without removing child history', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].activeTabId === tab && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
api.openMember('session-a', 'parent-a', 'a')
await api.openResource('session-a', 'session-a', 'artifact:demo', undefined, { kind: 'artifact', path: 'artifact:demo', name: '产物', content: 'content' })
check('opening an artifact from child tools replaces the member surface', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 2)
api.closeMember('session-b')
check('closing a member preserves its conversation workbench', !useWorkbenchStore.getState().sessions['session-b'].member)
console.log('SUBAGENT_DISPLAY_PASS ' + JSON.stringify({ checks }))
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SubagentScheduler, type SubagentWorker } from '../src/main/subagents/scheduler'
import { executeTurnTool, type ConversationToolContext } from '../src/main/agent/conversation-tools'
import { toolSpecs } from '../src/main/agent/tools'
import type { CapabilityToolSnapshot } from '../src/main/capabilities/runtime'
import type { ChatEvent, ChatMessage, ToolCallRecord } from '../src/shared/types'
import { applyAssistantEvent } from '../src/shared/message-parts'
import { getRevertChanges } from '../src/shared/revert'
import { assistantWireHistory } from '../src/main/agent/history'
const root = await mkdtemp(join(tmpdir(), 'ycode-agents-'))
const checks: string[] = []
function check(label: string, condition: unknown): void { assert.ok(condition, label); checks.push(label); console.log('PASS ' + label) }
const pause = (ms = 10): Promise<void> => new Promise(done => setTimeout(done, ms))
const base: CapabilityToolSnapshot = { specs: [], execute: async () => undefined }
function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: string) {
const id = Math.random().toString(36), controller = new AbortController(), events: ChatEvent[] = []
const policies = new Map<string, CapabilityToolSnapshot>(), prompts: string[] = [], release = new Map<string, () => void>()
let current = 0, peak = 0, disposed = 0
const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'selected-model', enabled,
signal: controller.signal, emit: event => events.push(event), capabilities: base, timeoutMs,
worker: async (task, policy): Promise<SubagentWorker> => {
policies.set(task.id, policy)
return { async run(prompt, signal, emit) {
current++; peak = Math.max(peak, current); prompts.push(prompt)
emit({ runId: 'child', type: 'usage', usage: { inputTokens: 20, outputTokens: 5, totalTokens: 25 } })
try {
if (hold) await new Promise<void>((done, reject) => {
const onAbort = (): void => { signal.removeEventListener('abort', onAbort); reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })) }
signal.addEventListener('abort', onAbort, { once: true })
release.set(task.id, () => { signal.removeEventListener('abort', onAbort); done() })
if (signal.aborted) onAbort()
})
emit({ runId: 'child', type: 'text', delta: 'RESULT:' + task.name })
return { content: resultText ?? 'RESULT:' + task.name, tools: [] }
} finally { current-- }
}, async dispose() { disposed++ } }
}
})
const ctx: ConversationToolContext = { runId: id, toolCallId: 'test-call', sessionId: 'parent-' + id, workspaceRoot: root,
permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: event => events.push(event) }
const call = (name: string, args: Record<string, unknown> = {}) => executeTurnTool(name, args, ctx, scheduler.tools)
const spawn = async (name: string, extra = {}) => {
const result = await call('spawn_agent', { name, task: '独立调查并给出验收结果', ...extra })
assert.equal(result.ok, true, result.content); return JSON.parse(result.content) as { id: string; status: string }
}
return { scheduler, controller, ctx, call, spawn, events, policies, prompts, release,
get peak() { return peak }, get current() { return current }, get disposed() { return disposed } }
}
{
const state = setup(false)
check('disabled switch hides spawn and rejects a direct stale-name invocation', !state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent') && !(await state.call('spawn_agent', { name: 'x', task: 'x' })).ok)
state.scheduler.setEnabled(true)
check('enabling immediately exposes real scheduling tools', state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent'))
check('plan mode cannot bypass the scheduling mode gate', !(await executeTurnTool('spawn_agent', { name: 'x', task: 'x' }, { ...state.ctx, agentMode: 'plan' }, state.scheduler.tools)).ok)
const spawned = await Promise.all([1, 2, 3, 4, 5, 6].map(i => state.spawn('任务' + i)))
await pause()
check('six tasks use five workers with the sixth queued', state.peak === 5 && JSON.parse((await state.call('list_agents')).content).filter((view: { status: string }) => view.status === 'queued').length === 1)
const stranger = setup()
check('cross-turn agent identifiers cannot be read or cancelled', !(await stranger.call('cancel_agent', { id: spawned[0].id })).ok)
await stranger.spawn('跨会话排队')
check('the concurrency budget also applies across root conversations', stranger.current === 0)
await stranger.scheduler.close()
state.scheduler.setEnabled(false)
await state.scheduler.beforeFinish()
check('disabling cancels running and queued children without aborting the parent', !state.controller.signal.aborted && JSON.parse((await state.call('list_agents')).content).every((view: { status: string }) => view.status === 'cancelled'))
check('disabled switch also denies follow-up messages', !(await state.call('send_agent_message', { id: spawned[0].id, message: '继续' })).ok)
await state.scheduler.close()
check('cleanup disposes the five created child runtimes', state.disposed === 5)
}
{
const state = setup(true, false, 2000, '\u0000'.repeat(60000))
await state.spawn('长结果')
await pause()
const result = await state.call('wait_agent', { timeoutMs: 1000 })
check('large escaped results remain bounded complete JSON', result.content.length <= 30000 && JSON.parse(result.content)[0].truncated)
await state.scheduler.close()
}
{
const state = setup()
const writer = await state.spawn('写文件', { access: 'write', files: ['owned.txt'] })
check('write declarations reject directories, escapes and glob patterns', !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['.'] })).ok
&& !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['../escape.txt'] })).ok
&& !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['*.ts'] })).ok)
await pause()
const policy = state.policies.get(writer.id)!
const workerCtx = { ...state.ctx, runId: 'worker', sessionId: 'child', toolCallId: 'child-write' }
check('child schemas hide commands, browsers and nested scheduling', policy.filterSpecs!(toolSpecs()).every(tool => ['list_dir', 'read_file', 'write_file', 'edit_file'].includes(tool.function.name)))
check('unlisted shell and nested spawn calls are rejected by the host', !(await executeTurnTool('execute_command', { command: 'echo forbidden' }, workerCtx, policy)).ok
&& !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok)
check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok)
check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok)
check('parent write and shell cannot collide with a child lease', !(await state.call('write_file', { path: 'owned.txt', content: 'parent' })).ok
&& !(await state.call('execute_command', { command: 'echo no' })).ok)
const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy)
check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child')
state.release.get(writer.id)!()
await state.call('wait_agent', { ids: [writer.id], timeoutMs: 1000 })
const parentWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent-after' })
check('file lease is released after child completion', parentWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'parent-after')
const childTool: ToolCallRecord = { id: 'child-write', name: 'write_file', status: 'ok', args: {}, argsRaw: '{}', result: childWrite.data, metadata: childWrite.metadata }
const parentTool: ToolCallRecord = { ...childTool, id: 'parent-write', result: parentWrite.data, metadata: parentWrite.metadata }
const view = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent')
assert.ok(view?.type === 'part' && view.part.type === 'subagent')
const messages: ChatMessage[] = [{ id: 'user', role: 'user', content: 'change', createdAt: 0 },
{ id: 'assistant', role: 'assistant', content: '', createdAt: 1, tools: [parentTool],
parts: [{ type: 'subagent', agent: { ...view.part.agent, tools: [childTool] } }], status: 'done' }]
const changes = getRevertChanges(messages, 'user')
check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after')
await state.scheduler.close()
}
{
const state = setup()
const reader = await state.spawn('只读')
await pause()
const policy = state.policies.get(reader.id)!
check('read-only child cannot write even with inherited full-access permission', !(await executeTurnTool('write_file', { path: 'read-only.txt', content: 'no' }, { ...state.ctx, runId: 'reader' }, policy)).ok)
const after = state.scheduler.beforeFinish()
state.release.get(reader.id)!()
check('parent finalization waits and supplies unread child results for synthesis', (await after)?.includes('RESULT:只读'))
check('collected results do not create an infinite finalization loop', await state.scheduler.beforeFinish() === undefined)
const part = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent')
assert.ok(part?.type === 'part')
check('HTTP cold history preserves child results as explicitly labeled task data', JSON.stringify(assistantWireHistory({
id: 'history', role: 'assistant', content: '', createdAt: 0, parts: [part.part]
})).includes('RESULT:只读'))
const follow = await state.call('send_agent_message', { id: reader.id, message: '补充核对' })
await pause()
state.release.get(reader.id)!()
await state.call('wait_agent', { ids: [reader.id], timeoutMs: 1000 })
check('completed child keeps its runtime for follow-up instructions', follow.ok && state.prompts.length === 2 && state.prompts[1] === '补充核对')
await state.scheduler.close()
}
{
const state = setup()
await state.spawn('取消测试')
await pause()
state.controller.abort()
await state.scheduler.close()
check('parent cancellation ends all children and releases their runtimes', state.current === 0 && state.disposed === 1)
}
{
const state = setup(true, true, 50)
await state.spawn('超时')
await pause(100)
const result = JSON.parse((await state.call('list_agents')).content)
check('child execution timeout reaches a terminal state', result[0].status === 'cancelled')
await state.scheduler.close()
}
{
const state = setup()
for (let i = 0; i < 16; i++) await state.spawn('有界任务' + i)
check('task creation is bounded per root turn', !(await state.call('spawn_agent', { name: 'overflow', task: 'overflow' })).ok)
const first = state.events.find(event => event.type === 'part' && event.part.type === 'subagent')
assert.ok(first?.type === 'part' && first.part.type === 'subagent')
let message: ChatMessage = { id: 'recover', role: 'assistant', content: '', createdAt: 0 }
message = applyAssistantEvent(message, first)
message = applyAssistantEvent(message, { ...first, part: { type: 'subagent', agent: { ...first.part.agent, status: 'running' } } })
check('streaming agent snapshots upsert in place', message.parts?.length === 1)
message = applyAssistantEvent(message, { runId: state.ctx.runId, type: 'cancelled' })
check('cold interrupted history never leaves a child shown as running', message.parts?.[0].type === 'subagent' && message.parts[0].agent.status === 'cancelled')
await state.scheduler.close()
}
await writeFile(join(root, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
console.log('SUBAGENT_REGRESSION_PASS ' + JSON.stringify({ checks: checks.length, directory: root }))
+26
View File
@@ -0,0 +1,26 @@
import { mkdir, writeFile, rename } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { resolve, join } from 'node:path'
const directory = resolve('resources/model-metadata')
const source = 'https://models.dev/api.json'
const response = await fetch(source, { signal: AbortSignal.timeout(30000), redirect: 'error' })
if (!response.ok) throw Error(`models.dev HTTP ${response.status}`)
const data = Buffer.from(await response.arrayBuffer())
if (data.length > 24 * 1024 * 1024) throw Error('Model catalog exceeds 24MiB')
const parsed = JSON.parse(data)
const providers = Object.values(parsed)
if (providers.length < 3 || !providers.every(p => p && typeof p.models === 'object')) throw Error('Invalid model catalog')
const sha256 = createHash('sha256').update(data).digest('hex')
const revisionResponse = await fetch('https://api.github.com/repos/anomalyco/models.dev/commits/dev', { signal: AbortSignal.timeout(15000) })
const revision = revisionResponse.ok ? (await revisionResponse.json()).sha : undefined
const licenseResponse = await fetch('https://raw.githubusercontent.com/anomalyco/models.dev/dev/LICENSE', { signal: AbortSignal.timeout(15000) })
if (!licenseResponse.ok) throw Error('Unable to retrieve upstream license')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'models.dev.json.tmp'), data)
await rename(join(directory, 'models.dev.json.tmp'), join(directory, 'models.dev.json'))
const manifest = { source, repository: 'https://github.com/anomalyco/models.dev', repositoryRevisionAtFetch: revision,
fetchedAt: new Date().toISOString(), sha256, bytes: data.length, providers: providers.length,
models: providers.reduce((total, p) => total + Object.keys(p.models).length, 0) }
await writeFile(join(directory, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
await writeFile(join(directory, 'LICENSE'), await licenseResponse.text())
console.log(JSON.stringify(manifest))
+165
View File
@@ -0,0 +1,165 @@
/**
* Vendors the pinned OMP kernel binary and its license files into resources/omp.
*
* The binary is not committed to Git. `npm run omp:vendor` restores it from the
* pinned upstream release (or from a local copy via `--from <file|directory>`)
* and verifies every asset against resources/omp/runtime.lock.json.
*/
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
import path from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
const project = fileURLToPath(new URL('../', import.meta.url))
const directory = path.join(project, 'resources', 'omp')
const lockPath = path.join(directory, 'runtime.lock.json')
const hashPattern = /^[a-f0-9]{64}$/
const namePattern = /^[A-Za-z0-9._-]+$/
const releasePrefix = 'https://github.com/can1357/oh-my-pi/releases/download/'
const rawPrefix = 'https://raw.githubusercontent.com/can1357/oh-my-pi/'
const maxAssetBytes = 512 * 1024 * 1024
function asset(value, label) {
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !namePattern.test(value.name)
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
throw new Error(`Invalid ${label} entry in runtime.lock.json`)
}
return value
}
async function readLock() {
const lock = JSON.parse((await fs.readFile(lockPath, 'utf8')).replace(/^\uFEFF/, ''))
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
throw new Error('Invalid OMP runtime lock')
}
const binary = asset(lock.binary, 'binary')
const license = asset(lock.license, 'license')
const thirdPartyNotices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || thirdPartyNotices.name !== 'THIRD-PARTY-NOTICES.txt') {
throw new Error('OMP runtime lock uses unexpected asset names')
}
return { lock, binary, license, thirdPartyNotices }
}
async function sha256File(file) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(file)) hash.update(chunk)
return hash.digest('hex')
}
async function verifyFile(file, spec) {
const stat = await fs.stat(file).catch(() => null)
if (!stat?.isFile()) throw new Error(`Missing OMP asset: ${path.relative(project, file)}`)
if (stat.size !== spec.bytes) throw new Error(`OMP asset size mismatch: ${spec.name} (${stat.size} != ${spec.bytes})`)
const digest = await sha256File(file)
if (digest !== spec.sha256) throw new Error(`OMP asset digest mismatch: ${spec.name}`)
return digest
}
async function copyVerified(source, target, spec) {
if (path.resolve(source) === path.resolve(target)) return verifyFile(target, spec)
const stat = await fs.stat(source)
const file = stat.isDirectory() ? path.join(source, spec.name) : source
const sourceStat = await fs.stat(file)
if (!sourceStat.isFile() || sourceStat.size !== spec.bytes) throw new Error(`Local OMP asset has an unexpected size: ${file}`)
const digest = await sha256File(file)
if (digest !== spec.sha256) throw new Error(`Local OMP asset does not match the pinned release: ${file}`)
const temporary = `${target}.part`
await fs.copyFile(file, temporary)
try {
await verifyFile(temporary, spec)
await fs.rename(temporary, target)
} catch (error) {
await fs.rm(temporary, { force: true })
throw error
}
return digest
}
async function download(url, target, spec) {
const response = await fetch(url, {
redirect: 'follow',
headers: { 'user-agent': 'YCode OMP vendor' },
signal: AbortSignal.timeout(10 * 60 * 1000)
})
if (!response.ok || !response.body) throw new Error(`OMP download failed (${response.status}): ${url}`)
const declared = response.headers.get('content-length')
if (declared && Number(declared) !== spec.bytes) throw new Error(`OMP download size header mismatch: ${spec.name}`)
const temporary = `${target}.part`
try {
await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary))
const digest = await verifyFile(temporary, spec)
await fs.rename(temporary, target)
return digest
} catch (error) {
await fs.rm(temporary, { force: true })
throw error
}
}
function releaseUrl(version, name) {
const url = `${releasePrefix}v${version}/${name}`
if (!url.startsWith(releasePrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP release URL: ${url}`)
return url
}
function rawUrl(version, name) {
const url = `${rawPrefix}v${version}/${name}`
if (!url.startsWith(rawPrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP raw URL: ${url}`)
return url
}
async function ensureAsset({ lock, spec, source, target, verifyOnly, offline }) {
if (await fs.stat(target).catch(() => null)) {
const digest = await verifyFile(target, spec)
console.log(`[omp] ${spec.name} verified (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
return
}
if (verifyOnly) throw new Error(`Missing OMP asset: ${path.relative(project, target)}`)
if (source) {
if (spec.name !== 'omp.exe') throw new Error(`--from only applies to omp.exe, not ${spec.name}`)
const digest = await copyVerified(source, target, spec)
console.log(`[omp] ${spec.name} imported from ${source} (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
return
}
if (offline) throw new Error(`OMP asset is missing in offline mode: ${path.relative(project, target)}`)
try {
const digest = await download(releaseUrl(lock.version, spec.name), target, spec)
console.log(`[omp] ${spec.name} downloaded (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
} catch (releaseError) {
if (spec.name === 'omp.exe') throw releaseError
const digest = await download(rawUrl(lock.version, spec.name), target, spec)
console.log(`[omp] ${spec.name} downloaded from raw source (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
}
}
async function main() {
const args = process.argv.slice(2)
const verifyOnly = args.includes('--verify')
const offline = args.includes('--offline')
const fromIndex = args.indexOf('--from')
const source = fromIndex >= 0 ? args[fromIndex + 1] : null
if (fromIndex >= 0 && (!source || source.startsWith('--'))) throw new Error('--from requires a file or directory')
const { lock, binary, license, thirdPartyNotices } = await readLock()
await fs.mkdir(directory, { recursive: true })
console.log(`[omp] pinned ${lock.upstream} v${lock.version} (${lock.platform})`)
await ensureAsset({ lock, spec: binary, source, target: path.join(directory, binary.name), verifyOnly, offline })
await ensureAsset({ lock, spec: license, target: path.join(directory, license.name), verifyOnly, offline })
await ensureAsset({ lock, spec: thirdPartyNotices, target: path.join(directory, thirdPartyNotices.name), verifyOnly, offline })
console.log(`[omp] ready: ${path.relative(project, directory)}`)
}
main().catch((error) => {
console.error(`[omp] ${error.message}`)
process.exitCode = 1
})
+156
View File
@@ -0,0 +1,156 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { unzipSync } from 'fflate'
const project = fileURLToPath(new URL('../', import.meta.url))
const lockPath = path.join(project, 'resources/sandbox/git-runtime.lock.json')
const defaults = path.join(project, 'resources/sandbox/win32-x64/git')
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
const hashPattern = /^[a-f0-9]{64}$/
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
const within = (root, target) => { const rel = path.relative(root, target); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel) }
const safePath = name => typeof name === 'string' && name.length > 0 && name.length <= 2048 && !path.isAbsolute(name) && !/[\\:\x00-\x1f]/.test(name)
&& !name.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))
async function regular(file, maximum = 128 * 1024 * 1024) {
const stat = await fs.lstat(file)
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error(`Unsafe bundled Git file: ${file}`)
const bytes = await fs.readFile(file)
if (bytes.length !== stat.size) throw new Error('Bundled Git file changed during reading')
return bytes
}
async function directory(file) {
const stat = await fs.lstat(file)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Bundled Git directory is a link or special file: ${file}`)
}
export async function readGitRuntimeLock() {
const lock = JSON.parse((await regular(lockPath, 64 * 1024)).toString('utf8'))
if (lock.format !== 1 || lock.provider !== 'git-for-windows' || lock.platform !== 'win32-x64' || !/^\d+\.\d+\.\d+\.windows\.\d+$/.test(lock.version)
|| !hashPattern.test(lock.runtimeManifestSha256) || !hashPattern.test(lock.archive?.sha256) || !Number.isSafeInteger(lock.archive.bytes) || lock.archive.bytes < 1 || lock.archive.bytes > 128 * 1024 * 1024
|| new URL(lock.archive.url).origin !== 'https://github.com' || !lock.archive.url.startsWith('https://github.com/git-for-windows/git/releases/download/')
|| lock.archive.name !== path.basename(lock.archive.url) || !Number.isSafeInteger(Date.parse(lock.publishedAt))) throw new Error('Invalid pinned Git runtime lock')
return lock
}
export async function verifyBundledGit(outputDirectory = defaults) {
const lock = await readGitRuntimeLock()
await directory(outputDirectory); await directory(path.join(outputDirectory, 'files'))
const bytes = await regular(path.join(outputDirectory, 'manifest.json'), 4 * 1024 * 1024), manifest = JSON.parse(bytes.toString('utf8'))
if (sha256(bytes) !== lock.runtimeManifestSha256) throw new Error('Bundled Git manifest digest mismatch against release lock')
if (manifest.version !== 1 || manifest.kind !== 'git' || !uuidPattern.test(manifest.id) || manifest.distribution?.version !== lock.version
|| manifest.distribution.archiveSha256 !== lock.archive.sha256 || manifest.distribution.releaseUrl !== lock.releaseUrl
|| !Array.isArray(manifest.files) || manifest.files.length < 7 || manifest.files.length > 12_000) throw new Error('Bundled Git does not match the pinned release')
const filesRoot = path.join(outputDirectory, 'files'), checked = new Set([filesRoot]), seen = new Set()
let total = 0
for (const file of manifest.files) {
if (!safePath(file.path) || seen.has(file.path.toLowerCase()) || !hashPattern.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > 128 * 1024 * 1024) throw new Error('Invalid bundled Git file manifest')
seen.add(file.path.toLowerCase())
let parent = path.dirname(path.join(filesRoot, file.path))
while (!checked.has(parent)) { if (!within(filesRoot, parent)) throw new Error('Bundled Git path escaped'); await directory(parent); checked.add(parent); parent = path.dirname(parent) }
const content = await regular(path.join(filesRoot, file.path))
if (content.length !== file.bytes || sha256(content) !== file.sha256) throw new Error(`Bundled Git digest mismatch: ${file.path}`)
total += content.length
}
if (total !== manifest.bytes || total > 512 * 1024 * 1024 || !['git.exe', 'license.txt', 'third_party_notices.txt', 'sources.json', 'package-versions.txt'].every(name => seen.has(name))) throw new Error('Bundled Git license inventory or size is invalid')
return { version: lock.version, archiveSha256: lock.archive.sha256, manifestSha256: sha256(bytes), files: manifest.files.length, bytes: total }
}
export async function vendorSandboxGit({ archivePath, outputDirectory = defaults, offline = false, rebuild = false } = {}) {
const lock = await readGitRuntimeLock(), target = path.resolve(outputDirectory)
if (path.basename(target) !== 'git' || !within(project, target) || target === project) throw new Error('Git output must be a project-local generated git directory')
if (await fs.lstat(target).catch(() => null)) {
await directory(target)
const previous = await regular(path.join(target, 'manifest.json'), 4 * 1024 * 1024).then(bytes => JSON.parse(bytes.toString('utf8'))).catch(() => null)
if (previous?.version !== 1 || previous.kind !== 'git' || !hashPattern.test(previous.distribution?.archiveSha256)) throw new Error('Refusing to replace an existing directory that is not a generated Git bundle')
}
if (!rebuild && await fs.stat(path.join(target, 'manifest.json')).catch(() => null)) {
try { const current = await verifyBundledGit(target); console.log(`SANDBOX_GIT_BUNDLE_READY ${current.version} ${current.bytes}`); return current } catch { /* Rebuild explicitly requested vendored resources from the pinned archive. */ }
}
const cache = path.join(project, '.tmp/vendor-git')
await fs.mkdir(cache, { recursive: true })
const archive = archivePath ? path.resolve(archivePath) : path.join(cache, lock.archive.name)
if (!await fs.lstat(archive).catch(() => null)) {
if (archivePath || offline) throw new Error('Pinned MinGit archive is missing; provide --archive or allow this explicit vendor command to download it')
const response = await fetch(lock.archive.url, { signal: AbortSignal.timeout(180_000) })
if (!response.ok || Number(response.headers.get('content-length') || 0) > lock.archive.bytes) throw new Error(`MinGit download failed: ${response.status}`)
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.length !== lock.archive.bytes || sha256(bytes) !== lock.archive.sha256) throw new Error('Official MinGit archive digest mismatch')
await fs.writeFile(archive, bytes, { flag: 'wx', mode: 0o600 })
}
const archiveBytes = await regular(archive)
if (archiveBytes.length !== lock.archive.bytes || sha256(archiveBytes) !== lock.archive.sha256) throw new Error('Official MinGit archive digest mismatch')
const unpacked = unzipSync(archiveBytes, { filter: file => /^(?:ucrt64|mingw64)\/bin\/(?:git(?:-remote-https?|-upload-pack|-receive-pack)?\.exe|[^/]+\.dll)$/i.test(file.name)
|| /^usr\/bin\/(?:(?:sh|ssh)\.exe|[^/]+\.dll)$/i.test(file.name)
|| /^usr\/share\/licenses\//.test(file.name)
|| /^(?:ucrt64|mingw64)\/share\/licenses\//.test(file.name) || ['LICENSE.txt', 'etc/package-versions.txt'].includes(file.name) })
const work = await fs.mkdtemp(path.join(project, '.tmp/vendor-git-build-'))
const extraction = path.join(work, 'archive'), stateRoot = path.join(work, 'state'), bundle = path.join(work, 'bundle')
for (const [name, bytes] of Object.entries(unpacked)) {
if (name.endsWith('/')) continue
if (!safePath(name) || bytes.length > 128 * 1024 * 1024) throw new Error('Unsafe MinGit archive entry')
const destination = path.join(extraction, name)
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
}
const prefix = unpacked['ucrt64/bin/git.exe'] ? 'ucrt64' : 'mingw64'
const loaderPath = path.join(work, 'loader.mjs')
await build({ absWorkingDir: project, stdin: { contents: "export { importSandboxToolchain } from './src/main/sandbox/toolchains'; export { configureSandbox } from './src/main/sandbox/configuration';", resolveDir: project, sourcefile: 'git-vendor-loader.ts', loader: 'ts' }, bundle: true, platform: 'node', format: 'esm', outfile: loaderPath, logLevel: 'warning' })
const loader = await import(pathToFileURL(loaderPath).href)
loader.configureSandbox({ stateRoot, resourceDirectory: path.join(work, 'resources'), useBundledGit: false })
const imported = await loader.importSandboxToolchain('git', path.join(extraction, prefix, 'bin/git.exe'))
const importedObject = path.join(stateRoot, 'toolchains/objects', imported.id)
await fs.cp(importedObject, bundle, { recursive: true, errorOnExist: true, force: false })
const manifest = JSON.parse(await fs.readFile(path.join(bundle, 'manifest.json'), 'utf8'))
const put = async (name, bytes) => {
if (!safePath(name)) throw new Error('Invalid third-party notice path')
const destination = path.join(bundle, 'files', name)
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
manifest.files.push({ path: name, bytes: bytes.length, sha256: sha256(bytes) }); manifest.bytes += bytes.length
}
for (const [name, bytes] of Object.entries(unpacked)) {
if (name.endsWith('/')) continue
if (name === 'LICENSE.txt') await put('LICENSE.txt', bytes)
else if (name === 'etc/package-versions.txt') await put('package-versions.txt', bytes)
else if (/^(?:ucrt64|mingw64)\/share\/licenses\//.test(name)) await put(name.replace(/^(?:ucrt64|mingw64)\/share\//, ''), bytes)
else if (/^usr\/share\/licenses\//.test(name)) await put(name.replace(/^usr\/share\//, 'usr/'), bytes)
}
await put('SOURCES.json', Buffer.from(JSON.stringify({ provider: lock.provider, version: lock.version, releaseUrl: lock.releaseUrl, archive: lock.archive, sources: lock.sources, components: manifest.files.filter(file => /\.(?:exe|dll)$/i.test(file.path)).map(file => file.path) }, null, 2) + '\n'))
await put('THIRD_PARTY_NOTICES.txt', Buffer.from(`YCode bundled Git runtime\n\nUnmodified Git for Windows ${lock.version} and its required DLLs, extracted from the official MinGit distribution.\nRelease: ${lock.releaseUrl}\nArchive SHA-256: ${lock.archive.sha256}\n\nGit is licensed under GPL-2.0; see LICENSE.txt. Dependency license/copyright notices from the upstream archive are preserved under licenses/. Some notices refer to upstream components not included in this runtime subset. See SOURCES.json and package-versions.txt for provenance and source/build references.\n\nGit executes as a separate process. No host Git configuration or credentials are bundled.\n`))
const idBytes = Buffer.from(lock.archive.sha256.slice(0, 32), 'hex'); idBytes[6] = (idBytes[6] & 15) | 64; idBytes[8] = (idBytes[8] & 63) | 128
const hex = idBytes.toString('hex')
manifest.id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
manifest.createdAt = Date.parse(lock.publishedAt)
manifest.distribution = { version: lock.version, archiveSha256: lock.archive.sha256, releaseUrl: lock.releaseUrl }
manifest.features.push('bundled')
manifest.files.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))
const manifestBytes = Buffer.from(JSON.stringify(manifest) + '\n')
await fs.writeFile(path.join(bundle, 'manifest.json'), manifestBytes)
if (sha256(manifestBytes) !== lock.runtimeManifestSha256) throw new Error(`Generated Git manifest needs review: ${path.join(bundle, 'manifest.json')} SHA-256 ${sha256(manifestBytes)}. Update the reviewed source lock before publishing resources.`)
const verified = await verifyBundledGit(bundle)
const parent = path.dirname(target)
await fs.mkdir(parent, { recursive: true })
if (!within(await fs.realpath(project), await fs.realpath(parent))) throw new Error('Git bundle output parent escaped the project')
const current = await fs.lstat(target).catch(() => null), backup = path.join(work, 'previous-bundle')
if (current) {
await directory(target)
if (path.dirname(await fs.realpath(target)) !== await fs.realpath(parent)) throw new Error('Git bundle replacement target escaped its parent')
await fs.rename(target, backup)
}
try { await fs.rename(bundle, target) } catch (error) { if (current) await fs.rename(backup, target); throw error }
console.log(`SANDBOX_GIT_BUNDLE_PASS ${verified.version} ${verified.files} files ${verified.bytes} bytes`)
return verified
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2), options = {}
while (args.length) {
const name = args.shift()
if (name === '--offline') options.offline = true
else if (name === '--rebuild') options.rebuild = true
else if (name === '--archive' || name === '--output') { const value = args.shift(); if (!value) throw new Error(`Missing ${name}`); options[name === '--archive' ? 'archivePath' : 'outputDirectory'] = value }
else throw new Error(`Unknown option: ${name}`)
}
await vendorSandboxGit(options)
}
+43 -11
View File
@@ -9,6 +9,8 @@ const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const EXTERNAL_ARROW_PATH = 'M9.8242 6.6001L9.8242 5.109Q9.8243 3.305 9.7462 2.7323Q9.7081 2.4537 9.6375 2.2354L0.8485 11.0243L0 10.1758L8.789 1.3868Q8.5706 1.3162 8.292 1.2782Q7.7193 1.2 5.9153 1.2001L4.4243 1.2001L4.4243 0L5.9152 0Q7.8008 -0 8.4543 0.0892Q9.5761 0.2423 10.179 0.8453Q10.782 1.4482 10.9351 2.57Q11.0243 3.2235 11.0243 5.1091L11.0243 6.6001L9.8242 6.6001Z'
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/about-help-settings-'))
@@ -85,13 +87,18 @@ if (!process.versions.electron) {
section: panel?.dataset.aboutHelpPanel,
card: rect(document.querySelector('.help-feedback-panel__card')),
rows: [...document.querySelectorAll('.help-feedback-panel__card-row')].map(row => ({
rect: rect(row),
id: row.id, rect: rect(row),
labelTag: row.querySelector('.help-feedback-panel__card-label')?.tagName,
label: row.querySelector('.help-feedback-panel__card-label')?.childNodes[0]?.textContent?.trim(),
value: row.querySelector('.help-feedback-panel__card-value')?.textContent?.trim()
})),
buttons: buttons.map(button => ({ text: button.textContent.trim(), rect: rect(button), disabled: button.disabled,
ariaDisabled: button.getAttribute('aria-disabled'),
title: button.getAttribute('title'), arrow: !!button.querySelector('.wb-button__icon svg') })),
title: button.getAttribute('title'), arrow: !!button.querySelector('.wb-button__icon svg'),
iconWrapperClass: button.querySelector('.wb-button__icon')?.className ?? null,
iconFill: button.querySelector('.wb-button__icon svg')?.getAttribute('fill') ?? null,
path: button.querySelector('.wb-button__icon path')?.getAttribute('d') ?? null,
transform: button.querySelector('.wb-button__icon path')?.getAttribute('transform') ?? null })),
follow: rect(document.querySelector('.help-feedback-panel__follow-card')),
followTitle: (() => { const node = document.querySelector('.help-feedback-panel__follow-title'); return node ? { ...rect(node), text: node.textContent.trim() } : null })(),
qrList: rect(document.querySelector('.help-feedback-panel__qr-list')),
@@ -101,8 +108,13 @@ if (!process.versions.electron) {
placeholder: item.querySelector('.help-feedback-panel__qr--placeholder')?.textContent?.trim()
})),
footer: rect(document.querySelector('.help-feedback-panel__footer')),
footerDivider: (() => { const node = document.querySelector('.help-feedback-panel__footer-divider')
return node ? { rect: rect(node), className: node.className, fontSize: getComputedStyle(node).fontSize,
lineHeight: getComputedStyle(node).lineHeight } : null })(),
footerLinks: [...document.querySelectorAll('.help-feedback-panel__footer-link')].map(link => ({
text: link.textContent.trim(), rect: rect(link), disabled: link.disabled, color: getComputedStyle(link).color })),
text: link.textContent.trim(), rect: rect(link), disabled: link.disabled, className: link.className,
color: getComputedStyle(link).color, fontSize: getComputedStyle(link).fontSize,
lineHeight: getComputedStyle(link).lineHeight })),
anchors: [...document.querySelectorAll('a')].map(anchor => anchor.getAttribute('href')).filter(Boolean),
cardBackground: panel ? getComputedStyle(document.querySelector('.help-feedback-panel__card')).backgroundColor : null,
labelColor: (() => { const node = document.querySelector('.help-feedback-panel__card-label'); return node ? getComputedStyle(node).color : null })(),
@@ -133,31 +145,47 @@ if (!process.versions.electron) {
const light = await read(layoutSource, settings)
snapshots.aboutLight = light
await fs.writeFile(path.join(directory, 'about-light.png'), (await settings.webContents.capturePage()).toPNG())
check('关于 panel keeps the recovered 638x620 box at 232,40',
[near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 620)], [true, true, true, true])
check('关于 top card keeps 638x108 with two 606x32 rows',
[near(light.card.width, 638), near(light.card.height, 108),
check('关于 adds the requested metadata row while keeping 606x32 row geometry',
[near(light.card.width, 638), near(light.card.height, 160),
near(light.rows[0].rect.y, 52), near(light.rows[0].rect.width, 606), near(light.rows[0].rect.height, 32),
near(light.rows[1].rect.y, 104)], [true, true, true, true, true, true])
check('关于 rows keep the recovered ids and span labels',
light.rows.map(row => [row.id, row.labelTag]),
[['settings-about-version', 'SPAN'], ['settings-about-product', 'SPAN'], ['settings-about-model-metadata', 'SPAN']])
check('关于 rows show the real YCode version and the recovered labels',
[light.rows[0].label, light.rows[0].value, light.rows[1].label],
['当前版本', `v${appInfo.version}`, '关于 YCode'])
check('关于 action buttons keep 82x32 / 102x32 and stay explicitly disabled',
[light.buttons.map(button => button.text), near(light.buttons[0].rect.x, 772), near(light.buttons[0].rect.width, 82),
[light.buttons.slice(0, 2).map(button => button.text), near(light.buttons[0].rect.x, 772), near(light.buttons[0].rect.width, 82),
near(light.buttons[1].rect.x, 752), near(light.buttons[1].rect.width, 102),
light.buttons.every(button => button.ariaDisabled === 'true' && near(button.rect.height, 32)),
light.buttons.slice(0, 2).every(button => button.ariaDisabled === 'true' && near(button.rect.height, 32)),
light.buttons[0].title, light.buttons[1].title, light.buttons[1].arrow],
[['检查更新', '前往官网'], true, true, true, true, true, '暂未接入', '尚未配置', true])
check('关于 metadata update is enabled in its own row', [light.buttons[2].text, light.buttons[2].ariaDisabled !== 'true', near(light.rows[2].rect.y, 156), near(light.rows[2].rect.height, 32)], ['更新元数据', true, true, true])
check('关于 external arrow keeps the exact REA wrapper, path and transform',
[light.buttons[1].iconWrapperClass, light.buttons[1].iconFill, light.buttons[1].path, light.buttons[1].transform],
['wb-button__icon wb-button__icon--right', 'currentColor', EXTERNAL_ARROW_PATH, 'matrix(1 0 0 1 2.57586 2.39996)'])
check('关注卡片 keeps 638x226 with the recovered title and three 120x120 placeholder QRs',
[near(light.follow.y, 164), near(light.follow.width, 638), near(light.follow.height, 226),
[near(light.follow.y, 216), near(light.follow.width, 638), near(light.follow.height, 226),
light.followTitle.text, light.qrList.width, light.qrItems.map(item => item.label),
light.qrItems.every(item => near(item.rect.width, 120) && near(item.rect.height, 120) && item.placeholder === '尚未配置')],
[true, true, true, '关注我们,获取实用技巧与产品最新动态', 408, ['公众号', '视频号', '腾讯频道'], true])
check('关于 footer keeps the recovered 44px row with two 12px disabled links',
[near(light.footer.y, 616), near(light.footer.width, 638), near(light.footer.height, 44),
light.footerLinks.map(link => link.text), light.footerLinks.every(link => link.disabled && near(link.rect.height, 20)),
light.footerLinks.every(link => link.className.includes('wb-button--link') && link.className.includes('wb-button--small')),
light.footerLinks[0].color, near(light.footerLinks[0].rect.x, 489.438, 0.5), near(light.footerLinks[1].rect.x, 564.563, 0.5)],
[true, true, true, ['隐私政策', '服务协议'], true, 'rgba(0, 0, 0, 0.3)', true, true])
[true, true, true, ['隐私政策', '服务协议'], true, true, 'rgba(0, 0, 0, 0.3)', true, true])
check('关于 footer divider keeps the recovered class and 3.125x17 glyph box',
[light.footerDivider?.className, near(light.footerDivider?.rect.width, 3.125, 0.25),
near(light.footerDivider?.rect.height, 17, 0.75), near(light.footerDivider?.rect.x, 549.438, 0.5)],
['help-feedback-panel__footer-divider', true, true, true])
check('关于 footer keeps the recovered 12/20 link labels and 13/normal divider glyph',
[light.footerLinks.map(link => [link.fontSize, link.lineHeight]), light.footerDivider?.fontSize, light.footerDivider?.lineHeight],
[[['12px', '20px'], ['12px', '20px']], '13px', 'normal'])
check('关于 page contains no anchor links (no WorkBuddy jumps)', light.anchors, [])
await step('help-settings')
@@ -176,6 +204,9 @@ if (!process.versions.electron) {
near(help.buttons[1].rect.x, 752), near(help.buttons[1].rect.width, 102),
help.buttons.every(button => button.ariaDisabled === 'true' && button.title === '尚未配置'), help.buttons[1].arrow],
[['意见反馈', '帮助文档'], ['填写反馈', '查看文档'], true, true, true, true, true, true])
check('获取帮助 rows keep the recovered ids and span labels',
help.rows.map(row => [row.id, row.labelTag]),
[['settings-get-help-feedback', 'SPAN'], ['settings-get-help-docs', 'SPAN']])
check('获取帮助 has no QR block, footer or anchor links',
[help.follow, help.footer, help.anchors], [null, null, []])
@@ -188,11 +219,12 @@ if (!process.versions.electron) {
await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.help-feedback-panel')`, 'dark about panel', settings)
const dark = await read(layoutSource, settings)
snapshots.aboutDark = dark
await fs.writeFile(path.join(directory, 'about-dark.png'), (await settings.webContents.capturePage()).toPNG())
check('dark theme keeps the recovered card, text, button and QR surfaces',
[dark.cardBackground, dark.labelColor, dark.buttonBackground, dark.buttonBorder, dark.qrBackground],
['rgb(31, 31, 31)', 'rgb(242, 243, 245)', 'rgb(38, 38, 38)', 'rgb(61, 61, 61)', 'rgb(41, 41, 41)'])
check('dark theme keeps every recovered geometry',
[near(dark.panel.width, 638), near(dark.panel.height, 620), near(dark.card.height, 108),
check('dark theme keeps the row geometry and requested metadata extension',
[near(dark.panel.width, 638), near(dark.panel.height, 620), near(dark.card.height, 160),
near(dark.follow.height, 226), dark.qrItems.every(item => near(item.rect.width, 120))], [true, true, true, true, true])
await openSettings('appearance')
await wait('!!document.querySelector(".appearance-card")', 'appearance panel again', settings)
+29 -17
View File
@@ -197,12 +197,12 @@ if (!process.versions.electron) {
check('menu items keep the recovered 2px gap', light.itemsGap, '2px')
check('settings row shows the Ctrl+, shortcut', light.items[0].value, 'Ctrl+,')
check('appearance row reports the current light theme', light.items[1].value, '浅色')
check('unwired rows stay honest about their state', [light.items[2].value, light.items[3].value], ['待拆解', '未接入'])
check('memory is wired while the update row stays honest', [light.items[2].value, light.items[3].value], [null, '未接入'])
check('footer keeps margin-top 6 + padding-top 8 + 1px rule', [light.footer.marginTop, light.footer.paddingTop, light.footer.ruleHeight], ['6px', '8px', '1px'])
check('logout row keeps the 32px row inside the footer', [Math.round(light.logout.box[3]), light.logout.font], [32, '14px/400/22px'])
check('logout entry is present and honest', /退出登录.*未接入/.test(light.logout.text), true)
check('menu content matches the requested scope', light.text,
'Xxiaohan.yuan账号为 mock 数据,尚未接入登录套餐未接入升级套餐余额未接入购买积分设置Ctrl+,外观浅色记忆与进化待拆解检查更新未接入退出登录未接入')
'Xxiaohan.yuan账号为 mock 数据,尚未接入登录套餐未接入升级套餐余额未接入购买积分设置Ctrl+,外观浅色记忆与进化检查更新未接入退出登录未接入')
check('menu shows no fabricated 套餐/余额/积分 numbers', light.digits, false)
await shot('account-menu-light')
@@ -260,9 +260,9 @@ if (!process.versions.electron) {
await step('menu-settings-navigation')
assert.equal(await read(clickRow('设置')), true)
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after settings click')
await wait('!!document.querySelector(".models-settings-panel")', 'models panel in the reused settings window', settings)
await wait('!!document.querySelector(".general-settings-panel")&&!document.querySelector(".appearance-panel")', 'general panel in the reused settings window', settings)
check('设置 row reuses the native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1)
check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true)
check('设置 row lands on the general panel', await read(`!!document.querySelector('.general-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true)
await step('menu-light-restore')
await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings)
@@ -272,22 +272,34 @@ if (!process.versions.electron) {
check('light theme is restored through the same settings window', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['light', 'light'])
const settingsId = settings.id
await read(`document.querySelector('.settings-window-close').click()`, settings)
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
check('settings window closes normally', settings.isDestroyed(), true)
check('closed settings window is gone from the app windows', BrowserWindow.getAllWindows().filter(win => win.id === settingsId).length, 0)
for (let index = 0; index < 200 && settings.isVisible(); index++) await pause(20)
check('settings window hides normally', !settings.isDestroyed() && !settings.isVisible(), true)
check('closed settings window stays cached', BrowserWindow.getAllWindows().filter(win => win.id === settingsId).length, 1)
await step('menu-memory-navigation')
await read(openMenu)
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for memory')
assert.equal(await read(clickRow('记忆与进化')), true)
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after memory click')
for (let index = 0; index < 200; index++) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'memory settings window')
observe(settings)
await wait('!!document.querySelector(".memory-settings-panel")', 'memory settings panel', settings)
check('记忆与进化 row opens the real memory settings panel', await read(`!!document.querySelector('.memory-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true)
check('memory row reuses one native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1)
const memorySettingsId = settings.id
await read(`document.querySelector('.settings-window-close').click()`, settings)
for (let index = 0; index < 200 && settings.isVisible(); index++) await pause(20)
check('memory settings window hides normally', !settings.isVisible() && BrowserWindow.getAllWindows().some(win => win.id === memorySettingsId), true)
await step('menu-unwired-rows')
await read(openMenu)
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for unwired rows')
snapshots.unwiredRows = await read(rowSource)
assert.equal(await read(clickRow('记忆与进化')), true)
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after unwired row click')
const hint = await wait(`(()=>{const node=document.querySelector('.sidebar__hint');return node?node.textContent:null})()`, 'sidebar hint for the unwired row')
check('记忆与进化 reports a pending state instead of fake success', /待拆解|尚未接入/.test(hint), true)
check('unwired row does not open an extra window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 0)
await wait('!document.querySelector(".sidebar__hint")', 'hint auto-dismissed')
await read(openMenu)
await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened for remaining rows')
assert.equal(await read(clickRow('检查更新')), true)
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after 检查更新')
check('检查更新 reports an unwired state', /尚未接入/.test(await wait(`(()=>{const node=document.querySelector('.sidebar__hint');return node?node.textContent:null})()`, 'hint for 检查更新')), true)
@@ -295,8 +307,8 @@ if (!process.versions.electron) {
await read(openMenu)
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for logout')
await shot('account-menu-final')
// 记忆与进化 / 检查更新 / 退出登录 三行都要对辅助技术保持 disabled,不假装可用。
check('unwired rows stay disabled for assistive tech', snapshots.unwiredRows.slice(2).map(row => row.ariaDisabled), ['true', 'true', 'true'])
// 已接入的记忆行保持可操作;检查更新与退出登录仍要对辅助技术保持 disabled。
check('only the still-unwired rows are disabled for assistive tech', snapshots.unwiredRows.slice(2).map(row => row.ariaDisabled), [null, 'true', 'true'])
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
+32 -88
View File
@@ -1,10 +1,6 @@
// TODO 15 验收:设置 → 智能体的 1:1 布局 + 团队管理偏好真实持久化 + 未接入项标注 + 深浅主题。
// 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。
//
// 原版依据(REA ev_bd0f4b…/ev_3384aa…/ev_01940b…/ev_1cc896…):
// 页面根 [232,40,638,503];团队管理卡 638×90(section 606×66、下拉 120×32 @734,69);
// 经济模式卡 638×90(开关 34×20 @820,181);浏览器操控标签 638×22 + Beta 39.391×20 @314,253;
// 浏览器卡片 638×261(主 section 606×44、安装 68×32 @786,294、列表 606×193、行 68/68/56、按钮 102×32 @752)。
// TODO 15 验收:智能体团队管理布局、真实持久化和深浅主题。
// 2026-10-18 用户要求移除经济模式与浏览器操控,页面仅保留 638x90 团队管理卡。
// 真实隔离 YCode(preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900x680。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
@@ -79,50 +75,22 @@ if (!process.versions.electron) {
const rect = node => { if (!node) return null; const r = node.getBoundingClientRect()
return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } }
const panel = document.querySelector('.extensions-settings-panel')
const cards = [...document.querySelectorAll('.extensions-settings-panel > .general-settings-panel__group-card')]
const sections = [...document.querySelectorAll('.extensions-settings-panel .general-settings-panel__group-card > .general-settings-panel__section')]
const buttons = [...document.querySelectorAll('.extensions-settings-panel__action-button')]
const rows = [...document.querySelectorAll('.extensions-settings-panel__browser-row')]
const select = document.querySelector('.extensions-settings-panel__team-dropdown .wb-select')
const switchInput = document.querySelector('#agents-economy-switch, .extensions-settings-panel .wb-switch__input')
const cards = [...panel.querySelectorAll(':scope > .general-settings-panel__group-card')]
const sections = [...panel.querySelectorAll('.general-settings-panel__group-card > .general-settings-panel__section')]
const select = panel.querySelector('.extensions-settings-panel__team-dropdown .wb-select')
return {
panel: rect(panel),
cards: cards.map(card => rect(card)),
sections: sections.map(section => ({
rect: rect(section),
panel: rect(panel), cards: cards.map(card => rect(card)),
sections: sections.map(section => ({ rect: rect(section),
title: section.querySelector('.general-settings-panel__section-title')?.textContent?.trim(),
desc: section.querySelector('.general-settings-panel__section-desc')?.textContent?.trim()
})),
desc: section.querySelector('.general-settings-panel__section-desc')?.textContent?.trim() })),
select: select ? { rect: rect(select), value: select.querySelector('.wb-select__value')?.textContent?.trim(), title: select.getAttribute('title') } : null,
switch: switchInput ? (() => { const track = switchInput.closest('.wb-switch')?.querySelector('.wb-switch__track')
return { rect: rect(track), trackBackground: track ? getComputedStyle(track).backgroundColor : null,
trackOpacity: track ? getComputedStyle(track).opacity : null, thumb: rect(track?.querySelector('.wb-switch__thumb')),
checked: switchInput.checked, disabled: switchInput.disabled,
unavailable: switchInput.closest('.extensions-settings-panel__unavailable-control')?.getAttribute('title') } })() : null,
browserLabel: (() => { const node = document.querySelector('.extensions-settings-panel__browser-label'); return node ? { ...rect(node), text: node.firstChild?.textContent?.trim() } : null })(),
beta: (() => { const node = document.querySelector('.extensions-settings-panel__beta'); return node ? { ...rect(node), text: node.textContent.trim() } : null })(),
browserCard: rect(document.querySelector('.extensions-settings-panel__browser-card')),
browserList: rect(document.querySelector('.extensions-settings-panel__browser-list')),
rows: rows.map(row => ({
rect: rect(row),
title: row.querySelector('.extensions-settings-panel__browser-title')?.textContent?.trim(),
desc: row.querySelector('.extensions-settings-panel__browser-desc')?.textContent?.trim(),
icon: (() => { const node = row.querySelector('.extensions-settings-panel__browser-icon')
return node ? { ...rect(node), tagName: node.tagName, src: node.getAttribute('src'), placeholderTitle: node.getAttribute('title'),
complete: node.complete === true, naturalWidth: node.naturalWidth, naturalHeight: node.naturalHeight } : null })(),
button: (() => { const node = row.querySelector('.extensions-settings-panel__action-button')
return node ? { rect: rect(node), text: node.textContent.trim(), ariaDisabled: node.getAttribute('aria-disabled'), title: node.getAttribute('title'), arrow: !!node.querySelector('.wb-button__icon svg') } : null })()
})),
buttons: buttons.map(button => ({ text: button.textContent.trim(), rect: rect(button), ariaDisabled: button.getAttribute('aria-disabled'), title: button.getAttribute('title') })),
anchors: [...document.querySelectorAll('a')].map(anchor => anchor.getAttribute('href')).filter(Boolean),
obsoleteControls: panel.querySelectorAll('.wb-switch, .extensions-settings-panel__browser-group, .extensions-settings-panel__action-button').length,
obsoleteText: ['经济模式', '浏览器操控', '扩展程序', '前往安装'].filter(text => panel.textContent.includes(text)),
cardBackground: cards[0] ? getComputedStyle(cards[0]).backgroundColor : null,
titleColor: (() => { const node = document.querySelector('.extensions-settings-panel .general-settings-panel__section-title'); return node ? getComputedStyle(node).color : null })(),
descColor: (() => { const node = document.querySelector('.extensions-settings-panel .general-settings-panel__section-desc'); return node ? getComputedStyle(node).color : null })(),
titleColor: (() => { const node = panel.querySelector('.general-settings-panel__section-title'); return node ? getComputedStyle(node).color : null })(),
descColor: (() => { const node = panel.querySelector('.general-settings-panel__section-desc'); return node ? getComputedStyle(node).color : null })(),
selectBackground: select ? getComputedStyle(select).backgroundColor : null,
selectBorder: select ? getComputedStyle(select).borderTopColor : null,
buttonBackground: buttons[0] ? getComputedStyle(buttons[0]).backgroundColor : null,
buttonBorder: buttons[0] ? getComputedStyle(buttons[0]).borderTopColor : null,
betaBackground: (() => { const node = document.querySelector('.extensions-settings-panel__beta'); return node ? getComputedStyle(node).backgroundColor : null })(),
theme: document.documentElement.dataset.theme ?? 'light'
}})()`
@@ -146,45 +114,21 @@ if (!process.versions.electron) {
const light = await read(layoutSource, settings)
snapshots.light = light
await fs.writeFile(path.join(directory, 'ycode-agents-light.png'), (await settings.webContents.capturePage()).toPNG())
check('agents panel keeps the recovered 638x503 box at 232,40',
[near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 503)], [true, true, true, true])
check('team-management and economy cards keep 638x90 with 606x66 sections',
[light.cards.slice(0, 2).map(card => [near(card.width, 638), near(card.height, 90)].every(Boolean)),
light.sections.slice(0, 2).map(section => [near(section.rect.width, 606), near(section.rect.height, 66)].every(Boolean)),
near(light.cards[0].y, 40), near(light.cards[1].y, 146)], [[true, true], [true, true], true, true])
check('team-management keeps the recovered title, description and 120x32 select',
check('agents panel only occupies the 638x90 team-management card at 232,40',
[near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 90)], [true, true, true, true])
check('team-management keeps one 638x90 card with its 606x66 section',
[light.cards.length, near(light.cards[0].width, 638), near(light.cards[0].height, 90),
near(light.sections[0].rect.width, 606), near(light.sections[0].rect.height, 66), near(light.cards[0].y, 40)],
[1, true, true, true, true, true])
check('team-management reflects the active scheduler rules with a 120x32 select',
[light.sections[0].title, light.sections[0].desc,
near(light.select.rect.x, 734), near(light.select.rect.y, 69), near(light.select.rect.width, 120), near(light.select.rect.height, 32),
light.select.value, light.select.title],
['智能体团队管理', '开启后,复杂任务会自动拆分多个智能体协作完成。专家团队任务不受此设置影响。',
true, true, true, true, '开启', '仅保存偏好;多智能体协作执行尚未接入'])
check('economy mode keeps the recovered copy, 34x20 switch and disabled annotation',
[light.sections[1].title, light.sections[1].desc,
near(light.switch.rect.x, 820), near(light.switch.rect.y, 181), near(light.switch.rect.width, 34), near(light.switch.rect.height, 20),
light.switch.trackBackground, light.switch.checked, light.switch.disabled, light.switch.unavailable],
['经济模式', '自动在合适时机触发上下文压缩,减少长对话中重复传输的上下文,降低积分消耗',
true, true, true, true, 'rgb(0, 194, 154)', true, true, '暂未接入'])
check('browser group keeps the recovered label 638x22 and Beta badge',
[near(light.browserLabel.x, 232), near(light.browserLabel.y, 252), near(light.browserLabel.width, 638), near(light.browserLabel.height, 22),
light.browserLabel.text, near(light.beta.x, 314), near(light.beta.y, 253), near(light.beta.width, 39.391), near(light.beta.height, 20), light.beta.text],
[true, true, true, true, '浏览器操控', true, true, true, true, 'Beta'])
check('browser card keeps 638x261 with 606x44 main section and 68x32 install button',
[near(light.browserCard.y, 282), near(light.browserCard.width, 638), near(light.browserCard.height, 261),
near(light.sections[2].rect.y, 294), near(light.sections[2].rect.width, 606), near(light.sections[2].rect.height, 44),
light.sections[2].title, near(light.buttons[0].rect.x, 786), near(light.buttons[0].rect.width, 68), near(light.buttons[0].rect.height, 32),
light.buttons[0].ariaDisabled, light.buttons[0].title],
[true, true, true, true, true, true, '让 YCode 控制浏览器', true, true, true, 'true', '尚未配置'])
check('browser list keeps the recovered 606x193 with 68/68/56 rows',
[near(light.browserList.y, 338), near(light.browserList.width, 606), near(light.browserList.height, 193),
light.rows.map(row => near(row.rect.height, row.title.includes('QQ') ? 56 : 68)),
light.rows.every(row => row.icon && row.icon.tagName === 'IMG' && near(row.icon.width, 36) && near(row.icon.height, 36)
&& row.icon.complete && row.icon.naturalWidth > 0 && row.icon.naturalHeight > 0 && row.icon.src && row.icon.placeholderTitle === null)],
[true, true, true, [true, true, true], true])
check('browser rows keep the recovered titles, descriptions and disabled 102x32 actions',
[light.rows.map(row => row.title),
light.rows.every(row => row.button && near(row.button.rect.width, 102) && near(row.button.rect.height, 32) && row.button.text === '前往安装' && row.button.ariaDisabled === 'true' && row.button.title === '尚未配置' && row.button.arrow)],
[['YCode Chrome 扩展程序', 'YCode Edge 扩展程序', 'YCode QQ 浏览器扩展程序'], true])
check('agents page contains no anchor links', light.anchors, [])
['智能体团队管理', '开启后,复杂任务可由多个智能体协作完成。关闭后取消子任务,主对话继续。',
true, true, true, true, '开启', null])
check('economy mode and browser controls are fully removed',
[light.sections.map(section => section.title), light.obsoleteControls, light.obsoleteText],
[['智能体团队管理'], 0, []])
await step('agents-team-toggle')
await read(`document.querySelector('.extensions-settings-panel__team-dropdown .wb-select').click()`, settings)
@@ -215,13 +159,13 @@ if (!process.versions.electron) {
const dark = await read(layoutSource, settings)
snapshots.dark = dark
await fs.writeFile(path.join(directory, 'ycode-agents-dark.png'), (await settings.webContents.capturePage()).toPNG())
check('dark theme keeps the recovered card, text, select and button surfaces',
[dark.cardBackground, dark.titleColor, dark.descColor, dark.selectBackground, dark.selectBorder, dark.buttonBackground, dark.buttonBorder, dark.betaBackground],
['rgb(31, 31, 31)', 'rgb(242, 243, 245)', 'rgba(255, 255, 255, 0.5)', 'rgb(31, 31, 31)', 'rgb(61, 61, 61)', 'rgb(38, 38, 38)', 'rgb(61, 61, 61)', 'rgb(31, 31, 31)'])
check('dark theme keeps every recovered geometry',
[near(dark.panel.width, 638), near(dark.panel.height, 503), near(dark.cards[0].height, 90),
near(dark.browserCard.height, 261), near(dark.browserList.height, 193)], [true, true, true, true, true])
check('dark economy switch keeps the recovered enabled brand track', dark.switch.trackBackground, 'rgb(34, 203, 168)')
check('dark theme keeps the recovered card, text and select surfaces',
[dark.cardBackground, dark.titleColor, dark.descColor, dark.selectBackground, dark.selectBorder],
['rgb(31, 31, 31)', 'rgb(242, 243, 245)', 'rgba(255, 255, 255, 0.5)', 'rgb(31, 31, 31)', 'rgb(61, 61, 61)'])
check('dark theme only occupies the team-management card with removed controls absent',
[near(dark.panel.width, 638), near(dark.panel.height, 90), near(dark.cards[0].height, 90),
dark.sections.map(section => section.title), dark.obsoleteControls, dark.obsoleteText],
[true, true, true, ['智能体团队管理'], 0, []])
await openSettings('appearance')
await wait('!!document.querySelector(".appearance-card")', 'appearance panel again', settings)
await read(`document.querySelector('[aria-label="浅色"].appearance-card').click()`, settings)
+490
View File
@@ -0,0 +1,490 @@
// TODO 18: verify the REA-recovered WorkBuddy "Discover Apps" shell against a real isolated YCode renderer.
// WorkBuddy source CSS: REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94
// (contexts-BkjwYSZT.css SHA-256 50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380).
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/app-discover-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const workspace = path.join(directory, 'workspace')
await Promise.all([fs.mkdir(data), fs.mkdir(home), fs.mkdir(workspace)])
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home,
YCODE_DISABLE_GPU: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(
require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
)
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('APP_DISCOVER_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => {
output += bytes
if (bytes.toString().includes('APP_DISCOVER_')) process.stderr.write(bytes)
})
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-8000))
process.exitCode = code ? 1 : 0
})
})().catch(error => {
console.error(error)
process.exitCode = 1
})
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('app-discover-'))
app.disableHardwareAcceleration()
app.setPath('userData', path.join(directory, 'data'))
app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main
let phase = 'boot'
const checks = []
const snapshots = {}
const rendererErrors = []
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source, win = main) {
let timer
try {
return await Promise.race([
win.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
})
])
} catch (error) {
throw Error(error.message + ' - ' + source.slice(0, 240))
} finally {
clearTimeout(timer)
}
}
async function wait(source, label, win = main) {
for (let index = 0; index < 300; index++) {
const value = await read(source, win)
if (value) return value
await pause(30)
}
throw Error('Timeout: ' + label)
}
async function step(label) {
phase = label
await fs.writeFile(path.join(directory, 'phase.txt'), label)
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({
label,
pass,
actual,
expected: typeof expected === 'function' ? expected.toString() : expected
})
}
async function shot(name) {
if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3')
const image = await main.webContents.debugger.sendCommand('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: false
})
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(image.data, 'base64'))
}
async function mouseMove(selector) {
const point = await read(`(()=>{
const node=document.querySelector(${JSON.stringify(selector)});
if(!node)return null;
const box=node.getBoundingClientRect();
return [box.x+box.width/2,box.y+box.height/2]
})()`)
assert.ok(point, 'mouse target: ' + selector)
if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3')
await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x: point[0],
y: point[1]
})
await pause(80)
}
async function mouseClick(selector) {
const point = await read(`(()=>{
const node=document.querySelector(${JSON.stringify(selector)});
if(!node)return null;
const box=node.getBoundingClientRect();
return [box.x+box.width/2,box.y+box.height/2]
})()`)
assert.ok(point, 'mouse target: ' + selector)
if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3')
await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: point[0],
y: point[1],
button: 'left',
clickCount: 1
})
await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: point[0],
y: point[1],
button: 'left',
clickCount: 1
})
await pause(100)
}
const openPanel = async () => {
await read(`(()=>{
const trigger=document.querySelector('.industry-template-switcher__trigger');
if(!trigger)return false;
trigger.click();
return true
})()`)
await wait(`!!document.querySelector('.app-discover-popover')`, 'discover panel open')
await pause(100)
}
const fillSearch = async value => {
await read(`(()=>{
const input=document.querySelector('.app-discover-popover__search-input');
if(!input)return false;
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;
setter.call(input,${JSON.stringify(value)});
input.dispatchEvent(new Event('input',{bubbles:true}));
return true
})()`)
await pause(100)
}
const chooseApp = async tab => {
await read(`(()=>{
const item=document.querySelector('.industry-template-switcher__group-items [data-template-id=${JSON.stringify(tab)}]');
if(!item)return false;
item.click();
return true
})()`)
await wait(`(()=>{
const page=document.querySelector('.yc-capability-center');
return page&&page.getAttribute('data-capability-tab')===${JSON.stringify(tab)}
})()`, 'capability page ' + tab)
await pause(80)
}
const layoutSource = `(()=>{
const trigger=document.querySelector('.industry-template-switcher__trigger');
const pop=document.querySelector('.app-discover-popover');
const panel=pop?.querySelector('.industry-template-switcher__panel');
const searchHeader=pop?.querySelector('.industry-template-switcher__search-header');
const search=pop?.querySelector('.industry-template-switcher__search');
const list=pop?.querySelector('.industry-template-switcher__list');
if(!trigger||!pop||!panel||!searchHeader||!search||!list)return null;
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
const style=node=>getComputedStyle(node);
const font=node=>{const s=style(node);return s.fontSize+'/'+s.fontWeight+'/'+s.lineHeight};
const items=[...pop.querySelectorAll('.industry-template-switcher__group-items > .industry-template-switcher__item')];
const headers=[...pop.querySelectorAll('.industry-template-switcher__group-title')];
return {
theme:document.documentElement.dataset.theme??'light',
trigger:box(trigger),
triggerStyle:{padding:style(trigger).padding,height:style(trigger).height,radius:style(trigger).borderRadius,
background:style(trigger).backgroundColor,borderColor:style(trigger).borderTopColor,color:style(trigger).color,
font:font(trigger),gap:style(trigger).gap},
popover:{box:box(pop),position:style(pop).position,width:style(pop).width,maxHeight:style(pop).maxHeight,
padding:style(pop).padding,radius:style(pop).borderRadius,background:style(pop).backgroundColor,
borderWidth:style(pop).borderTopWidth,borderColor:style(pop).borderTopColor,display:style(pop).display,
overflow:style(pop).overflow,shadow:style(pop).boxShadow},
panel:box(panel),
searchHeader:{box:box(searchHeader),paddingBottom:style(searchHeader).paddingBottom,marginBottom:style(searchHeader).marginBottom},
search:{box:box(search),display:style(search).display,padding:style(search).padding,borderColor:style(search).borderTopColor},
list:{box:box(list),gap:style(list).gap,overflowY:style(list).overflowY},
headers:headers.map(node=>({text:node.textContent,font:font(node),color:style(node).color})),
items:items.map(item=>({
id:item.getAttribute('data-template-id'),box:box(item),padding:style(item).padding,gap:style(item).gap,
radius:style(item).borderRadius,minHeight:style(item).minHeight,
title:item.querySelector('.industry-template-switcher__item-title')?.textContent,
titleFont:font(item.querySelector('.industry-template-switcher__item-title')),
desc:item.querySelector('.industry-template-switcher__item-desc')?.textContent,
descFont:font(item.querySelector('.industry-template-switcher__item-desc')),
icon:box(item.querySelector('.industry-template-switcher__template-icon')),
selected:item.getAttribute('aria-selected')
})),
text:pop.textContent.replace(/\\s+/g,' ').trim(),
recentGrid:!!pop.querySelector('.industry-template-switcher__recent-grid'),
empty:pop.querySelector('.industry-template-switcher__empty')?.textContent??null
}
})()`
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') ?? BrowserWindow.getAllWindows()[0]
assert.ok(main)
main.setSize(1336, 902)
main.showInactive()
main.webContents.debugger.attach('1.3')
main.webContents.on('console-message', (_event, level, message) => {
if (level >= 2) rendererErrors.push({ windowId: main.id, message })
})
await wait(`!!window.ycode && !!document.querySelector('.conversation-list')`, 'main renderer')
await wait(`!!document.querySelector('.industry-template-switcher__trigger')`, 'discover trigger')
await pause(500)
await step('trigger-semantics')
check('the trigger is a collapsed dialog button', await read(`(()=>{
const node=document.querySelector('.industry-template-switcher__trigger');
return [node.getAttribute('aria-haspopup'),node.getAttribute('aria-expanded'),node.getAttribute('aria-label')]
})()`), ['dialog', 'false', '发现应用'])
await step('closed-state-light')
snapshots.closedLight = await read(`(()=>{
const node=document.querySelector('.industry-template-switcher__trigger'),s=getComputedStyle(node),r=node.getBoundingClientRect();
return {box:[r.x,r.y,r.width,r.height],background:s.backgroundColor,border:s.borderTopColor,color:s.color,
padding:s.padding,radius:s.borderRadius,font:s.fontSize+'/'+s.fontWeight+'/'+s.lineHeight}
})()`)
check('the trigger keeps the recovered 98x28 pill geometry',
snapshots.closedLight.box.slice(2).map(Math.round), [98, 28])
check('the trigger keeps the recovered light/secondary paint',
[snapshots.closedLight.background, snapshots.closedLight.border, snapshots.closedLight.radius],
['rgb(242, 242, 242)', 'rgb(235, 235, 235)', '100px'])
check('the trigger keeps the recovered 12/500/20 typography', snapshots.closedLight.font, '12px/500/20px')
await step('open-light')
await openPanel()
const light = await read(layoutSource)
snapshots.light = light
check('the popover is anchored to the trigger right edge', Math.abs(light.popover.box[0] + 240 - (light.trigger[0] + light.trigger[2])) <= 1, true)
check('the popover sits 6px below the trigger', Math.round(light.popover.box[1] - (light.trigger[1] + light.trigger[3])), 6)
check('the popover keeps 240px max width and 420px max height',
[light.popover.width, light.popover.maxHeight], ['240px', '420px'])
check('the popover keeps 8px padding and 16px radius',
[light.popover.padding, light.popover.radius], ['8px', '16px'])
check('the light popover uses the recovered white surface and hairline',
[light.popover.background, light.popover.borderWidth, light.popover.borderColor],
['rgb(255, 255, 255)', '1px', 'rgb(222, 222, 222)'])
check('the popover is a fixed layered surface', [light.popover.position, light.popover.display], ['fixed', 'flex'])
check('the search header and 28px search field match the source geometry',
[Math.round(light.searchHeader.box[2]), Math.round(light.searchHeader.box[3]), Math.round(light.search.box[2]), Math.round(light.search.box[3])],
[222, 33, 222, 28])
check('the search header keeps the recovered divider spacing',
[light.searchHeader.paddingBottom, light.searchHeader.marginBottom], ['4px', '4px'])
check('the list has the recovered 4px gap and transparent scroll surface',
[light.list.gap, light.list.overflowY], ['4px', 'auto'])
check('the recommended group contains exactly three local capability entries',
light.items.map(item => item.id), ['experts', 'skills', 'connectors'])
check('each app entry keeps the 218x48 / 8px / 12px geometry',
light.items.map(item => [Math.round(item.box[2]), Math.round(item.box[3]), item.padding, item.gap]),
[[218, 48, '8px', '12px'], [218, 48, '8px', '12px'], [218, 48, '8px', '12px']])
check('each app entry keeps an 8px radius and 24px icon',
light.items.map(item => [item.radius, Math.round(item.icon[2]), Math.round(item.icon[3])]),
[['8px', 24, 24], ['8px', 24, 24], ['8px', 24, 24]])
check('entry titles and descriptions keep 13/16 and 11/14 typography',
light.items.map(item => [item.titleFont, item.descFont]),
[['13px/400/16px', '11px/400/14px'], ['13px/400/16px', '11px/400/14px'], ['13px/400/16px', '11px/400/14px']])
check('group headings keep the recovered 11/14 tertiary text',
light.headers.map(item => [item.text, item.font]), [['推荐', '11px/400/14px']])
check('the trigger is marked expanded while the panel is open',
await read(`document.querySelector('.industry-template-switcher__trigger').getAttribute('aria-expanded')`), 'true')
check('the trigger chevron rotates open', await read(`getComputedStyle(document.querySelector('.industry-template-switcher__chevron')).transform`),
value => typeof value === 'string' && value !== 'none')
await step('hover-enter')
await mouseMove('.industry-template-switcher__group-items [data-template-id="skills"]')
check('an unselected entry reveals the 24px 进入 action on hover', await read(`(()=>{
const item=document.querySelector('[data-template-id="skills"]');
const action=item.querySelector('.industry-template-switcher__item-action');
const button=item.querySelector('.industry-template-switcher__item-action-btn');
const s=getComputedStyle(action),b=getComputedStyle(button);
return [s.display,b.height,button.textContent]
})()`), ['flex', '24px', '进入'])
await shot('app-discover-light')
await step('search-match')
await fillSearch('技能')
const searched = await read(layoutSource)
snapshots.search = searched
check('search narrows the list to the matching local app', searched.items.map(item => item.id), ['skills'])
check('search highlights the matching glyphs', await read(`document.querySelector('.industry-template-switcher__match')?.textContent`), '技能')
check('search hides the recent/recommended grouping', searched.headers, [])
await fillSearch('不存在')
check('search shows the recovered empty state', await read(`document.querySelector('.industry-template-switcher__empty')?.textContent`), '未找到匹配的应用')
await step('keyboard-navigation')
await fillSearch('中心')
await read(`document.querySelector('.app-discover-popover__search-input').dispatchEvent(
new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true})
)`)
await pause(80)
check('ArrowDown moves focus from search to the first result',
await read(`document.activeElement?.getAttribute('data-template-id')`), 'experts')
await read(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}))`)
await pause(50)
check('subsequent ArrowDown moves through results in visual order',
await read(`document.activeElement?.getAttribute('data-template-id')`), 'skills')
await step('navigate-experts')
await fillSearch('')
await chooseApp('experts')
check('clicking an app opens the real expert capability page',
await read(`document.querySelector('.yc-capability-center')?.getAttribute('data-capability-tab')`), 'experts')
check('the local app id is persisted as the most recent', await read(`JSON.parse(localStorage.getItem('ycode.app-discover.recent.v1')||'[]')`), ['experts'])
check('the popover closes after navigation', await read(`!document.querySelector('.app-discover-popover')`), true)
await step('active-app-exit')
await openPanel()
const activeApp = await read(`(()=>{
const item=document.querySelector('.industry-template-switcher__group-items [data-template-id="experts"]');
return {tag:item?.tagName,selected:item?.getAttribute('aria-selected'),
action:item?.querySelector('.industry-template-switcher__item-action-btn')?.textContent}
})()`)
check('the active app uses the source non-button option with check state',
[activeApp.tag, activeApp.selected, activeApp.action], ['DIV', 'true', '退出'])
await read(`document.querySelector('[data-template-id="experts"] .industry-template-switcher__item-action-btn').click()`)
await wait(`!document.querySelector('.yc-capability-center') && !document.querySelector('.app-discover-popover')`, 'exit active app to chat')
check('退出 returns from the active capability page to chat', await read(`!document.querySelector('.yc-capability-center')`), true)
await step('navigate-skills')
await openPanel()
await fillSearch('技能')
await chooseApp('skills')
check('clicking the skill app opens the real skills page',
await read(`document.querySelector('.yc-capability-center')?.getAttribute('data-capability-tab')`), 'skills')
check('recent app order puts the latest choice first', await read(`JSON.parse(localStorage.getItem('ycode.app-discover.recent.v1')||'[]')`), ['skills', 'experts'])
await step('navigate-connectors')
await openPanel()
await fillSearch('连接器')
await chooseApp('connectors')
check('clicking the connector app opens the real connectors page',
await read(`document.querySelector('.yc-capability-center')?.getAttribute('data-capability-tab')`), 'connectors')
check('recent app history is de-duplicated and ordered', await read(`JSON.parse(localStorage.getItem('ycode.app-discover.recent.v1')||'[]')`), ['connectors', 'skills', 'experts'])
await step('recent-after-reload')
main.webContents.reload()
await wait(`!!document.querySelector('.industry-template-switcher__trigger')`, 'renderer after reload')
await pause(600)
await openPanel()
const recent = await read(`(()=>{
const pop=document.querySelector('.app-discover-popover');
return {
grid:[...pop.querySelectorAll('.industry-template-switcher__recent-grid [data-template-id]')].map(node=>node.getAttribute('data-template-id')),
labels:[...pop.querySelectorAll('.industry-template-switcher__recent-grid .industry-template-switcher__grid-label')].map(node=>node.textContent),
gridDisplay:getComputedStyle(pop.querySelector('.industry-template-switcher__recent-grid')).display,
columns:getComputedStyle(pop.querySelector('.industry-template-switcher__recent-grid')).gridTemplateColumns
}
})()`)
snapshots.recent = recent
check('recent apps survive a renderer reload in recency order', recent.grid, ['connectors', 'skills', 'experts'])
check('recent apps use a three-column grid and the local labels',
[recent.gridDisplay, recent.columns.trim().split(/\s+/).length, recent.labels],
['grid', 3, ['连接器中心', '技能中心', '专家中心']])
await step('escape-close')
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
await wait(`!document.querySelector('.app-discover-popover')`, 'Escape closes discover panel')
check('Escape closes the panel and restores trigger focus',
await read(`document.activeElement===document.querySelector('.industry-template-switcher__trigger')`), true)
await step('outside-close')
await openPanel()
await mouseClick('.conversation-list-brand-line')
await wait(`!document.querySelector('.app-discover-popover')`, 'outside click closes discover panel')
check('outside pointer input closes the panel', await read(`!document.querySelector('.app-discover-popover')`), true)
await step('dark-theme')
await read(`(()=>{
localStorage.setItem('ycode:theme','dark');
window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:'dark'}));
return true
})()`)
await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme applied')
await openPanel()
snapshots.dark = await read(`(()=>{
const trigger=document.querySelector('.industry-template-switcher__trigger');
const pop=document.querySelector('.app-discover-popover');
const item=pop.querySelector('.industry-template-switcher__template-icon');
return {
theme:document.documentElement.dataset.theme,
triggerBackground:getComputedStyle(trigger).backgroundColor,
popoverBackground:getComputedStyle(pop).backgroundColor,
popoverBorder:getComputedStyle(pop).borderTopColor,
itemIconBackground:getComputedStyle(item).backgroundColor,
textColor:getComputedStyle(pop.querySelector('.industry-template-switcher__item-title')).color
}
})()`)
check('dark theme uses the recovered WorkBuddy trigger/popover paint',
[snapshots.dark.triggerBackground, snapshots.dark.popoverBackground, snapshots.dark.popoverBorder],
['rgb(46, 46, 46)', 'rgb(36, 36, 36)', 'rgb(61, 61, 61)'])
check('dark theme keeps the recovered primary text and icon surface',
[snapshots.dark.textColor, snapshots.dark.itemIconBackground],
['rgb(255, 255, 255)', 'rgb(41, 41, 41)'])
await shot('app-discover-dark')
await step('restore-light')
await read(`(()=>{
localStorage.setItem('ycode:theme','light');
window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:'light'}));
return true
})()`)
await wait(`document.documentElement.dataset.theme==='light'`, 'light theme restored')
check('discover panel follows the shared light theme back',
await read(`getComputedStyle(document.querySelector('.app-discover-popover')).backgroundColor`), 'rgb(255, 255, 255)')
check('no renderer error logs', rendererErrors, [])
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/renderer at 1336x902; TODO 18 Discover Apps trigger, popover geometry, search/empty/keyboard/outside/Escape behavior, recent persistence, three local capability routes, light/dark paint.',
sourceEvidence: [
'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94',
'ev_7eb66ea8d5949eb7d872f1a93a97894a811b3beb7a02eabd81a500409f1045e4',
'ev_bd22e46c95d3887daba16dbe319389ad74e10afea8f71af9a7639e8bce95ebb4',
'ev_d3527391f609e4a8cb4d194a24ee17dcbbdfc0a2d58db240ef3686119694d85f',
'contexts-BkjwYSZT.css SHA-256 50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380'
],
snapshots,
checks,
rendererErrors
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('APP_DISCOVER_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) {
console.log('APP_DISCOVER_FAILURE ' + JSON.stringify(failure))
}
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
phase,
error: String(error),
stack: error.stack,
checks,
snapshots,
rendererErrors
}, null, 2))
console.error(error)
app.exit(1)
})
}
+255
View File
@@ -0,0 +1,255 @@
// Real isolated YCode App + preload/IPC/filesystem verification for the archived-task UI.
// No WorkBuddy runtime or original evidence is accessed by this script.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/archived-tasks-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const independentRoot = path.join(directory, 'independent-root')
const workspaceRoot = path.join(directory, 'workspace-root')
await Promise.all([fs.mkdir(data), fs.mkdir(home), fs.mkdir(independentRoot), fs.mkdir(workspaceRoot)])
const now = Date.now()
const crypto = require('node:crypto')
const workspaceId = 'archived-space'
const sessions = [
{
id: 'archived-active',
title: 'Archive Active',
cwd: independentRoot,
createdAt: now - 30_000,
updatedAt: now - 30_000,
messages: [
{ id: crypto.randomUUID(), role: 'user', content: 'active task stays visible', createdAt: now - 30_000, status: 'done' }
]
},
{
id: 'archived-independent',
title: 'Archive Independent',
cwd: independentRoot,
createdAt: now - 20_000,
updatedAt: now - 20_000,
archivedAt: now - 1_000,
messages: [
{ id: crypto.randomUUID(), role: 'user', content: 'independent message preserved', createdAt: now - 20_000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: 'independent reply preserved', createdAt: now - 19_000, status: 'done', tools: [], parts: [] }
]
},
{
id: 'archived-workspace',
title: 'Archive Workspace',
workspaceId,
cwd: workspaceRoot,
createdAt: now - 10_000,
updatedAt: now - 10_000,
archivedAt: now,
messages: [
{ id: crypto.randomUUID(), role: 'user', content: 'workspace message preserved', createdAt: now - 10_000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: 'workspace reply preserved', createdAt: now - 9_000, status: 'done', tools: [], parts: [] }
]
}
]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
await fs.writeFile(path.join(data, 'workspaces.json'), JSON.stringify({
workspaces: [{ id: workspaceId, name: 'Archive Space', root: workspaceRoot, createdAt: now }],
selectedWorkspaceId: workspaceId
}))
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home,
YCODE_DISABLE_GPU: '1',
YCODE_QA_OFFSCREEN: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('ARCHIVED_TASKS_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 150000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output)
process.exitCode = code ? 1 : 0
})
})()
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
app.disableHardwareAcceleration()
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('archived-tasks-'))
app.setPath('userData', path.join(directory, 'data'))
app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main
let phase = 'boot'
const checks = []
const snapshots = {}
const rendererErrors = []
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source) {
let timer
try {
return await Promise.race([
main.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
})
])
} catch (error) {
throw Error(error.message + ' - ' + source.slice(0, 240))
} finally {
clearTimeout(timer)
}
}
async function wait(source, label, attempts = 250) {
for (let index = 0; index < attempts; index++) {
const value = await read(source)
if (value) return value
await pause(30)
}
throw Error('Timeout: ' + label)
}
async function step(label) {
phase = label
await fs.writeFile(path.join(directory, 'phase.txt'), label)
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
}
async function shot(name) {
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
}
const clickArchiveEntry = `(()=>{const button=document.querySelector('.conversation-section-archived-btn');if(!button)return false;button.click();return true})()`
const clickRowAction = (id, selector) => `(()=>{const row=document.querySelector('[data-archived-task-id=${JSON.stringify(id)}]');const button=row?.querySelector(${JSON.stringify(selector)});if(!button)return false;button.click();button.click();return true})()`
;(async () => {
const { pathToFileURL } = require('node:url')
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
main = BrowserWindow.getAllWindows()[0]
main.setSize(1336, 902)
main.showInactive()
main.webContents.debugger.attach('1.3')
main.webContents.on('console-message', (_event, level, message) => {
if (level >= 2) rendererErrors.push(message)
})
await wait('!!document.querySelector("#root")', 'renderer root')
await pause(1500)
await wait('!!document.querySelector("[data-session-id=archived-active]")', 'active sidebar task')
await step('entry-and-loading')
check('archive entry sits in the task section header', await read(`(()=>{const header=document.querySelector('.conversation-section-label-tasks');const button=header?.querySelector('.conversation-section-archived-btn');return !!button&&button.textContent.includes('已归档')})()`), true)
const initial = await read(`(async()=>{${clickArchiveEntry};await new Promise(requestAnimationFrame);return {dialog:!!document.querySelector('.archived-tasks-dialog'),loading:!!document.querySelector('.archived-tasks-dialog__loading'),count:document.querySelectorAll('.archived-tasks-dialog').length}})()`)
check('archive entry opens exactly one dialog', initial.count, 1)
check('dialog exposes a paint-visible loading state', initial.loading || (await read(`!!document.querySelector('.archived-tasks-dialog__loading')`)), true)
await wait(`document.querySelectorAll('.archived-tasks-dialog__row').length===2`, 'two archived tasks')
check('loading state is replaced by the real list', await read(`!!document.querySelector('.archived-tasks-dialog__loading')`), false)
await step('list-filter-and-kind')
const rows = await read(`[...document.querySelectorAll('.archived-tasks-dialog__row')].map(row=>({id:row.getAttribute('data-archived-task-id'),workspaceId:row.getAttribute('data-archived-workspace-id'),title:row.querySelector('.archived-tasks-dialog__title')?.textContent,meta:row.querySelector('.archived-tasks-dialog__meta')?.textContent,actions:[...row.querySelectorAll('.archived-tasks-dialog__actions button')].map(button=>button.textContent)}))`)
snapshots.rows = rows
check('only archived sessions are listed', rows.map(row => row.id).sort(), ['archived-independent', 'archived-workspace'])
check('active task is excluded from the archive dialog', rows.some(row => row.id === 'archived-active'), false)
check('independent task is labelled independently', rows.find(row => row.id === 'archived-independent')?.meta.includes('独立任务'), true)
check('workspace task keeps its workspace identity', rows.find(row => row.id === 'archived-workspace')?.meta.includes('空间 · Archive Space'), true)
check('every archived row exposes restore and view actions', rows.every(row => row.actions.join('|') === '恢复|查看'), true)
check('dialog does not mention removed data management', await read(`document.querySelector('.archived-tasks-dialog').textContent.includes('数据管理')`), false)
await shot('archive-list')
await step('restore-independent')
const rapidRestore = await read(clickRowAction('archived-independent', '.archived-tasks-dialog__restore'))
check('rapid duplicate restore clicks are accepted only once', rapidRestore, true)
await wait(`!document.querySelector('[data-archived-task-id=archived-independent]')`, 'independent row removed')
await wait(`document.querySelectorAll('[data-session-id=archived-independent]').length===1`, 'independent restored once')
check('restored independent task appears exactly once in the sidebar', await read(`document.querySelectorAll('[data-session-id=archived-independent]').length`), 1)
check('restored independent task is no longer archived', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).some(item=>item.id==='archived-independent'))`), false)
await read(`(()=>{const row=document.querySelector('[data-session-id=archived-independent]');row.click();return true})()`)
await wait(`document.body.textContent.includes('independent message preserved')`, 'independent history restored')
check('independent task messages survive restore and open', await read(`document.body.textContent.includes('independent message preserved')&&document.body.textContent.includes('independent reply preserved')`), true)
await step('view-workspace-task')
check('view action accepts rapid duplicate clicks', await read(clickRowAction('archived-workspace', '.archived-tasks-dialog__view')), true)
await wait(`!document.querySelector('.archived-tasks-dialog')`, 'view closes archive dialog')
await wait(`document.querySelector('[data-session-id=archived-workspace].selected')`, 'workspace task selected')
check('workspace task returns to its original space group', await read(`document.querySelector('[data-session-id=archived-workspace]')?.closest('[data-workspace-id]')?.getAttribute('data-workspace-id')`), 'archived-space')
check('view restores the workspace task without duplicate rows', await read(`document.querySelectorAll('[data-session-id=archived-workspace]').length`), 1)
await wait(`document.body.textContent.includes('workspace message preserved')`, 'workspace history restored')
check('workspace task messages survive restore and view', await read(`document.body.textContent.includes('workspace message preserved')&&document.body.textContent.includes('workspace reply preserved')`), true)
check('all archived tasks are absent after restore/view', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).length)`), 0)
await step('empty-state')
await read(clickArchiveEntry)
await wait(`!!document.querySelector('.archived-tasks-dialog__empty')`, 'archive empty state')
check('empty state explains that no archived tasks exist', await read(`document.querySelector('.archived-tasks-dialog__empty').textContent.includes('暂无已归档任务')`), true)
await read(`document.querySelector('.archived-tasks-dialog__close').click()`)
await wait(`!document.querySelector('.archived-tasks-dialog')`, 'empty dialog close')
await step('load-failure')
ipcMain.removeHandler('session:list-archived')
ipcMain.handle('session:list-archived', () => { throw new Error('forced archive list failure') })
await read(clickArchiveEntry)
await wait(`!!document.querySelector('.archived-tasks-dialog__load-error')`, 'archive load failure')
check('load failure keeps a retry action', await read(`!!document.querySelector('.archived-tasks-dialog__retry')`), true)
check('load failure reports the real IPC error', await read(`document.querySelector('.archived-tasks-dialog__load-error').textContent.includes('forced archive list failure')`), true)
await shot('archive-failure')
ipcMain.removeHandler('session:list-archived')
ipcMain.handle('session:list-archived', () => [])
await read(`document.querySelector('.archived-tasks-dialog__retry').click()`)
await wait(`!!document.querySelector('.archived-tasks-dialog__empty')`, 'archive retry recovers')
check('retry reloads the archive and replaces the error state', await read(`!document.querySelector('.archived-tasks-dialog__load-error')&&!!document.querySelector('.archived-tasks-dialog__empty')`), true)
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`)
await wait(`!document.querySelector('.archived-tasks-dialog')`, 'escape closes archive dialog')
check('escape closes the archive dialog when idle', await read(`!document.querySelector('.archived-tasks-dialog')`), true)
await step('cleanup')
check('no renderer error logs', rendererErrors, [])
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode main/preload/IPC/renderer/filesystem. Archive list success and failure use IPC. Temporary data only.',
checks,
snapshots,
rendererErrors
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('ARCHIVED_TASKS_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('ARCHIVED_TASKS_FAILURE ' + JSON.stringify(failure))
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
phase,
error: String(error),
stack: error.stack,
checks,
snapshots,
rendererErrors
}, null, 2))
console.error(error)
if (main) await shot('failure').catch(() => undefined)
app.exit(1)
})
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Verifies the staged (or packaged) OMP kernel assets: lock schema, sizes,
* SHA-256 digests, and that the binary actually launches with the pinned version.
*
* Usage:
* node scripts/verify-bundled-omp.cjs # resources/omp
* node scripts/verify-bundled-omp.cjs --packaged # release/win-unpacked/resources/omp
* node scripts/verify-bundled-omp.cjs <directory>
*/
const { createHash } = require('node:crypto')
const { createReadStream, existsSync, promises: fs } = require('node:fs')
const path = require('node:path')
const { spawn } = require('node:child_process')
const project = path.resolve(__dirname, '..')
const hashPattern = /^[a-f0-9]{64}$/
const lockName = 'runtime.lock.json'
const maxAssetBytes = 512 * 1024 * 1024
function fail(message) {
throw new Error(message)
}
function asset(value, label) {
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value.name)
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
fail(`Invalid ${label} entry in ${lockName}`)
}
return value
}
async function sha256File(file) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(file)) hash.update(chunk)
return hash.digest('hex')
}
async function verifyAsset(directory, spec) {
const file = path.join(directory, spec.name)
const stat = await fs.stat(file).catch(() => null)
if (!stat?.isFile()) fail(`Missing asset: ${file}`)
if (stat.size !== spec.bytes) fail(`Size mismatch for ${spec.name}: ${stat.size} != ${spec.bytes}`)
const digest = await sha256File(file)
if (digest !== spec.sha256) fail(`SHA-256 mismatch for ${spec.name}`)
console.log(`PASS ${spec.name} ${spec.bytes} bytes ${digest.slice(0, 12)}…`)
}
function readVersion(exe, timeoutMs = 30_000) {
return new Promise((resolve, reject) => {
const child = spawn(exe, ['--version'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill()
reject(new Error(`Timed out launching ${exe}`))
}, timeoutMs)
child.stdout.on('data', (chunk) => { stdout += chunk })
child.stderr.on('data', (chunk) => { stderr += chunk })
child.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
child.on('exit', (code) => {
clearTimeout(timer)
if (code !== 0) reject(new Error(`OMP exited with ${code}: ${stderr.trim()}`))
else resolve(stdout.trim())
})
})
}
async function main() {
const args = process.argv.slice(2)
const packaged = args.includes('--packaged')
const positional = args.find((arg) => !arg.startsWith('--'))
const directory = positional
? path.resolve(positional)
: packaged
? path.join(project, 'release', 'win-unpacked', 'resources', 'omp')
: path.join(project, 'resources', 'omp')
if (!existsSync(directory)) fail(`OMP directory does not exist: ${directory}`)
const lock = JSON.parse((await fs.readFile(path.join(directory, lockName), 'utf8')).replace(/^\uFEFF/, ''))
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
fail(`Invalid ${lockName}`)
}
const binary = asset(lock.binary, 'binary')
const license = asset(lock.license, 'license')
const notices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || notices.name !== 'THIRD-PARTY-NOTICES.txt') {
fail(`Unexpected asset names in ${lockName}`)
}
console.log(`Checking OMP ${lock.version} in ${directory}`)
await verifyAsset(directory, binary)
await verifyAsset(directory, license)
await verifyAsset(directory, notices)
const output = await readVersion(path.join(directory, binary.name))
if (output !== `omp/${lock.version}`) fail(`Unexpected OMP version output: ${output}`)
console.log(`PASS omp.exe --version => ${output}`)
console.log(`OMP ${lock.version} bundle is valid.`)
}
main().catch((error) => {
console.error(`FAIL ${error.message}`)
process.exitCode = 1
})
+121 -24
View File
@@ -15,9 +15,11 @@
// - 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`)、`src-CyXHA0IC.css`(`.cr-context-usage*`)。
// - 文案:已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 /
// tooltip "{percentage}% · {used} / {total} 上下文已使用" / needMessage / requesting / unavailable)。
// - REA 保留的 `ContextUsageDisplay` 源码进一步确认:百分比固定一位小数,token 达 1K 后
// 固定一位小数并显示为 K(例如 `20.0% · 12.8K / 64.0K`)。
//
// YCode 侧数据真实性:分支名由主进程真实执行 git;用量只用上游真实 usage(经 OMP 归一化)+
// 模型自己的真实上下文窗口,拿不到就显示原版的「无法获取」文案,不填 0、不估算。
// 该回合冻结的真实上下文窗口。历史样本没有分母时保持未知,不套当前模型配置。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
@@ -29,6 +31,9 @@ const QA_PROMPT_TOKENS = 12800
const QA_COMPLETION_TOKENS = 40
const QA_CONTEXT_WINDOW = 64000
const QA_EXPECTED_PERCENT = 20
const QA_EXPECTED_PERCENT_TEXT = `${QA_EXPECTED_PERCENT.toFixed(1)}%`
const QA_EXPECTED_USED_TEXT = '12.8K'
const QA_EXPECTED_TOTAL_TEXT = '64.0K'
const RING_CIRCUMFERENCE = 2 * Math.PI * 7
if (!process.versions.electron) {
@@ -53,8 +58,19 @@ if (!process.versions.electron) {
const now = Date.now()
const sessions = [{ id: 'context-info-session', title: 'Context Info', cwd: repo, modelId: 'local-usage', createdAt: now, updatedAt: now + 1, messages: [
{ id: 'context-user', role: 'user', content: '上下文信息验收', createdAt: now, status: 'done', requestId: 'context-run' },
{ id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [] }
{ id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [],
contextUsage: { usage: { inputTokens: 8000, outputTokens: 40, totalTokens: 8040 } } }
] }]
const nativePath=path.join(data,'omp-sessions','session-native-context-session.jsonl')
await fs.mkdir(path.dirname(nativePath),{recursive:true})
await fs.writeFile(nativePath,[
{id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'old'}]}},
{id:'native-reply',parentId:'native-user',type:'message',message:{role:'assistant',model:'old-model',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:now-1000}}
].map(value=>JSON.stringify(value)).join('\n'))
sessions.push({id:'native-context-session',title:'Native Usage Recovery',cwd:repo,modelId:'local-usage',createdAt:now-1000,updatedAt:now-1000,messages:[
{id:'legacy-user',role:'user',content:'old',createdAt:now-1000,status:'done',requestId:'legacy-native',nativeEntryId:'native-user',nativeSessionPath:nativePath},
{id:'legacy-reply',role:'assistant',content:'old reply',createdAt:now-900,status:'done',requestId:'legacy-native',modelId:'local-usage'}
]})
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
@@ -133,7 +149,7 @@ if (!process.versions.electron) {
const track=ring.querySelector('circle:not(.cr-context-usage__ring-progress)');const progress=ring.querySelector('.cr-context-usage__ring-progress');
const wrapper=right.querySelector('.cr-context-usage-tooltip-trigger');const model=right.querySelector('.cr-model-selector');
return {ring:{box:box(ring),radius:style.borderRadius,width:style.width,height:style.height,transition:style.transitionProperty+' '+style.transitionDuration,
display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor},
display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor,ariaLabel:ring.getAttribute('aria-label')},
wrapper:wrapper?box(wrapper):null,
svg:svg?{box:box(svg),viewBox:svg.getAttribute('viewBox'),className:svg.getAttribute('class')}:null,
track:track?{r:track.getAttribute('r'),strokeWidth:track.getAttribute('stroke-width'),fill:track.getAttribute('fill'),stroke:getComputedStyle(track).stroke}:null,
@@ -161,8 +177,15 @@ if (!process.versions.electron) {
bar:bar?{box:box(bar),height:getComputedStyle(bar).height,radius:getComputedStyle(bar).borderRadius,fillWidth:popover.querySelector('.cr-context-usage-popover__bar-fill')?.style.width}:null,
rows,bodyText:popover.textContent,tokenTertiary:probe('color','--cr-text-tertiary')}})()`
const requestingTipSource = `(()=>{const tip=document.querySelector('.cr-context-usage-requesting-tip');if(!tip)return null;
const style=getComputedStyle(tip);const text=tip.querySelector('.cr-context-usage-requesting-tip__text');
return {role:tip.getAttribute('role'),text:text?.textContent,background:style.backgroundColor,border:style.border,
borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
popoverPresent:!!document.querySelector('.cr-context-usage-popover')}})()`
;(async () => {
// 受控上游:最后一次 chunk 带真实 usage(provider 语义,含总 prompt tokens)。
let omitUsage=false
server = createServer(async (req, res) => {
let raw = ''
for await (const part of req) raw += part
@@ -172,7 +195,7 @@ if (!process.versions.electron) {
const chunk = payload => `data: ${JSON.stringify({ id: 'local-usage', object: 'chat.completion.chunk', created: 1, model: body.model, ...payload })}\n\n`
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'USAGE_REPLY' }, finish_reason: null }] }))
res.write(chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS } }))
...(omitUsage?{}:{usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS }}) }))
res.write('data: [DONE]\n\n')
res.end()
})
@@ -250,20 +273,20 @@ if (!process.versions.electron) {
check('ring track matches the recovered circle', [ring.track.r, ring.track.strokeWidth, ring.track.fill], ['7', '2', 'none'])
check('ring progress circle matches the recovered geometry', [ring.progress.r, ring.progress.strokeWidth, ring.progress.strokeLinecap, ring.progress.transform], ['7', '2', 'round', 'rotate(-90 8 8)'])
check('ring progress keeps the recovered dashoffset transition', ring.progress.transition, 'stroke-dashoffset 0.3s')
check('no usage yet: ring stays empty', Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE))
check('legacy usage without a frozen denominator keeps the ring empty',
Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE))
await step('usage-popover-without-usage')
await step('usage-requesting-tip-without-denominator')
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
await wait('!!document.querySelector(".cr-context-usage-popover")', 'usage popover')
const emptyPopover = await read(popoverSource)
snapshots.emptyPopover = emptyPopover
check('popover keeps the recovered 280-wide card', [Math.round(emptyPopover.box[2]), emptyPopover.padding, emptyPopover.borderRadius, emptyPopover.font], [280, '16px', '16px', '12px/18px'])
check('popover keeps the recovered header', [emptyPopover.title.text, emptyPopover.title.font, Math.round(emptyPopover.close[2]), Math.round(emptyPopover.close[3])], ['上下文用量', '12px/400/20px', 16, 16])
check('no usage yet: popover says so instead of inventing numbers', emptyPopover.bodyText.includes('暂时无法获取用量明细,请稍后重试'), true)
check('no usage yet: popover shows no percentage', emptyPopover.percent, null)
await read(`(()=>{document.querySelector('.cr-context-usage-popover__close').click();return true})()`)
await wait('!document.querySelector(".cr-context-usage-popover")', 'popover closed')
check('close button dismisses the popover', await read('!document.querySelector(".cr-context-usage-popover")'), true)
await wait('!!document.querySelector(".cr-context-usage-popover")', 'known usage with unknown denominator')
const legacyPopover = await read(popoverSource)
snapshots.legacyPopover = legacyPopover
check('known token count is visible even without its historical window',legacyPopover.usedValue,'8.0K/ 未知')
check('legacy unknown denominator is not replaced by the current model configuration',legacyPopover.percent.text,'—')
check('legacy classifications remain unknown',legacyPopover.rows.map(row=>row.value),['—','—','—','—','—'])
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
await wait('!document.querySelector(".cr-context-usage-popover")', 'legacy popover closed')
check('clicking the ring dismisses the usage panel', await read('!document.querySelector(".cr-context-usage-popover")'), true)
await step('real-turn-usage')
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
@@ -280,6 +303,8 @@ if (!process.versions.electron) {
check('ring dashoffset follows the real provider usage', Math.abs(usedRing.progress.dashOffset - expectedOffset) < 0.6, true)
check('ring stroke uses the recovered progress colour', usedRing.progress.stroke, 'rgb(156, 163, 175)')
check('ring track uses the recovered track colour', usedRing.track.stroke, 'rgba(128, 128, 128, 0.2)')
check('ring accessible label uses the same recovered compact usage sentence', usedRing.ring.ariaLabel,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
await step('usage-tooltip')
await hover('.cr-context-usage')
@@ -289,7 +314,8 @@ if (!process.versions.electron) {
background:style.backgroundColor,color:style.color,borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
arrow:!!node.querySelector('.wb-tooltip__arrow'),tokenBg:probe('backgroundColor','--wb-tooltip-bg'),tokenFg:probe('color','--wb-tooltip-text-color')}})()`)
snapshots.tooltip = tooltip
check('hover tooltip uses the recovered usage sentence', tooltip?.text, `${QA_EXPECTED_PERCENT}% · ${QA_PROMPT_TOKENS} / ${QA_CONTEXT_WINDOW} 上下文已使用`)
check('hover tooltip uses the recovered one-decimal and compact-token formatting', tooltip?.text,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
check('tooltip keeps the recovered base surface', [tooltip.background, tooltip.color, tooltip.borderRadius, tooltip.padding, tooltip.font, tooltip.arrow],
[tooltip.tokenBg || 'rgba(0, 0, 0, 0.75)', tooltip.tokenFg || 'rgb(255, 255, 255)', '8px', '4px 8px', '12px/20px', true])
@@ -299,16 +325,38 @@ if (!process.versions.electron) {
await pause(200)
const usedPopover = await read(popoverSource)
snapshots.usedPopover = usedPopover
check('popover shows the real percentage', usedPopover.percent.text, `${QA_EXPECTED_PERCENT}%`)
check('popover keeps the recovered one-decimal percentage', usedPopover.percent.text, QA_EXPECTED_PERCENT_TEXT)
check('popover percent keeps 20/700/28', usedPopover.percent.font, '20px/700/28px')
check('popover shows the real used/total pair', usedPopover.usedValue, `${QA_PROMPT_TOKENS} / ${QA_CONTEXT_WINDOW}`)
check('popover keeps the recovered compact used/total pair', usedPopover.usedValue,
`${QA_EXPECTED_USED_TEXT}/ ${QA_EXPECTED_TOTAL_TEXT}`)
check('popover used label keeps the recovered copy', [usedPopover.usedLabel.text, usedPopover.usedLabel.color], ['已使用', usedPopover.tokenTertiary])
check('popover bar keeps the recovered 8px track', [Math.round(usedPopover.bar.box[3]), usedPopover.bar.height, usedPopover.bar.radius], [8, '8px', '3px'])
check('popover bar fill follows the same percentage', usedPopover.bar.fillWidth, `${QA_EXPECTED_PERCENT}%`)
const prepared = await read(`window.ycode.session.messages('context-info-session').then(messages=>{const m=messages.filter(item=>item.role==='assistant').at(-1);return {breakdown:m?.contextBreakdown,requestId:m?.requestId}})`)
snapshots.preparedInput=prepared
check('real OMP provider hook records this request, not an unrelated historical sample',
[prepared.breakdown?.scope,prepared.breakdown?.requestId,prepared.breakdown?.method],['provider-request',prepared.requestId,'utf8-estimate'])
check('classification contains actual system, tool definitions, dialogue and skill catalogue',
['system','tools','conversation','skills'].every(key=>prepared.breakdown?.counts[key]>0),true)
check('category sum is independent from controlled upstream count',prepared.breakdown?.inputTokens!==QA_PROMPT_TOKENS,true)
check('classification bar uses its own independent local estimate',Math.abs(parseFloat(usedPopover.bar.fillWidth)-Math.min(100,prepared.breakdown.inputTokens/QA_CONTEXT_WINDOW*100))<0.01,true)
check('popover legend keeps the five recovered categories', usedPopover.rows.map(row => row.name),
['系统提示词', '工具及子智能体', '对话消息', '连接器及MCP', '技能'])
check('legend swatches keep the recovered 8px dots', usedPopover.rows.map(row => row.swatch), [[8, 8], [8, 8], [8, 8], [8, 8], [8, 8]])
check('legend values stay unknown instead of fabricated', usedPopover.rows.map(row => row.value), ['—', '—', '—', '—', '—'])
check('legend values are explicitly labeled local estimates',usedPopover.rows.every(row=>row.value.startsWith('≈')),true)
await fs.writeFile(path.join(directory,'context-breakdown-light.png'),(await main.webContents.capturePage()).toPNG())
await step('frozen-window-survives-model-config-change')
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', previousId: 'local-usage', name: 'Local Usage', providerId: 'custom',
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
maxInputTokens: QA_CONTEXT_WINDOW * 2, maxOutputTokens: 4096 })})`)
await pause(250)
const frozenRing = await read(ringSource)
snapshots.frozenWindow = frozenRing
check('historical usage keeps its own frozen denominator after model config changes',
frozenRing.ring.ariaLabel,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
check('historical usage does not silently inherit the newly configured model window',
Math.abs(frozenRing.progress.dashOffset - expectedOffset) < 0.6, true)
await step('dark-theme-via-native-settings')
// 真实路径:设置窗口切成深色,聊天区 `data-cr-theme` 随之变化,输入区 token 跟着切。
@@ -327,8 +375,8 @@ if (!process.versions.electron) {
snapshots.settingsWindow = await read(`({url:location.href,search:location.search,bodyClass:document.body.className,rootHtml:(document.querySelector('#root')?.innerHTML||'').slice(0,240)})`, settings)
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await read(`(()=>{const tab=document.querySelector('[data-settings-tab="appearance"]');if(tab)tab.click();return true})()`, settings)
await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings)
await read(`(()=>{const node=[...document.querySelectorAll('.segmented__item')].find(item=>item.textContent.trim()==='深色');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings)
await wait(`!!document.querySelector('.appearance-card')`, 'appearance panel', settings)
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="深色"]');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings)
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window dark theme')
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='dark'`, 'composer dark scope')
await pause(250)
@@ -339,16 +387,65 @@ if (!process.versions.electron) {
tokenElevated:probe('backgroundColor','--cr-bg-elevated'),tokenActive:probe('backgroundColor','--cr-bg-active'),
tokenTextPrimary:probe('color','--wb-color-text-primary'),scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
snapshots.dark = { lightTokens, darkPopover }
await fs.writeFile(path.join(directory,'context-breakdown-dark.png'),(await main.webContents.capturePage()).toPNG())
check('native settings switches the composer to the dark scope', [lightTokens.scope, darkPopover.scope], ['light', 'dark'])
check('dark popover uses the recovered dark elevated surface', darkPopover.popoverBackground, 'rgb(36, 38, 41)')
check('dark popover bar uses the recovered dark active fill', darkPopover.bar, 'rgba(255, 255, 255, 0.1)')
check('dark pill switches to the dark primary text colour', [darkPopover.pillColor, lightTokens.pillColor, darkPopover.pillColor === darkPopover.tokenTextPrimary], ['rgb(255, 255, 255)', 'rgb(0, 0, 0)', true])
await read(`(()=>{const node=[...document.querySelectorAll('.segmented__item')].find(item=>item.textContent.trim()==='浅色');if(!node)throw new Error('missing light option');node.click();return true})()`, settings)
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="浅色"]');if(!node)throw new Error('missing light option');node.click();return true})()`, settings)
await wait(`document.documentElement.dataset.theme==='light'`, 'main window light theme')
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='light'`, 'composer light scope')
await read(`document.querySelector('.settings-window-close').click()`, settings)
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
check('light theme restored through the same settings window', await read(`[document.documentElement.dataset.theme,document.querySelector('.chat-pane').dataset.crTheme]`), ['light', 'light'])
await step('persisted-usage-after-renderer-reload')
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`, 'real turn persistence barrier')
const persistedUsage = await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(message=>message.role==='assistant').at(-1)?.contextUsage)`)
snapshots.persistedUsage = persistedUsage
check('actual turn stores its upstream input sample and original model window',
[persistedUsage?.usage?.inputTokens, persistedUsage?.contextWindow], [QA_PROMPT_TOKENS, QA_CONTEXT_WINDOW])
const reloaded = new Promise(resolve => main.webContents.once('did-finish-load', resolve))
main.webContents.reload()
await reloaded
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')===${JSON.stringify(`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)}`,
'restored usage with frozen denominator')
snapshots.reloadedUsage = await read(ringSource)
check('renderer reload recovers usage without adopting the newly doubled model window',
snapshots.reloadedUsage.ring.ariaLabel,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
await fs.writeFile(path.join(directory, 'context-usage-restored.png'), (await main.webContents.capturePage()).toPNG())
await step('new-request-without-provider-usage')
omitUsage=true
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'NO_USAGE_NEW_REQUEST');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
await read(`document.querySelector('.cr-input-toolbar__send').click()`)
await wait(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='user').at(-1)?.content==='NO_USAGE_NEW_REQUEST')`,'new round created')
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`,'no-usage round complete')
const noUsage=await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='assistant').at(-1))`)
snapshots.noUsage={contextUsage:noUsage.contextUsage,contextBreakdown:noUsage.contextBreakdown,requestId:noUsage.requestId}
check('a new no-usage request cannot inherit the previous provider total',noUsage.contextUsage??null,null)
check('the new request still retains its independently prepared classification',noUsage.contextBreakdown?.requestId,noUsage.requestId)
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'local estimate clearly identified')
const noUsageReload=new Promise(resolve=>main.webContents.once('did-finish-load',resolve));main.webContents.reload();await noUsageReload
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'estimate restored without reviving old provider sample')
check('reopening a no-usage round does not pick an older assistant sample',
await read(`window.ycode.session.contextUsage('context-info-session').then(info=>[!!info.sample,info.breakdown?.requestId])`),[false,noUsage.requestId])
await step('native-history-usage-recovery')
const nativeUpdatedBefore=await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`)
await wait(`!!document.querySelector('[data-session-id="native-context-session"]')`,'legacy sidebar item')
await read(`document.querySelector('[data-session-id="native-context-session"]').click()`)
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.includes('5.2K / 未知')`,'native sample recovered without a fabricated denominator')
await read(`document.querySelector('.cr-context-usage').click()`)
await wait(`document.querySelector('.yc-context-source')?.textContent.includes('原生记录恢复')`,'source of recovered sample visible')
const recovered=await read(`window.ycode.session.messages('native-context-session').then(messages=>messages.at(-1).contextUsage)`)
snapshots.nativeRecovered=recovered
check('real IPC saves the recovered native sample with its provenance',
[recovered.usage.inputTokens,recovered.contextWindow??null,recovered.source,recovered.nativeEntryId],[5161,null,'native-history','native-reply'])
check('restoring usage metadata does not reorder the sidebar task',await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`),nativeUpdatedBefore)
await fs.writeFile(path.join(directory,'context-native-recovered.png'),(await main.webContents.capturePage()).toPNG())
check('querying old records does not start a native agent',await fs.readdir(path.join(process.env.YCODE_USER_DATA_DIR,'context-usage')).then(files=>files.some(name=>name.includes('native-context-session'))),false)
check('no renderer error logs', rendererErrors, [])
server.close()
+357
View File
@@ -0,0 +1,357 @@
const fs = require('node:fs/promises'), path = require('node:path'), assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/custom-model-settings-'))
for (const name of ['data', 'home']) await fs.mkdir(path.join(directory, name))
await fs.cp(path.resolve('out'), path.join(directory, 'build/out'), { recursive: true })
await fs.cp(path.resolve('resources/model-metadata'), path.join(directory, 'build/resources/model-metadata'), { recursive: true })
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home') }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), ['--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''; child.stdout.on('data', data => { output += data; if (data.toString().includes('CUSTOM_MODEL_')) process.stdout.write(data) }); child.stderr.on('data', data => output += data)
const timer = setTimeout(() => child.kill(), 180000)
child.on('exit', async code => { clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output); console.log(JSON.stringify({ directory, exitCode: code })); if (code) console.error(output.slice(-5000)); process.exitCode = code ? 1 : 0 })
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron'), { pathToFileURL } = require('node:url'), { createServer } = require('node:http')
const directory = process.env.YCODE_QA_DIRECTORY
app.setPath('home', path.join(directory, 'home')); app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
let main, settings, phase = 'boot'
const checks = [], servers = [], requests = [], rendererErrors = []
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const read = (source, win = settings) => win.webContents.executeJavaScript(source)
const check = (label, actual, expected = true) => checks.push({ label, actual, expected, pass: JSON.stringify(actual) === JSON.stringify(expected) })
async function wait(source, label, win = settings) { for (let i = 0; i < 500; i++) { const value = await read(source, win); if (value) return value; await pause(20) }; throw Error('Timeout: ' + label) }
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
const click = selector => read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing control: '+${JSON.stringify(selector)});node.click();return true})()`)
async function fill(selector, value) { await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing input: '+${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(node,${JSON.stringify(value)});node.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) }
async function option(value) {
const source = `(()=>{const nodes=[...document.querySelectorAll('[role="option"]')];const node=nodes.find(n=>n.textContent.trim()===${JSON.stringify(value)}&&n.getClientRects().length);if(!node||getComputedStyle(node.closest('.wb-select__panel')).pointerEvents==='none'||getComputedStyle(node.closest('.wb-popover')).visibility==='hidden'||Number(getComputedStyle(node.closest('.wb-popover')).opacity)<.99)return false;node.click();return true})()`
await wait(source, 'select option ' + value)
await wait(`![...document.querySelectorAll('[role="option"]')].some(node=>node.textContent.trim()===${JSON.stringify(value)}&&node.getClientRects().length)`, 'option committed ' + value)
}
async function shot(name) {
await read(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(true))))`)
await read(`(async()=>{const menu=document.querySelector('.custom-model-options');if(menu)await Promise.allSettled(menu.getAnimations({subtree:true}).filter(animation=>animation.effect.getComputedTiming().iterations!==Infinity).map(animation=>animation.finished));await new Promise(resolve=>requestAnimationFrame(()=>resolve(true)))})()`)
await fs.writeFile(path.join(directory, name + '.png'), (await settings.webContents.capturePage()).toPNG())
await fs.writeFile(path.join(directory, name + '.layout.json'), JSON.stringify(await read(`(()=>{const body=document.querySelector('.models-settings-panel__editor-body'),menu=document.querySelector('.custom-model-options');return {theme:document.documentElement.dataset.theme,body:body?{top:body.scrollTop,height:body.clientHeight,scrollHeight:body.scrollHeight}:null,menu:menu?{x:menu.getBoundingClientRect().x,y:menu.getBoundingClientRect().y,width:menu.getBoundingClientRect().width,opacity:getComputedStyle(menu).opacity}:null}})()`), null, 2))
}
function anthropicStream(model) { return [
{ type: 'message_start', message: { id: 'msg_ui', type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 20, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'MODEL_PROTOCOL_OK' } },
{ type: 'content_block_stop', index: 0 }, { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 4 } }, { type: 'message_stop' }
].map(row => `event: ${row.type}\ndata: ${JSON.stringify(row)}\n\n`).join('') }
async function upstream(protocol, model, options = {}) {
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://local')
const validAuth = options.noKey ? !req.headers['x-api-key'] && !req.headers['x-goog-api-key'] && !req.headers.authorization
: protocol === 'anthropic' ? req.headers['x-api-key'] === 'controlled-model-key' && !req.headers.authorization : protocol === 'gemini' ? req.headers['x-goog-api-key'] === 'controlled-model-key' : req.headers.authorization === 'Bearer controlled-model-key'
const listPath = protocol === 'gemini' ? '/v1beta/models' : '/v1/models'
const generationPath = protocol === 'anthropic' ? '/v1/messages' : protocol === 'gemini' ? `/v1beta/models/${model}:streamGenerateContent` : protocol === 'responses' ? '/v1/responses' : '/v1/chat/completions'
if (req.method === 'GET') {
if (url.pathname !== listPath || !validAuth || options.noList) { res.writeHead(404); res.end('{}'); return }
options.listStarted = true
if (options.listDelay) await pause(options.listDelay)
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(protocol === 'gemini' ? { models: options.emptyList ? [] : [{ name: 'models/' + model, displayName: model, supportedGenerationMethods: ['generateContent'] }] } : { data: options.emptyList ? [] : [{ id: model }, ...(options.additionalModels ?? []).map(id => ({ id }))] })); options.listEnded = true; return
}
let bytes = ''; for await (const chunk of req) bytes += chunk
const body = JSON.parse(bytes || '{}'), nonstreamGemini = protocol === 'gemini' && url.pathname === `/v1beta/models/${model}:generateContent`
requests.push({ protocol, path: req.url, stream: !!body.stream || url.searchParams.get('alt') === 'sse', body, authorized: validAuth,
authShape: { names: Object.keys(req.headers), bearerMatches: req.headers.authorization === 'Bearer controlled-model-key', xKeyMatches: req.headers['x-api-key'] === 'controlled-model-key', xKeyLength: String(req.headers['x-api-key'] ?? '').length } })
if (!validAuth) { res.writeHead(401); res.end('{}'); return }
if (url.pathname !== generationPath && !nonstreamGemini) { res.writeHead(404); res.end('{}'); return }
const streaming = body.stream || url.searchParams.get('alt') === 'sse'
if (!streaming) {
options.probeStarted = true
if (options.probeDelay) await pause(options.probeDelay)
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify(protocol === 'anthropic' ? { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'OK' }] }
: protocol === 'gemini' ? { candidates: [{ content: { role: 'model', parts: [{ text: 'OK' }] } }] }
: protocol === 'responses' ? { id: 'resp_test', object: 'response', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'OK' }] }] }
: { choices: [{ message: { role: 'assistant', content: 'OK' } }] })); options.probeEnded = true; return
}
res.writeHead(200, { 'content-type': 'text/event-stream' })
if (protocol === 'anthropic') res.end(anthropicStream(model))
else if (protocol === 'gemini') res.end('data: ' + JSON.stringify({ candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'MODEL_PROTOCOL_OK' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 4, totalTokenCount: 24 } }) + '\n\n')
else if (protocol === 'responses') {
const message = { id: 'msg_resp', type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'MODEL_PROTOCOL_OK' }] }
res.end([{ type: 'response.output_item.added', output_index: 0, item: { ...message, content: [] } }, { type: 'response.output_text.delta', output_index: 0, content_index: 0, item_id: message.id, delta: 'MODEL_PROTOCOL_OK' }, { type: 'response.completed', response: { id: 'resp_actual', status: 'completed', output: [message], usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 } } }].map(row => `event: ${row.type}\ndata: ${JSON.stringify(row)}\n\n`).join(''))
} else res.end([{ choices: [{ index: 0, delta: { role: 'assistant', content: 'MODEL_PROTOCOL_OK' }, finish_reason: null }] }, { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 4, total_tokens: 24 } }].map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server)
return `127.0.0.1:${server.address().port}`
}
;(async () => {
await import(pathToFileURL(path.join(directory, 'build/out/main/index.mjs')).href); await app.whenReady()
for (let i = 0; i < 300; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(20) }
await wait(`!!window.ycode&&!!document.querySelector('.chat-pane')`, 'main app', main)
await wait(`!!document.querySelector('.cr-model-selector__trigger')`, 'composer model selector', main)
await read(`document.querySelector('.cr-model-selector__trigger').click()`, main)
await wait(`document.querySelector('.cr-model-selector__menu > .cr-menu-item')?.textContent.trim()==='配置自定义模型'`, 'configure custom models action', main)
await read(`document.querySelector('.cr-model-selector__menu > .cr-menu-item').click()`, main)
settings = BrowserWindow.getAllWindows().find(win => win !== main)
settings.webContents.on('console-message', event => { if (event.level === 'error') rendererErrors.push(event.message) })
await wait(`!!document.querySelector('.models-settings-panel')`, 'model list page')
check('configure custom models opens the list without an add dialog', await read(`!document.querySelector('.models-settings-panel__editor-overlay')`))
check('model page has no metadata update button', await read(`![...document.querySelectorAll('.models-settings-panel button')].some(node=>node.textContent.includes('更新元数据'))`))
await click('.models-settings-panel__add-button')
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'model editor')
check('keyless switch and explanatory text are removed', await read(`!document.querySelector('.models-settings-panel__editor-body').textContent.includes('无需')`))
await step('custom-provider-only')
await click('.models-settings-panel__provider-select'); await option('自定义')
check('only custom provider appears in the editor', await read(`document.querySelector('.models-settings-panel__provider-select').textContent.trim()`), '自定义')
const source = await read(`window.ycode.models.metadataStatus()`)
const manifest = JSON.parse(await fs.readFile(path.join(directory, 'build/resources/model-metadata/manifest.json'), 'utf8'))
check('bundled catalog matches the fetched snapshot', [source.sha256, source.providers, source.models], [manifest.sha256, manifest.providers, manifest.models])
await shot('custom-editor-empty-light')
const cases = [{ protocol: 'openai', model: 'gpt-5.4' }, { protocol: 'anthropic', model: 'claude-sonnet-4-6' }, { protocol: 'gemini', model: 'gemini-2.5-flash' }, { protocol: 'responses', model: 'gpt-5.4-pro' }, { protocol: 'responses', model: 'gpt-6.1-sol', extended: true }]
const profiles = []
for (let index = 0; index < cases.length; index++) {
const item = cases[index]; await step('form-' + item.protocol)
if (index) { await read(`window.ycode.app.openSettings('model','new')`, main); await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'next model editor') }
const domain = await upstream(item.protocol, item.model, index === 0 ? { additionalModels: [item.model + '-alt'] } : {})
await fill('input[placeholder*="api.example.com"]', domain); await fill('input[type="password"]', 'controlled-model-key')
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
await option(item.model)
await wait(`document.querySelector('.custom-model-id-combobox input').value===${JSON.stringify(item.model)}&&!document.querySelector('.models-settings-panel__editor-save').disabled`, 'metadata applied')
const metadata = await read(`window.ycode.models.metadata(${JSON.stringify(item.model)},'auto')`)
check(item.protocol + ' metadata found', !!metadata)
check(item.protocol + ' metadata descriptions and total-context field are removed', await read(`!document.querySelector('.custom-model-metadata')&&!document.querySelector('[aria-label="总上下文窗口"]')&&!document.querySelector('.models-settings-panel__editor-body').textContent.includes('参考价格')`))
if (index === 0) {
await fill('.custom-model-id-combobox input', '5.4-alt')
await wait(`document.querySelectorAll('.custom-model-options [role="option"]').length===1`, 'inline model search')
check('one combobox searches the list directly without a second search field', await read(`document.querySelector('.custom-model-id-combobox input').getAttribute('role')==='combobox'&&!document.querySelector('.custom-model-options input')`))
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}))`)
await wait(`!!document.querySelector('.custom-model-id-combobox input').getAttribute('aria-activedescendant')`, 'active keyboard option')
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
await wait(`document.querySelector('.custom-model-id-combobox input').value==='gpt-5.4-alt'`, 'keyboard selection')
await fill('.custom-model-id-combobox input', 'vendor/manual-model')
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
check('an unmatched manual model stays editable and is not replaced', await read(`document.querySelector('.custom-model-id-combobox input').value`), 'vendor/manual-model')
await fill('.custom-model-id-combobox input', item.model)
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
check('Escape closes only the model list', await read(`!document.querySelector('.custom-model-options')&&!!document.querySelector('.models-settings-panel__editor-overlay')`))
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'original model metadata restored')
}
await shot('custom-editor-' + item.protocol + '-light')
await read(`document.querySelector('.models-settings-panel__editor-body').scrollTop=10000`)
if (item.extended) {
await read(`(()=>{const fields=document.querySelectorAll('.models-settings-panel__number-grid .models-settings-panel__field');fields[0].querySelectorAll('button').forEach(button=>{if(button.textContent==='500K')button.click()});fields[1].querySelectorAll('button').forEach(button=>{if(button.textContent==='128K')button.click()})})()`)
check('500K and 128K shortcuts use exact catalog-compatible token counts', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['500000', '128000'])
check('both large presets show their selected state', await read(`[...document.querySelectorAll('.models-settings-panel__quick-value.wb-button--secondary')].map(node=>node.textContent)`), ['500K', '128K'])
check('fields configure total context and output', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.getAttribute('aria-label'))`), ['上下文','输出'])
check('live budgets show 372K input and compression at 500K total', await read(`document.querySelector('[data-context-budget]').textContent.includes('372,000 tokens · 自动压缩阈值:372,000')`))
}
await shot('custom-editor-' + item.protocol + '-metadata-light')
if (item.extended) await shot('custom-editor-500k-128k-light')
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'save with protocol verification')
const registry = await read(`window.ycode.models.list()`), profile = registry.models.find(model => model.id === item.model)
assert(profile, 'saved profile ' + item.model); profiles.push(profile)
check(item.protocol + ' stores only custom provider', profile.providerId, 'custom')
check(item.protocol + ' automatically negotiated generation API', profile.endpoint.api, item.protocol === 'anthropic' ? 'anthropic-messages' : item.protocol === 'gemini' ? 'google-generative-ai' : item.protocol === 'responses' ? 'openai-responses' : 'openai-completions')
check(item.protocol + ' saves actual capabilities', [profile.supportsImages, profile.supportsReasoning, profile.supportsToolCall], [metadata.supportsImages, metadata.supportsReasoning, metadata.supportsToolCall])
check(item.protocol + ' saves metadata thinking defaults', [profile.onlyReasoning, profile.canDisableThinking, profile.defaultEffort, profile.supportedEfforts], [metadata.onlyReasoning, metadata.canDisableThinking, metadata.defaultEffort, metadata.supportedEfforts])
check(item.protocol + ' saves total context and derived input budget', [profile.maxInputTokens, profile.maxOutputTokens, profile.contextWindowTokens], item.extended ? [372000, 128000, 500000] : [metadata.maxInputTokens, metadata.maxOutputTokens, metadata.contextWindowTokens])
check(item.protocol + ' saves the matching tier reference', profile.price, item.extended ? metadata.priceTiers.find(tier=>tier.minContext===272000) : metadata.price)
check(item.protocol + ' retains no plaintext key in renderer view', !JSON.stringify(profile).includes('controlled-model-key'))
await step('real-chat-' + item.protocol)
const session = await read(`window.ycode.session.create('Protocol ${item.protocol}',{modelId:${JSON.stringify(item.model)}})`, main)
const before = requests.length
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(session.id)},text:'Reply briefly with MODEL_PROTOCOL_OK.'})`, main)
await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(rows=>rows.some(row=>row.role==='assistant'&&['done','error'].includes(row.status)))`, 'real native turn completes', main)
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main), assistant = messages.filter(row => row.role === 'assistant').at(-1)
check(item.protocol + ' production chat completes through actual OMP', [assistant.status, assistant.content.includes('MODEL_PROTOCOL_OK')], ['done', true])
check(item.protocol + ' actual chat reaches configured native server', requests.slice(before).some(request => request.protocol === item.protocol && request.stream && request.authorized))
const actualRequest = requests.slice(before).find(request => request.protocol === item.protocol && request.stream)?.body
if (item.extended) {
check('large output budget reaches the actual Responses provider request', actualRequest.max_output_tokens, 128000)
check('context accounting uses the configured 500K total', assistant.contextBreakdown?.contextWindow, 500000)
const managedRoot = path.join(directory, 'home', '.omp', 'profiles')
const yamls = await Promise.all((await fs.readdir(managedRoot)).map(async name => ({name,text:await fs.readFile(path.join(managedRoot, name, 'agent', 'models.yml'), 'utf8').catch(()=>'')})))
const managed = yamls.find(item=>item.text.includes('gpt-6.1-sol'))
check('real OMP profile uses 500K total and 128K output', !!managed && /contextWindow: 500000/.test(managed.text) && /maxTokens: 128000/.test(managed.text))
const budgetConfig = await fs.readFile(path.join(managedRoot, managed.name, 'agent', 'config.yml'), 'utf8')
check('OMP runtime config reserves output and sets the dynamic compression threshold', /reserveTokens: 128000/.test(budgetConfig) && /thresholdTokens: 372000/.test(budgetConfig))
const executable = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA, 'Ygent', 'omp.exe')
const parsed = JSON.parse(require('node:child_process').execFileSync(executable, ['config','get','compaction.thresholdTokens','--json'], {encoding:'utf8',windowsHide:true,timeout:15000,env:{...process.env,OMP_PROFILE:managed.name,USERPROFILE:path.join(directory,'home'),HOME:path.join(directory,'home')}}))
check('installed OMP loads the generated compression threshold', parsed.value ?? parsed, 372000)
}
check(item.protocol + ' actual request uses the automatically selected thinking strength', item.protocol === 'gemini'
? actualRequest.generationConfig?.thinkingConfig?.thinkingBudget === 1024
: (item.protocol === 'anthropic' ? actualRequest.output_config?.effort : item.protocol === 'responses' ? actualRequest.reasoning?.effort : actualRequest.reasoning_effort) === metadata.defaultEffort)
check(item.protocol + ' native request retains context breakdown observations', !!assistant.contextBreakdown && assistant.contextBreakdown.scope === 'provider-request' && assistant.contextBreakdown.counts.system > 0)
}
async function edit(profile) {
await read(`(()=>{const row=[...document.querySelectorAll('.models-settings-panel__row')].find(row=>row.querySelector('.models-settings-panel__row-name').textContent===${JSON.stringify(profile.name)});row.querySelector('button').click()})()`)
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')&&!document.querySelector('.models-settings-panel__editor-save').disabled`, 'edit saved model')
}
const largeProfile = profiles.find(profile=>profile.id==='gpt-6.1-sol')
await edit(largeProfile)
check('reopening keeps the 500K and 128K budgets', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['500000','128000'])
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '64000')
check('changing output dynamically recalculates input and keeps 15 percent compression headroom', await read(`document.querySelector('[data-context-budget]').textContent.includes('436,000 tokens · 自动压缩阈值:425,000')`))
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '131072')
await click('.models-settings-panel__editor-save')
await wait(`document.querySelector('.model-ui-error')?.textContent.includes('128,000')`, 'catalog output ceiling validation')
check('invalid output budget keeps the editor open', await read(`!!document.querySelector('.models-settings-panel__editor-overlay')`))
check('invalid output budget preserves the saved model', (await read(`window.ycode.models.list()`)).models.find(model=>model.id===largeProfile.id).maxOutputTokens, 128000)
await click('.models-settings-panel__editor-cancel')
await step('existing-secret-list-does-not-downgrade-responses')
const responsesProfile = profiles.find(profile => profile.id === 'gpt-5.4-pro')
await edit(responsesProfile)
check('editing never reveals saved key', await read(`document.querySelector('input[type="password"]').value`), '')
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
await option(responsesProfile.model)
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'unchanged model saved')
check('fetching with saved key preserves verified Responses routing', (await read(`window.ycode.models.list()`)).models.find(model => model.id === responsesProfile.id).endpoint.api, 'openai-responses')
await step('manual-overrides-survive-reopen')
const profile = profiles.find(profile => profile.id === 'gpt-5.4')
await edit(profile)
await fill('.models-settings-panel__number-grid input', '32768')
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '4096')
await read(`[...document.querySelectorAll('.models-settings-panel__checkbox-item')].find(node=>node.textContent.trim()==='图片输入').querySelector('input').click()`)
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'manual overrides saved')
const overridden = (await read(`window.ycode.models.list()`)).models.find(model => model.id === profile.id)
check('manual total/output and image override are persisted with derived input', [overridden.maxInputTokens, overridden.maxOutputTokens, overridden.contextWindowTokens, overridden.supportsImages], [28672, 4096, 32768, false])
await edit(overridden)
check('reopening retains manual input/output limits', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['32768', '4096'])
await fill('.custom-model-id-combobox input', 'manually-entered-model')
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'unknown metadata settled')
check('model input remains freely editable', await read(`document.querySelector('.custom-model-id-combobox input').value`), 'manually-entered-model')
check('unknown model keeps manual capability controls without metadata copy', await read(`!!document.querySelector('.models-settings-panel__advanced')&&!document.querySelector('.custom-model-metadata')`))
await click('.models-settings-panel__editor-cancel')
await step('manual-model-without-models-api-or-key')
const manualDomain = await upstream('openai', 'manual-unlisted', { noList: true, noKey: true })
await read(`window.ycode.app.openSettings('model','new')`, main)
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'edit saved model')
await fill('input[placeholder*="api.example.com"]', manualDomain)
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
await wait(`!!document.querySelector('.model-ui-error')`, 'missing models API feedback')
await fill('.custom-model-id-combobox input', 'manual-unlisted')
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'manual model ready')
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'manual keyless save')
const manual = (await read(`window.ycode.models.list()`)).models.find(model => model.id === 'manual-unlisted')
check('manual model without models API saves a verified keyless route', [manual.authMode, manual.hasApiKey, manual.endpoint.api, manual.endpoint.verifiedBy], ['none', false, 'openai-completions', 'generation'])
await step('keyless-gemini-production-fallback')
const keylessId = 'gemini-keyless-controlled'
await read(`window.ycode.app.openSettings('model','new')`, main)
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'keyless Gemini form')
await fill('input[placeholder*="api.example.com"]', await upstream('gemini', keylessId, { noKey: true, noList: true }))
await fill('.custom-model-id-combobox input', keylessId)
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'keyless Gemini ready')
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'keyless Gemini saved')
const keylessSession = await read(`window.ycode.session.create('Keyless Gemini',{modelId:${JSON.stringify(keylessId)}})`, main)
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(keylessSession.id)},text:'Reply briefly.'})`, main)
await wait(`window.ycode.session.messages(${JSON.stringify(keylessSession.id)}).then(rows=>rows.some(row=>row.role==='assistant'&&['done','error'].includes(row.status)))`, 'keyless Gemini turn', main)
const keylessReply = (await read(`window.ycode.session.messages(${JSON.stringify(keylessSession.id)})`, main)).filter(row => row.role === 'assistant').at(-1)
check('keyless Gemini completes through production native HTTP gateway', [keylessReply.status, keylessReply.content.includes('MODEL_PROTOCOL_OK')], ['done', true])
await step('clear-saved-key-without-an-auth-switch')
const clearable = {}, clearId = 'clearable-controlled'
await read(`window.ycode.app.openSettings('model','new')`, main)
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'clearable model form')
await fill('input[placeholder*="api.example.com"]', await upstream('openai', clearId, clearable))
await fill('input[type="password"]', 'controlled-model-key')
await fill('.custom-model-id-combobox input', clearId)
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'clearable model ready')
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'clearable model saved')
const clearProfile = (await read(`window.ycode.models.list()`)).models.find(model => model.id === clearId)
await edit(clearProfile)
clearable.noKey = true
await read(`document.querySelector('input[type="password"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Delete',bubbles:true}))`)
await fill('.custom-model-id-combobox input', clearId + '-renamed')
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'clear and rename ready')
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'cleared key persisted')
const cleared = (await read(`window.ycode.models.list()`)).models.find(model => model.id === clearId + '-renamed')
check('clearing the saved key remains keyless after changing the model ID', [cleared.authMode, cleared.hasApiKey], ['none', false])
await step('stale-model-fetch-and-connection-test')
await read(`window.ycode.app.openSettings('model','new')`, main)
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'race test form')
const slowList = { listDelay: 300 }, slowDomain = await upstream('openai', 'old-list', slowList)
await fill('input[placeholder*="api.example.com"]', slowDomain); await fill('input[type="password"]', 'controlled-model-key')
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
while (!slowList.listStarted) await pause(10)
const slowProbe = { probeDelay: 300 }, probeDomain = await upstream('openai', 'old-probe', slowProbe)
await fill('input[placeholder*="api.example.com"]', probeDomain)
while (!slowList.listEnded) await pause(10)
check('a late old-domain model list cannot refill the new-domain picker', await read(`document.querySelector('.custom-model-id-toggle').disabled`))
await fill('.custom-model-id-combobox input', 'old-probe')
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'initial probe model ready')
await click('.models-settings-panel__editor-test-connection')
while (!slowProbe.probeStarted) await pause(10)
await fill('.custom-model-id-combobox input', 'new-probe')
while (!slowProbe.probeEnded) await pause(10)
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'late probe settled')
const beforeProbeSave = requests.length
await click('.models-settings-panel__editor-save')
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'new model verified and saved')
check('a late test result cannot mark a changed model verified', requests.slice(beforeProbeSave).some(request => request.body.model === 'new-probe' && !request.stream))
await step('light-dark-form-and-menu')
await read(`window.ycode.app.openSettings('appearance')`, main)
await wait(`!!document.querySelector('.appearance-card--dark')`, 'appearance page')
await click('.appearance-card--dark')
await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme persisted')
await read(`window.ycode.app.openSettings('model','new')`, main)
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'dark model form')
check('supplier label no longer claims OpenAI only', await read(`!document.querySelector('.models-settings-panel__editor-body').textContent.includes('仅支持 OpenAI')`))
await shot('custom-editor-empty-dark')
await fill('input[placeholder*="api.example.com"]', await upstream('openai', 'gpt-5.4', { emptyList: true }))
await fill('input[type="password"]', 'controlled-model-key')
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
await wait(`!!document.querySelector('.model-ui-toast')?.textContent.includes('空列表')`, 'empty model list feedback')
check('empty lists allow manual input', await read(`!document.querySelector('.custom-model-id-combobox input').disabled`))
await fill('input[placeholder*="api.example.com"]', await upstream('gemini', 'gemini-2.5-flash'))
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
await wait(`!!document.querySelector('[role="option"]')`, 'dark model dropdown')
await shot('custom-editor-model-list-dark')
check('the dropdown aligns with the full input width', await read(`(()=>{const input=document.querySelector('.custom-model-id-combobox').getBoundingClientRect(),list=document.querySelector('.custom-model-options').getBoundingClientRect();return Math.abs(input.x-list.x)<1&&Math.abs(input.width-list.width)<1})()`))
check('the settled model dropdown is visible and opaque', await read(`(()=>{const style=getComputedStyle(document.querySelector('.custom-model-options'));return style.visibility==='visible'&&Number(style.opacity)>.99})()`))
await option('gemini-2.5-flash')
await fill('.custom-model-id-combobox input', 'gemini-2.5-flash')
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'dark metadata ready')
await read(`document.querySelector('.models-settings-panel__editor-body').scrollTop=10000`)
await shot('custom-editor-metadata-dark')
await click('.models-settings-panel__editor-cancel')
await step('metadata-update-in-about')
await read(`window.ycode.app.openSettings('helpFeedback')`, main)
await wait(`!!document.querySelector('#settings-about-model-metadata button')`, 'metadata update in About')
check('About contains an enabled metadata update action', await read(`!document.querySelector('#settings-about-model-metadata button').disabled`))
await shot('about-model-metadata-dark')
const originalFetch = globalThis.fetch
let refreshRequests = 0
globalThis.fetch = async (...args) => {
if (String(args[0]) !== 'https://models.dev/api.json') return originalFetch(...args)
refreshRequests++
return new Response(await fs.readFile(path.join(directory, 'build/resources/model-metadata/models.dev.json')), { headers: { 'content-type': 'application/json' } })
}
try {
await click('#settings-about-model-metadata button')
await wait(`document.querySelector('.help-feedback-panel__metadata-feedback')?.textContent==='模型元数据已更新'`, 'metadata refreshed through About')
check('About update calls the real metadata service once', refreshRequests, 1)
} finally { globalThis.fetch = originalFetch }
check('no renderer errors', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated built UI/preload/IPC, bundled metadata, encrypted storage and actual native OMP against loopback HTTP; no external model calls.', source, profiles, requests, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('CUSTOM_MODEL_UI_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('CUSTOM_MODEL_UI_FAILURE ' + JSON.stringify(failure))
for (const server of servers) server.closeAllConnections()
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, requests, rendererErrors }, null, 2)); console.error(error)
if (settings && !settings.isDestroyed()) await shot('failure').catch(() => undefined)
for (const server of servers) server.closeAllConnections()
app.exit(1)
})
}
+494
View File
@@ -0,0 +1,494 @@
// TODO 14 acceptance: WorkBuddy 5.8.3 memory settings, live YCode IPC, import flow and themes.
// Real isolated Electron, real preload/IPC/renderer/filesystem, viewport 900x680.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { createHash } = require('node:crypto')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/memory-settings-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
await fs.mkdir(data, { recursive: true })
await fs.mkdir(home, { recursive: true })
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home,
YCODE_DISABLE_GPU: '1',
YCODE_DISABLE_SYSTEM_INTEGRATION: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], {
env,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('MEMORY_SETTINGS_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 300000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-5000))
process.exitCode = code ? 1 : 0
})
})().catch(error => {
console.error(error)
process.exitCode = 1
})
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
app.setPath('home', path.join(directory, 'home'))
const checks = []
const snapshots = {}
const rendererErrors = []
let main
let settings
let phase = 'boot'
const pause = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
async function read(source, win = settings || main) {
let timer
try {
return await Promise.race([
win.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error(`Renderer timeout: ${source.slice(0, 120)}`)), 15000)
})
])
} finally {
if (timer) clearTimeout(timer)
}
}
async function wait(source, label, win = settings || main) {
for (let index = 0; index < 350; index++) {
const value = await read(source, win)
if (value) return value
await pause(40)
}
throw Error(`Timeout: ${label}`)
}
async function step(label) {
phase = label
await fs.writeFile(path.join(directory, 'phase.txt'), label)
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({
label,
pass,
actual,
expected: typeof expected === 'function' ? expected.toString() : expected
})
}
const near = (value, target, tolerance = 1) => Math.abs(value - target) <= tolerance
async function readJson(file) {
let lastError
for (let index = 0; index < 80; index++) {
try {
const text = await fs.readFile(file, 'utf8')
if (text.trim()) return JSON.parse(text)
} catch (error) {
lastError = error
}
await pause(25)
}
throw lastError ?? Error(`JSON file stayed empty: ${file}`)
}
async function waitForJson(file, predicate, label) {
let lastValue
for (let index = 0; index < 350; index++) {
try {
lastValue = await readJson(file)
if (predicate(lastValue)) return lastValue
} catch (error) {
lastValue = error
}
await pause(40)
}
throw Error(`Timeout: ${label}; last value: ${JSON.stringify(lastValue)}`)
}
function observe(win) {
win.webContents.on('console-message', event => {
if (event.level === 'error' || event.level >= 3) {
rendererErrors.push({ windowId: win.id, message: event.message })
}
})
}
async function openSettings(section) {
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`, main)
for (let index = 0; index < 200; index++) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'settings window')
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await pause(400)
return settings
}
const layoutSource = `(()=>{
const round = value => Math.round(value * 1000) / 1000
const rect = node => { if (!node) return null; const r = node.getBoundingClientRect()
return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } }
const style = node => node ? getComputedStyle(node) : null
const panel = document.querySelector('.memory-settings-panel')
const group = document.querySelector('.memory-settings-panel__generate-memory-group')
const rows = [...document.querySelectorAll('.memory-settings-panel__setting-row')]
const switches = [...document.querySelectorAll('.memory-settings-panel .wb-switch__input')].map(input => {
const label = input.closest('.wb-switch')
const track = label?.querySelector('.wb-switch__track')
const thumb = label?.querySelector('.wb-switch__thumb')
return { checked: input.checked, disabled: input.disabled, label: input.getAttribute('aria-label'),
track: rect(track), thumb: rect(thumb), trackBackground: style(track)?.backgroundColor,
thumbBackground: style(thumb)?.backgroundColor, title: label?.getAttribute('title') }
})
const manage = document.querySelector('.memory-settings-panel__manage-section')
const name = document.querySelector('.memory-settings-panel__setting-name')
const desc = document.querySelector('.memory-settings-panel__setting-desc')
const privacy = document.querySelector('.memory-settings-panel__privacy')
const link = document.querySelector('.memory-settings-panel__privacy-link')
return {
theme: document.documentElement.dataset.theme ?? 'light',
panel: rect(panel), group: rect(group), rows: rows.map(rect), switches,
manage: rect(manage),
titles: [...document.querySelectorAll('.memory-settings-panel__setting-name')].map(node => node.textContent.trim()),
descriptions: [...document.querySelectorAll('.memory-settings-panel__setting-desc')].map(node => node.textContent.trim()),
manageTitle: document.querySelector('.memory-modal__detail-name')?.textContent?.trim(),
manageDesc: document.querySelector('.memory-modal__detail-desc')?.textContent?.trim(),
importButton: (() => { const node = document.querySelector('.memory-settings-panel__manage-section .wb-button')
return node ? { text: node.textContent.trim(), rect: rect(node), background: style(node).backgroundColor, border: style(node).borderTopColor } : null })(),
privacy: rect(privacy), privacyText: privacy?.textContent?.replace(/\\s+/g, ' ').trim(),
link: link ? { href: link.getAttribute('href'), title: link.getAttribute('title'), ariaDisabled: link.getAttribute('aria-disabled') } : null,
titleStyle: name ? { fontSize: style(name).fontSize, fontWeight: style(name).fontWeight, lineHeight: style(name).lineHeight, color: style(name).color } : null,
descStyle: desc ? { fontSize: style(desc).fontSize, fontWeight: style(desc).fontWeight, lineHeight: style(desc).lineHeight, color: style(desc).color } : null,
groupBackground: style(group)?.backgroundColor,
manageBackground: style(manage)?.backgroundColor,
panelBackground: style(panel)?.backgroundColor,
privacyColor: style(privacy)?.color,
content: document.querySelector('.memory-settings-panel .memory-modal__memory-text')?.textContent ?? ''
}
})()`
const importLayoutSource = `(()=>{
const round = value => Math.round(value * 1000) / 1000
const rect = node => { if (!node) return null; const r = node.getBoundingClientRect()
return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } }
const style = node => node ? getComputedStyle(node) : null
const root = document.querySelector('[data-memory-import-dialog]')
const dialog = root?.querySelector('.wb-modal--medium')
const sections = [...(root?.querySelectorAll('.memory-modal__section') ?? [])]
const prompt = root?.querySelector('.memory-modal__prompt-text')
const textarea = root?.querySelector('textarea')
const buttons = [...(root?.querySelectorAll('.wb-modal__footer .wb-button') ?? [])]
return root ? {
dialog: rect(dialog),
title: dialog?.querySelector('.wb-modal__title')?.textContent?.trim(),
dialogBackground: style(dialog)?.backgroundColor,
dialogRadius: style(dialog)?.borderRadius,
sections: sections.map(rect),
labels: sections.map(section => section.querySelector('.memory-modal__label')?.textContent?.trim()),
prompt: prompt?.textContent ?? '',
promptHash: null,
textarea: textarea ? { rect: rect(textarea), placeholder: textarea.getAttribute('placeholder'), value: textarea.value } : null,
buttons: buttons.map(button => ({
text: button.textContent.trim(),
disabled: button.disabled,
className: button.className,
rect: rect(button)
})),
error: root.querySelector('[role=alert]')?.textContent?.trim() ?? ''
} : null
})()`
const resetLayoutSource = `(()=>{
const round = value => Math.round(value * 1000) / 1000
const rect = node => { if (!node) return null; const r = node.getBoundingClientRect()
return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } }
const style = node => node ? getComputedStyle(node) : null
const root = document.querySelector('[data-memory-reset-dialog]')
const dialog = root?.querySelector('.memory-reset-modal')
const body = dialog?.querySelector('.wb-modal__body')
const buttons = [...(dialog?.querySelectorAll('.wb-modal__footer .wb-button') ?? [])]
return root ? {
dialog: rect(dialog),
title: dialog.querySelector('.wb-modal__title')?.textContent?.trim(),
description: body?.querySelector('.memory-modal__reset-confirm-desc')?.textContent?.trim(),
hint: body?.querySelector('.memory-modal__reset-confirm-hint')?.textContent?.trim(),
background: style(dialog)?.backgroundColor,
radius: style(dialog)?.borderRadius,
closeLabel: dialog.querySelector('.wb-modal__close')?.getAttribute('aria-label'),
focused: document.activeElement?.textContent?.trim(),
buttons: buttons.map(button => ({
text: button.textContent.trim(),
disabled: button.disabled,
danger: button.classList.contains('wb-button--danger'),
background: style(button)?.backgroundColor,
border: style(button)?.borderTopColor,
rect: rect(button)
}))
} : null
})()`
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 200; index++) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'main window')
observe(main)
await wait('!!window.ycode', 'preload api', main)
await step('memory-base-light')
await openSettings('memory')
observe(settings)
await wait('!!document.querySelector(".memory-settings-panel")', 'memory panel', settings)
const light = await read(layoutSource, settings)
snapshots.light = light
await fs.writeFile(path.join(directory, 'ycode-memory-light.png'), (await settings.webContents.capturePage()).toPNG())
const nav = await read(`(()=>{const item=document.querySelector('[data-settings-tab="memory"]')
return { exists: !!item, disabled: item?.getAttribute('aria-disabled'), active: document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab }})()`, settings)
check('memory navigation entry is enabled and active', [nav.exists, nav.disabled !== 'true', nav.active], [true, true, 'memory'])
check('memory panel keeps the recovered 638x620 box at 232,40',
[near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 620)], [true, true, true, true])
check('two setting rows keep the recovered 638x128 group and 606x46 rows',
[near(light.group.x, 232), near(light.group.y, 40), near(light.group.width, 638), near(light.group.height, 128),
light.rows.map(row => [near(row.x, 248), near(row.width, 606), near(row.height, 46)].every(Boolean)),
near(light.rows[0].y, 52), near(light.rows[1].y, 110)], [true, true, true, true, [true, true], true, true])
check('memory copy and local-skills limitation are aligned',
[light.titles, light.descriptions, light.switches[1].title],
[['对话记忆', '本地记忆与技能'],
['记住对话中的重要信息,让后续回答更贴合你。', '自动记录工作过程并优化技能,数据仅保存在你的设备上。'],
'仅保存偏好;本地工作过程自动记录执行器尚未接入'])
check('switches keep recovered geometry and source default state',
light.switches.map(item => [item.checked, item.disabled, near(item.track.width, 34), near(item.track.height, 20),
near(item.thumb.width, 16), near(item.thumb.height, 16), item.trackBackground]),
[[true, false, true, true, true, true, 'rgb(0, 194, 154)'], [false, false, true, true, true, true, 'rgb(230, 230, 230)']])
check('manage card, typography and privacy footer keep recovered geometry',
[near(light.manage.x, 232), near(light.manage.y, 184), near(light.manage.width, 638), near(light.manage.height, 78),
light.manageTitle, light.manageDesc,
light.titleStyle, light.descStyle,
near(light.privacy.x, 232), near(light.privacy.y, 638), near(light.privacy.width, 638), near(light.privacy.height, 22),
light.privacyColor],
[true, true, true, true, '管理记忆', '每晚自动整理更新,可随时查看和编辑。',
{ fontSize: '14px', fontWeight: '500', lineHeight: '22px', color: 'rgb(0, 0, 0)' },
{ fontSize: '14px', fontWeight: '400', lineHeight: '22px', color: 'rgba(0, 0, 0, 0.7)' },
true, true, true, true, 'rgba(0, 0, 0, 0.7)'])
check('privacy legal target is visibly present but has no external URL',
[light.privacyText, light.link.href, light.link.title, light.link.ariaDisabled],
['记忆内容遵循 YCode 隐私政策,仅你本人可见', null, '尚未配置', 'true'])
await step('memory-switch-persistence')
await read(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0].click()`, settings)
await wait(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0]?.checked===false`, 'conversation memory off', settings)
let config = await readJson(path.join(directory, 'data', 'config.json'))
check('conversation-memory preference persists without changing local memory',
[config.memoryPreferences?.conversationMemory, config.memoryPreferences?.localMemorySkills], [false, false])
settings.close()
await pause(500)
await openSettings('memory')
await wait('!!document.querySelector(".memory-settings-panel")', 'memory panel reopened', settings)
const reopened = await read(layoutSource, settings)
check('reopened settings restores both independent preferences',
[reopened.switches[0].checked, reopened.switches[1].checked], [false, false])
await read(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0].click()`, settings)
await wait(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0]?.checked===true`, 'conversation memory restored', settings)
await wait(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1]?.disabled===false`, 'memory switches idle', settings)
config = await readJson(path.join(directory, 'data', 'config.json'))
check('conversation-memory round trip still leaves local memory disabled',
[config.memoryPreferences?.conversationMemory, config.memoryPreferences?.localMemorySkills], [true, false])
const enabledLocalSkills = await read(`(()=>{
const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1]
if(!(input instanceof HTMLInputElement)||input.disabled)return false
input.click()
return true
})()`, settings)
assert.equal(enabledLocalSkills, true, 'local memory switch must be enabled before the real click')
await wait(`(()=>{
const panel=document.querySelector('.memory-settings-panel')
const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1]
return panel?.getAttribute('aria-busy')==='false'&&input?.checked===true&&input?.disabled===false
})()`, 'local skills on IPC complete', settings)
config = await waitForJson(
path.join(directory, 'data', 'config.json'),
value => value.memoryPreferences?.localMemorySkills === true,
'local memory and skills preference persisted'
)
check('local memory and skills preference persists', config.memoryPreferences?.localMemorySkills, true)
const disabledLocalSkills = await read(`(()=>{
const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1]
if(!(input instanceof HTMLInputElement)||input.disabled)return false
input.click()
return true
})()`, settings)
assert.equal(disabledLocalSkills, true, 'local memory switch must be enabled before restoring')
await wait(`(()=>{
const panel=document.querySelector('.memory-settings-panel')
const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1]
return panel?.getAttribute('aria-busy')==='false'&&input?.checked===false&&input?.disabled===false
})()`, 'local skills restored to source default', settings)
await waitForJson(
path.join(directory, 'data', 'config.json'),
value => value.memoryPreferences?.localMemorySkills === false,
'local memory and skills preference restored'
)
await step('memory-import-dialog')
await read(`document.querySelector('.memory-settings-panel__manage-section .wb-button').click()`, settings)
await wait('!!document.querySelector("[data-memory-import-dialog]")', 'import dialog', settings)
const dialog = await read(importLayoutSource, settings)
snapshots.importDialog = dialog
await fs.writeFile(path.join(directory, 'ycode-memory-import.png'), (await settings.webContents.capturePage()).toPNG())
const promptHash = createHash('sha256').update(dialog.prompt).digest('hex')
check('import dialog keeps the recovered 640x520 centered shell',
[near(dialog.dialog.x, 130), near(dialog.dialog.y, 80), near(dialog.dialog.width, 640), near(dialog.dialog.height, 520),
dialog.dialogRadius], [true, true, true, true, '24px'])
check('import dialog keeps the recovered title, steps and placeholder',
[dialog.title, dialog.labels, dialog.textarea.placeholder],
['导入其他记忆', ['复制以下提示词到其他AI对话中', '将结果粘贴到下方,添加到 YCode 记忆'], '在此粘贴你的记忆'])
check('import prompt exactly matches the REA-recovered zh-CN template',
[dialog.prompt.length, Buffer.byteLength(dialog.prompt), promptHash],
[593, 1657, 'b9142225cbb0b036168e860077c7d8bdb0165f449e00772af55429b8673d632d'])
check('import footer keeps the recovered 68x32 action pair',
dialog.buttons.map(button => [button.text, near(button.rect?.width ?? 68, 68)]),
[['取消', true], ['导入', true]])
await step('memory-import-validation')
await read(`(()=>{const field=document.querySelector('[data-memory-import-dialog] textarea')
const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set
setter.call(field,'内容过短')
field.dispatchEvent(new Event('input',{bubbles:true}))})()`, settings)
await pause(80)
await read(`document.querySelectorAll('[data-memory-import-dialog] .wb-modal__footer .wb-button')[1].click()`, settings)
await wait(`document.querySelector('[data-memory-import-dialog] [role=alert]')?.textContent?.includes('内容过短')`, 'short memory error', settings)
check('short import content is rejected with recovered copy',
await read(`document.querySelector('[data-memory-import-dialog] [role=alert]').textContent.trim()`, settings),
'内容过短,请粘贴完整的记忆内容')
await step('memory-import-write')
const imported = '**偏好**\n[2026-10-17] - 用户偏好 TypeScript 和简洁的实现。'
await read(`(()=>{const field=document.querySelector('[data-memory-import-dialog] textarea')
const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set
setter.call(field,${JSON.stringify(imported)})
field.dispatchEvent(new Event('input',{bubbles:true}))})()`, settings)
await pause(80)
await read(`document.querySelectorAll('[data-memory-import-dialog] .wb-modal__footer .wb-button')[1].click()`, settings)
await wait(`!document.querySelector('[data-memory-import-dialog]')`, 'import dialog closes', settings)
const importedLayout = await wait(`(()=>{const node=document.querySelector('.memory-settings-panel .memory-modal__memory-text')
return node?{content:node.textContent,manage:document.querySelector('.memory-settings-panel__manage-section').getBoundingClientRect().height}:null})()`, 'imported memory visible', settings)
config = await readJson(path.join(directory, 'data', 'config.json'))
check('valid import writes to the same long-term memory used by conversations',
[importedLayout.content.includes('用户偏好 TypeScript'), config.personalization?.longTermMemory], [true, imported])
check('manage card expands after content exists while retaining the viewport footer',
[importedLayout.manage > 78,
near((await read(layoutSource, settings)).privacy.y, 638)],
[true, true])
await step('memory-dark')
await openSettings('appearance')
await wait('!!document.querySelector(".appearance-card")', 'appearance panel', settings)
await read(`document.querySelector('[aria-label="深色"].appearance-card').click()`, settings)
await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme', settings)
await openSettings('memory')
await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.memory-settings-panel')`, 'memory dark', settings)
const dark = await read(layoutSource, settings)
snapshots.dark = dark
await fs.writeFile(path.join(directory, 'ycode-memory-dark.png'), (await settings.webContents.capturePage()).toPNG())
check('dark theme keeps recovered geometry and paints card/text with shared tokens',
[near(dark.panel.width, 638), near(dark.panel.height, 620), near(dark.group.height, 128),
dark.groupBackground, dark.titleStyle.color, dark.descStyle.color, dark.switches[0].trackBackground],
[true, true, true, 'rgb(31, 31, 31)', 'rgb(242, 243, 245)', 'rgb(242, 243, 245)', 'rgb(34, 203, 168)'])
await openSettings('appearance')
await wait('!!document.querySelector(".appearance-card")', 'appearance panel again', settings)
await read(`document.querySelector('[aria-label="浅色"].appearance-card').click()`, settings)
await wait(`document.documentElement.dataset.theme==='light'`, 'light restored', settings)
await step('memory-reset-dialog')
await openSettings('memory')
await wait(`!!document.querySelector('.memory-settings-panel .memory-modal__memory-text')`, 'memory content before reset', settings)
const openReset = `(()=>{const button=[...document.querySelectorAll('.memory-settings-panel__manage-section .wb-button')]
.find(node=>node.textContent.trim()==='重置'); button?.click(); return !!button})()`
check('reset action is present after memory content exists', await read(openReset, settings), true)
await wait(`!!document.querySelector('[data-memory-reset-dialog]')`, 'reset confirmation dialog', settings)
const resetDialog = await read(resetLayoutSource, settings)
snapshots.resetDialog = resetDialog
await fs.writeFile(path.join(directory, 'ycode-memory-reset.png'), (await settings.webContents.capturePage()).toPNG())
check('reset dialog keeps the recovered 480px WorkBuddy confirm shell',
[near(resetDialog.dialog.width, 480), resetDialog.title, resetDialog.closeLabel, resetDialog.radius],
[true, '重置记忆', '关闭重置记忆', '24px'])
check('reset dialog keeps recovered destructive and recovery copy',
[resetDialog.description, resetDialog.hint],
['此操作将清空当前全部记忆,该操作不可恢复。',
'若「生成对话记忆」处于开启状态,后续对话仍会生成新的记忆;如需彻底停用,请同时关闭记忆功能。'])
check('reset dialog exposes cancel and danger confirm actions with cancel initially focused',
[resetDialog.focused, resetDialog.buttons.map(button => [button.text, button.danger]),
resetDialog.buttons.map(button => button.text === '取消' ? near(button.rect.width, 68) : button.rect.width >= 66)],
['取消', [['取消', false], ['重置记忆', true]], [true, true]])
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`, settings)
await wait(`!document.querySelector('[data-memory-reset-dialog]')`, 'reset dialog closes with Escape', settings)
await read(openReset, settings)
await wait(`!!document.querySelector('[data-memory-reset-dialog]')`, 'reset dialog reopened', settings)
await read(`document.querySelectorAll('[data-memory-reset-dialog] .wb-modal__footer .wb-button')[1].click()`, settings)
await wait(`!document.querySelector('[data-memory-reset-dialog]')&&!document.querySelector('.memory-settings-panel .memory-modal__memory-text')`, 'memory reset', settings)
config = await readJson(path.join(directory, 'data', 'config.json'))
const resetLayout = await read(layoutSource, settings)
check('reset clears the same long-term memory and restores the collapsed manage card',
[config.personalization?.longTermMemory, resetLayout.content, near(resetLayout.manage.height, 78)],
['', '', true])
await step('memory-errors')
check('no renderer error logs', rendererErrors, [])
const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL'
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({
result,
phase,
checks,
snapshots
}, null, 2))
console.log(`MEMORY_SETTINGS_${result} ${directory}`)
for (const failure of checks.filter(item => !item.pass)) {
console.log(`MEMORY_SETTINGS_FAILURE ${JSON.stringify(failure)}`)
}
app.exit(result === 'PASS' ? 0 : 1)
})().catch(async error => {
console.error(error)
try {
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({
result: 'ERROR',
phase,
checks,
snapshots,
error: String(error?.stack ?? error)
}, null, 2))
} catch { /* Keep original failure. */ }
app.exit(1)
})
}
Loaded 100 of 434 files, more files were not shown because too many files have changed in this diff. Show more