feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -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)`)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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="停止"/)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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&¤t?.id===assistantId&¤t.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')
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())) }
|
||||
@@ -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())) }
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }))
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
})
|
||||
@@ -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`)
|
||||
@@ -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`)
|
||||
@@ -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}`)
|
||||
@@ -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')
|
||||
@@ -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' : ''}`)
|
||||
@@ -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`)
|
||||
@@ -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 }))
|
||||
@@ -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}))
|
||||
@@ -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 }))
|
||||
@@ -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') }))
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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()=>{};
|
||||
` }))
|
||||
} }]
|
||||
})
|
||||
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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')
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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')}`)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
@@ -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 }))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user