Merge remote-tracking branch 'origin/master' into mergebot/pr1016
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user