Merge remote-tracking branch 'origin/master' into mergebot/pr1016

This commit is contained in:
imccyu
2026-07-31 14:43:03 +08:00
145 changed files with 2856 additions and 608 deletions

View File

@@ -160,8 +160,8 @@ async function harness(options?: {
await ctx.plugin(LlmService)
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
// The proxy serves only namespaces a configurable provider addresses, which
// is what the real LLM plugins declare at load; the tests mirror that.
// Model-provider namespaces plus the explicit Web preference and product
// onboarding allowlists are the proxy's complete settings surface.
if (options?.configurableProviders !== false) {
ctx.llm.registerConfigurableProviders([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
@@ -222,19 +222,29 @@ describe('settings domain', () => {
expect(JSON.stringify(value)).not.toContain('user-secret')
})
it('keeps arbitrary plugin namespaces outside the explicit Web allowlist', async () => {
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane is not — it is the
// model-provider surface, and a namespace nothing in the provider
// directory addresses must be invisible and unwritable here, so a future
// plugin cannot become remotely configurable just by registering.
// its own configuration. The Web configuration plane remains opt-in, so a
// future internal plugin cannot become remotely configurable just by
// registering; permission and the product onboarding namespace are the
// non-model namespaces intentionally admitted by this surface.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
ctx.settings.register(settingsNamespace('permission'), z.object({
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
}), {
base: { defaultPreset: 'read-only' },
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek'])
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
})))
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
for (const response of [
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
@@ -292,6 +302,20 @@ describe('settings domain', () => {
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
})
it('broadcasts a permission change without invalidating the model catalog', async () => {
const ctx = await harness()
const permission = ctx.settings.register(settingsNamespace('permission'), z.object({
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
}), {
base: { defaultPreset: 'read-only' },
})
const api = createApiProxy(ctx, DEFAULTS)
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => {
await permission.update({ defaultPreset: 'workspace-write' })
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
})
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)

View File

@@ -441,4 +441,44 @@ describe('Host Workspace increments', () => {
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
abort.abort()
})
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
const { api } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
const sessionId = SessionId('session-to-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const changed = nextHostFrame(stream)
expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
.toEqual([sessionId])
expect(await changed).toMatchObject({
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
})
// Accounting and the session itself are untouched; list re-baselines the set.
const listed = expectOk(await api.workspace.list(request({})))
expect(listed.archivedSessionIds).toEqual([sessionId])
expect(listed.items[0]?.sessionIds).toEqual([sessionId])
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
// The idempotent repeat emits no second frame: the next observed frame is
// the workspace-changed of a later attach, not another archive snapshot.
const after = nextHostFrame(stream)
expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
.toEqual([sessionId])
const otherSession = SessionId('session-after-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
expect(missing.result).toMatchObject({
ok: false,
error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
})
abort.abort()
})
})

View File

@@ -66,11 +66,12 @@ function scriptedApi(overrides: {
...overrides.host,
},
workspace: {
list: r => ok(r, { items: [] }),
list: r => ok(r, { items: [], archivedSessionIds: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
delete: r => ok(r, { deleted: true as const }),
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
},
commands: {
list: r => ok(r, { commands: [] }),
@@ -360,10 +361,12 @@ describe('workspace domain round trip', () => {
it('routes both workspace methods through their handler rows and value schemas', async () => {
const c = client(scriptedApi())
const list = await c.workspace.list({})
expect(list.result).toEqual({ ok: true, value: { items: [] } })
expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } })
const created = await c.workspace.create({ path: '/t' })
expect(created.result.ok).toBe(true)
if (created.result.ok) expect(created.result.value.created).toBe(true)
const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never })
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
})
it('rejects a create payload violating the exactly-one refine at the handler', async () => {

View File

@@ -122,7 +122,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
workspace: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } }
},
async create(request) {
return {
@@ -145,6 +145,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
}
},
async archiveSession(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } }
},
},
commands: {
async list(request) {

View File

@@ -20,6 +20,7 @@ import {
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import {
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
@@ -312,7 +313,16 @@ describe('workspace domain schemas', () => {
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
expect(workspaceListRequestSchema.parse({})).toEqual({})
expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1)
expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow()
})
it('archiveSession request/value carry the id and the full updated set', () => {
expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow()
expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds)
.toEqual(['s1', 's2'])
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
})
it('create requires exactly one of path/name (both refine arms)', () => {