Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ const baseDesktopBuilderConfig = {
entitlements: 'build/entitlements.mac.plist',
entitlementsInherit: 'build/entitlements.mac.inherit.plist',
extendInfo: {
NSMicrophoneUsageDescription: 'Maka uses your microphone during WorkHub voice calls.',
NSAppleEventsUsageDescription:
'Maka may automate other applications when you explicitly run an agent task.',
},
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/check-renderer-architecture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ function canonicalRendererEntryHtml(extraBody = '', policy = "script-src 'self'"
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; ${policy}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'"
content="default-src 'self'; ${policy}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; media-src 'self' blob:"
/>
<title>Maka</title>
<style>body { margin: 0; }</style>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/vite-renderer-entry-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const ALLOWED_HTML_TAGS = new Set([
'title',
]);
const CONTENT_SECURITY_POLICY =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'";
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; media-src 'self' blob:";

function normalizePath(path: string): string {
return path.split(sep).join('/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,15 @@ describe('main window Chromium permission policy', () => {
assert.equal(checkHandler(owner, 'clipboard-sanitized-write', 'file://', details), true);
});
});

// Calling never grants camera access or media access to another frame/window.
it('grants only audio during an explicitly armed trusted call', () => {
const input = { ownerMatches: true, rendererUrlMatches: true, permission: 'media', isMainFrame: true, voiceArmed: true };
assert.equal(allowsMainWindowPermissionCheck({ ...input, mediaType: 'audio' }), true);
assert.equal(allowsMainWindowPermissionCheck({ ...input, mediaType: 'video' }), false);
assert.equal(allowsMainWindowPermissionRequest({ ...input, mediaTypes: ['audio'] }), true);
assert.equal(allowsMainWindowPermissionRequest({ ...input, mediaTypes: ['audio', 'video'] }), false);
assert.equal(allowsMainWindowPermissionRequest({ ...input, mediaTypes: [] }), false);
assert.equal(allowsMainWindowPermissionCheck({ ...input, mediaType: 'audio', isMainFrame: false }), false);
assert.equal(allowsMainWindowPermissionCheck({ ...input, mediaType: 'audio', voiceArmed: false }), false);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { StoredMessage } from '@maka/core/session';
import type { LiveTurnProjection } from '@maka/ui';
import { workHubPublicConversation } from '../../renderer/features/workhub/testing.js';
const turnId = 'opaque-turn-id';
const user: StoredMessage = { type: 'user', id: 'input', turnId, ts: 1, text: 'Task result', workhubSource: 'voice_maintenance', presentation: 'internal' };
const raw = (text: string): LiveTurnProjection => ({ turnId, startedAt: 1,
steps: [{ stepId: 'raw-step', contentOrder: ['text'], tools: [], text: { text, complete: false, truncated: false } }] });
const answer: StoredMessage = { type: 'assistant', id: 'answer', turnId, ts: 3, text: 'Top5 已修改。', modelId: '' };
test('untagged maintenance never flashes at any stream boundary, including before input metadata arrives', () => {
const text = '队列为空,正在维护优先级';
for (let end = 0; end <= text.length; end++) {
assert.equal(workHubPublicConversation([user], raw(text.slice(0, end))).liveTurn, undefined);
assert.equal(workHubPublicConversation([], raw(text.slice(0, end))).liveTurn, undefined);
}
});
test('live, refreshed history and reload use the same public record exactly once', () => {
const hidden: StoredMessage = { ...answer, id: 'private', presentation: 'internal', text: 'PRIVATE_QUEUE_STATE' };
const messages = [user, hidden, answer];
for (const live of [raw('PRIVATE_RAW_TEXT'), undefined]) {
const view = workHubPublicConversation(messages, live);
assert.deepEqual(view.messages, [answer]);
assert.doesNotMatch(JSON.stringify(view), /PRIVATE|Task result/);
}
});
test('voice request steering into maintenance cannot make raw prose public', () => {
const live = raw('PRIVATE_PROCESS');
live.steps[0]!.leadingSteering = [{ id: 'real-user', ts: 2, content: { text: '改成 Top5', workhubSource: 'voice_request' } }];
const request: StoredMessage = { type: 'user', id: 'real-user', turnId, ts: 2, text: '改成 Top5', workhubSource: 'voice_request' };
const view = workHubPublicConversation([user, request, answer], live);
assert.deepEqual(view.messages, [request, answer]);
assert.equal(view.liveTurn, undefined);
});
test('normal text stays intact, including strings that previously acted as hiding tags', () => {
const input: StoredMessage = { type: 'user', id: 'input', turnId, ts: 1, text: '解释 <voice_queue> 标签', workhubSource: 'text_request' };
const live = raw('示例 <voice_queue>正文</voice_queue>');
const before = structuredClone(live);
assert.equal(workHubPublicConversation([input], live).liveTurn?.steps[0]?.text?.text, live.steps[0]?.text?.text);
assert.deepEqual(live, before);
assert.equal(workHubPublicConversation([input], live).liveTurn, live, 'ordinary WorkHub streams keep their tool and progress presentation');
assert.deepEqual(workHubPublicConversation([input]).messages, [input]);
});

45 changes: 45 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-voice-jev-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import {test, type TestContext} from 'node:test';
function credential(t: TestContext) { const previous=process.env.TYPESAFE_API_KEY; process.env.TYPESAFE_API_KEY='test-only'; t.after(()=>{if(previous===undefined)delete process.env.TYPESAFE_API_KEY;else process.env.TYPESAFE_API_KEY=previous;}); }
import {evaluateVoice} from '../workhub-voice-jev.js';
const input={facts:[{role:'user',text:'说结果'}],queue:[{id:'one',text:'四十二',context:''}],responses:[],deliveries:[]};
test('official Jev receives structured state, choice schema and abort signal',async t=>{
credential(t);
const signal=new AbortController().signal;
t.mock.method(globalThis,'fetch',async(url:unknown,init:RequestInit)=>{
assert.equal(url,'https://api.typesafe.ai/v1/systemone');assert.equal(init.signal,signal);
const body=JSON.parse(String(init.body));assert.equal(body.model,'jev-latest');assert.deepEqual(body.state,input);
assert.equal(body.questions.need0.type,'choice');
return Response.json({answers:{gap:{type:'choice',choice:'none'},need0:{type:'choice',choice:'yes'},repeat0:{type:'choice',choice:'no'},fit0:{type:'choice',choice:'yes'}}});
});
assert.deepEqual(await evaluateVoice(input,signal),{gap:false,items:{one:'inject'}});
});
test('official auth failure exposes status without provider response text',async t=>{
credential(t);
t.mock.method(globalThis,'fetch',async()=>new Response('private upstream body',{status:401}));
await assert.rejects(evaluateVoice(input,new AbortController().signal),{message:'TypeSafe Jev HTTP 401'});
});
test('missing official answer cannot approve a partially classified list',async t=>{
credential(t);
t.mock.method(globalThis,'fetch',async()=>Response.json({answers:{gap:{type:'choice',choice:'none'}}}));
await assert.rejects(evaluateVoice(input,new AbortController().signal),/Missing Jev decision/);
});
41 changes: 41 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-voice-jev-stress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { WorkHubVoiceJev } from '../workhub-voice-jev.js';
import type { WorkHubVoiceState } from '@maka/runtime-host/protocol';
const flush = () => new Promise<void>(resolve => setImmediate(resolve));
test('80 alternating interruptions invalidate approval and refresh without duplicate maintenance', async () => {
let calls=0, maintenance=0, settled=true;
let state: WorkHubVoiceState = {queue:[],deliveries:[]};
const jev = new WorkHubVoiceJev({callId:'stress',settled:()=>settled,flush:async()=>{},onError:assert.fail,
evaluate:async input=>{calls++;return {gap:false,items:Object.fromEntries(input.queue.map(item=>[item.id,'inject' as const]))};},
write:async input=>{if(input.review)maintenance++;return state;}});
for(let n=0;n<80;n++){
settled=false;jev.invalidate();
state={queue:[{id:`item-${n}`,text:`continuation ${n}`,context:''}],deliveries:[]};
jev.snapshot(state);await flush();assert.equal(jev.canSend(`item-${n}`),false);
settled=true;jev.fact(`turn-${n}`,{role:'user',text:`interruption ${n}`});
await jev.tick();assert.equal(jev.canSend(`item-${n}`),true);
for(let poll=0;poll<5;poll++){jev.snapshot(state);await flush();}
assert.equal(calls,n+1);
}
assert.equal(maintenance,0);jev.close();
});
97 changes: 97 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-voice-jev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { WorkHubVoiceJev, type JevDecision } from '../workhub-voice-jev.js';
import type { WorkHubVoiceState, WorkHubVoiceObservation } from '@maka/runtime-host/protocol';
const item = (id: string) => ({ id, text: id, context: '' });
const flush = () => new Promise<void>(resolve => setImmediate(resolve));
function fixture(decide: () => Promise<JevDecision>) {
let state: WorkHubVoiceState = { queue: [item('A'), item('B'), item('C')], deliveries: [] };
const writes: WorkHubVoiceObservation[] = [];
let settled = true;
const errors: string[] = [];
const jev = new WorkHubVoiceJev({ callId: 'call', settled: () => settled, flush: async () => {}, evaluate: decide,
write: async input => { writes.push(input); state = { ...state, queue: state.queue.filter(item => !input.discard?.some(d => d.id === item.id && d.text === item.text)) }; if (input.review) state.review = { id: input.id, callId: 'call', status: 'admitted', after: 0, through: 1 }; return state; }, onError: message => errors.push(message) });
return { jev, writes, errors, get state() { return state; }, set state(v) { state = v; }, set settled(v: boolean) { settled = v; } };
}
test('all discarded with no gap never activates WorkHub', async () => {
const f = fixture(async () => ({ gap: false, items: { A:'discard', B:'discard', C:'discard' } }));
f.jev.snapshot(f.state); await flush();
assert.equal(f.state.queue.length, 0); assert.equal(f.writes.filter(w => w.review).length, 0); f.jev.close();
});
test('rework is skipped, valid items approved, maintenance requested once', async () => {
let calls = 0;
const f = fixture(async () => { calls++; return { gap: false, items: { A:'rework', B:'discard', C:'inject' } }; });
f.jev.snapshot(f.state); await flush();
assert.equal(f.jev.canSend('A'), false); assert.equal(f.jev.canSend('C'), true);
assert.deepEqual(f.state.queue.map(i=>i.id), ['A','C']);
for(let i=0;i<30;i++) { f.jev.snapshot(f.state); await flush(); }
assert.equal(calls,2); assert.equal(f.writes.filter(w => w.review).length,1); f.jev.close();
});
test('new user speech invalidates in-flight deletion and approval', async () => {
let finish!: (d: JevDecision) => void;
const f = fixture(() => new Promise(resolve => { finish = resolve; }));
f.jev.snapshot(f.state); await flush();
f.settled = false; f.jev.invalidate();
finish({ gap: true, items: { A:'discard', B:'inject', C:'rework' } }); await flush();
assert.equal(f.writes.length,0); assert.equal(f.jev.canSend('B'),false); assert.equal(f.state.queue.length,3); f.jev.close();
});
test('changed list invalidates old evaluation; checks are serialized', async () => {
let finish!: (d: JevDecision) => void; let calls=0;
const f = fixture(() => { calls++; return new Promise(resolve => { finish=resolve; }); });
f.jev.snapshot(f.state); await flush();
f.state = {queue:[{...item('A'),text:'new text'}],deliveries:[]}; f.jev.snapshot(f.state);
assert.equal(calls,1);
finish({gap:false,items:{A:'inject',B:'inject',C:'inject'}}); await flush();
assert.equal(f.jev.canSend('A'),false);
f.jev.snapshot(f.state); await flush(); assert.equal(calls,2);
finish({gap:false,items:{A:'inject'}}); await flush(); assert.equal(f.jev.canSend('A'),true); f.jev.close();
});
test('API failure pauses list but does not hot-loop or request WorkHub', async () => {
let calls=0; const f=fixture(async()=>{calls++;throw Error('403');});
f.jev.snapshot(f.state); await flush();
for(let i=0;i<50;i++){f.jev.snapshot(f.state);await flush();}
assert.equal(calls,1); assert.equal(f.writes.length,0); assert.equal(f.jev.canSend('A'),false); assert.equal(f.errors.length,1); f.jev.close();
});

test('busy WorkHub admission retries one stable request without another model call', async t => {
t.mock.timers.enable({apis:['Date']});
let busy=true,calls=0;
const writes: WorkHubVoiceObservation[]=[];
const state: WorkHubVoiceState={queue:[],deliveries:[]};
const jev=new WorkHubVoiceJev({callId:'busy',settled:()=>true,flush:async()=>{},onError:assert.fail,
evaluate:async()=>{calls++;return {gap:true,items:{}};},write:async input=>{writes.push(input);return busy?state:{...state,review:{id:input.id,callId:'busy',status:'admitted',after:0,through:1}};}});
jev.snapshot(state);await flush();assert.equal(writes.length,1);
busy=false;t.mock.timers.tick(2001);await jev.tick();
assert.equal(writes.length,2);assert.equal(writes[0]!.id,writes[1]!.id);assert.equal(calls,1);
t.mock.timers.tick(2001);await jev.tick();assert.equal(writes.length,2);jev.close();
});
test('fresh evidence cancels an unadmitted obsolete maintenance request', async t => {
t.mock.timers.enable({apis:['Date']});
let gap=true;
const writes: WorkHubVoiceObservation[]=[];
const state: WorkHubVoiceState={queue:[],deliveries:[]};
const jev=new WorkHubVoiceJev({callId:'busy',settled:()=>true,flush:async()=>{},onError:assert.fail,
evaluate:async()=>({gap,items:{}}),write:async input=>{writes.push(input);return state;}});
jev.snapshot(state);await flush();assert.equal(writes.length,1);
gap=false;jev.fact('answered',{role:'assistant',text:'已经完成'});t.mock.timers.tick(2001);await jev.tick();
assert.equal(writes.length,1);jev.close();
});
32 changes: 32 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-voice-log-writer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { WorkHubVoiceLogWriter } from '../workhub-voice-log-writer.js';
import type { WorkHubVoiceObservation } from '@maka/runtime-host/protocol';
test('logs persist independently and failed writes retain stable identities', async () => {
const writes: WorkHubVoiceObservation[] = [];
let offline = true;
const writer = new WorkHubVoiceLogWriter({callId:'call',write:async input => { writes.push(input); if(offline) throw Error('offline'); return {queue:[],deliveries:[]}; },onError:()=>{}});
writer.record({id:'stable',kind:'transcript_delta',data:{delta:'hello'}});
await assert.rejects(writer.drain()); offline=false; await writer.drain();
assert.deepEqual(writes[0]!.entries,writes[1]!.entries);
assert.ok(writes.every(w=>!w.review)); await writer.close();
});
Loading
Loading