stillmade-sdk 0.1.35 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +8 -4
  2. package/DESKTOP_CAPABILITIES.md +81 -6
  3. package/FRAME_VIEWS.md +5 -0
  4. package/MODULES.md +104 -11
  5. package/PASTE_TO_LLM.md +14 -6
  6. package/SDK.md +245 -50
  7. package/blocks/connected-app/versions/1.6.0/view.js +27 -0
  8. package/blocks/gmail/versions/1.1.0/workspace.js +15 -0
  9. package/blocks/native/sm.when/versions/1.1.0/package.js +216 -0
  10. package/package.json +1 -1
  11. package/packages/block-cli/cli.js +27 -32
  12. package/packages/block-cli/dev-host/assets/host.js +38 -37
  13. package/packages/block-cli/dev-host/assets/index.css +1 -1
  14. package/packages/block-cli/dev-host/index.html +1 -0
  15. package/packages/block-cli/format.js +8 -0
  16. package/packages/block-platform/catalog.js +17 -1
  17. package/packages/block-platform/segment-runner.js +10 -4
  18. package/packages/block-platform/steps.js +6 -0
  19. package/packages/block-sdk/admission.js +11 -4
  20. package/packages/block-sdk/builder-contract.js +10 -5
  21. package/packages/block-sdk/capabilities.js +3 -0
  22. package/packages/block-sdk/connected-app.js +1 -1
  23. package/packages/block-sdk/contracts.js +1 -0
  24. package/packages/block-sdk/desktop-permissions.js +1 -1
  25. package/packages/block-sdk/first-party-identities.js +1 -0
  26. package/packages/block-sdk/first-party-packages.js +2 -1
  27. package/packages/block-sdk/folder-package.js +75 -0
  28. package/packages/block-sdk/index.d.ts +32 -6
  29. package/packages/block-sdk/index.js +4 -0
  30. package/packages/block-sdk/local-admission.js +4 -2
  31. package/packages/block-sdk/media-transform.js +42 -2
  32. package/packages/block-sdk/mock-host.js +3 -16
  33. package/packages/block-sdk/mocks.js +20 -0
  34. package/packages/block-sdk/module-host-calls.js +23 -0
  35. package/packages/block-sdk/module-hosted.js +22 -1
  36. package/packages/block-sdk/module-node.js +15 -20
  37. package/packages/block-sdk/module-runtime.js +7 -4
  38. package/packages/block-sdk/module-sockets.js +66 -0
  39. package/packages/block-sdk/module-worker.js +38 -1
  40. package/packages/block-sdk/network.js +20 -0
  41. package/packages/block-sdk/permission-summary.js +97 -0
  42. package/packages/block-sdk/permission-usage.js +72 -0
  43. package/packages/block-sdk/port-types.js +1 -1
  44. package/packages/block-sdk/schema-fields.js +11 -4
  45. package/packages/block-sdk/testing.js +20 -7
  46. package/packages/block-sdk/view-bridge.js +2 -1
  47. package/blocks/connected-app/versions/1.5.0/view.js +0 -18
  48. package/blocks/gmail/versions/1.0.0/workspace.js +0 -6
@@ -14,12 +14,40 @@ export const MODULE_WORKER_SOURCE = String.raw`(function(scope){
14
14
  function text(value){try{return typeof value==='string'?value:JSON.stringify(value);}catch(e){return String(value);}}
15
15
  async function bufferOf(data){
16
16
  // A copy, so the Block's own buffer stays usable after the host takes the bytes.
17
+ // Shared memory (threaded WebAssembly) is copied into an ordinary buffer, which can be transferred.
17
18
  if(data instanceof ArrayBuffer)return data.slice(0);
18
- if(ArrayBuffer.isView(data))return data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength);
19
+ if(typeof SharedArrayBuffer!=='undefined'&&data instanceof SharedArrayBuffer){var whole=new ArrayBuffer(data.byteLength);new Uint8Array(whole).set(new Uint8Array(data));return whole;}
20
+ if(ArrayBuffer.isView(data)){var copy=new ArrayBuffer(data.byteLength);new Uint8Array(copy).set(new Uint8Array(data.buffer,data.byteOffset,data.byteLength));return copy;}
19
21
  if(data&&typeof data.arrayBuffer==='function')return data.arrayBuffer();
20
22
  if(typeof data==='string')return new TextEncoder().encode(data).buffer;
21
23
  throw new TypeError('files.write takes an ArrayBuffer, typed array, Blob or string');
22
24
  }
25
+ // Live connections the host opened for this run, by id: each receives its
26
+ // messages and close from the host and keeps them for its listeners and iterator.
27
+ var sockets=new Map();
28
+ function liveSocket(id,url,protocol){
29
+ var target=new EventTarget(),state=1,queue=[],queued=0,waiting=[],ended=null,finish;
30
+ var closed=new Promise(function(resolve){finish=resolve;});
31
+ function dispatch(type,fields){var event=new Event(type);Object.keys(fields).forEach(function(key){Object.defineProperty(event,key,{value:fields[key],enumerable:true});});var handler=socket['on'+type];if(typeof handler==='function'){try{handler.call(socket,event);}catch(error){setTimeout(function(){throw error;});}}target.dispatchEvent(event);}
32
+ // for await (const data of socket) yields every message not yet read, including ones that
33
+ // arrived before the loop began: at most 1,024 messages or 16 MiB wait, the oldest dropped first.
34
+ function size(data){return typeof data==='string'?data.length*2:data.byteLength||0;}
35
+ function settle(){while(waiting.length&&(queue.length||ended)){var next=waiting.shift();if(queue.length){var data=queue.shift();queued-=size(data);next({value:data,done:false});}else next({value:undefined,done:true});}}
36
+ var socket={
37
+ url:url,protocol:protocol,binaryType:'arraybuffer',onmessage:null,onclose:null,closed:closed,
38
+ get readyState(){return state;},
39
+ send:async function(data){if(state!==1)throw Object.assign(new Error('This live connection is not open.'),{code:'NETWORK_SOCKET_CLOSED'});var payload=typeof data==='string'?data:await bufferOf(data);return call('net.socket',{id:id,op:'send',data:payload},typeof payload==='string'?[]:[payload]);},
40
+ close:function(code,reason){if(state>=2)return closed;state=2;call('net.socket',{id:id,op:'close',code:code,reason:reason}).catch(function(){});return closed;},
41
+ addEventListener:function(type,listener,options){target.addEventListener(type,listener,options);},
42
+ removeEventListener:function(type,listener,options){target.removeEventListener(type,listener,options);}
43
+ };
44
+ socket[Symbol.asyncIterator]=function(){return {next:function(){return new Promise(function(resolve){waiting.push(resolve);settle();});},return:function(){socket.close();return Promise.resolve({value:undefined,done:true});}};};
45
+ sockets.set(id,function(message){
46
+ if(message.event==='message'){queue.push(message.data);queued+=size(message.data);while(queue.length>1024||queued>16777216){queued-=size(queue.shift());}dispatch('message',{data:message.data});settle();}
47
+ else if(message.event==='close'){state=3;sockets.delete(id);ended={code:message.code,reason:message.reason,wasClean:message.wasClean};dispatch('close',ended);finish(ended);settle();}
48
+ });
49
+ return socket;
50
+ }
23
51
  function call(api,args,transfer){return new Promise(function(resolve,reject){var id=++sequence;pending.set(id,{resolve:resolve,reject:reject});post({type:'call',id:id,api:api,args:args},transfer);});}
24
52
  var host=Object.freeze({
25
53
  signal:controller.signal,
@@ -67,6 +95,14 @@ export const MODULE_WORKER_SOURCE = String.raw`(function(scope){
67
95
  var result=await call('net.fetch',{request:{url:String(url&&url.href||url),method:method,headers:headers,body:body}},body?[body]:[]);
68
96
  var empty=method==='HEAD'||[101,204,205,304].indexOf(result.status)>=0;
69
97
  return new Response(empty?null:result.body,{status:result.status,statusText:result.statusText||'',headers:result.headers||{}});
98
+ },
99
+ // A live WebSocket to the wss:// form of a declared origin, opened by the
100
+ // host outside this sandbox. Resolves once it is open; no key is added.
101
+ connect:async function(url,options){
102
+ options=options||{};var href=String(url&&url.href||url),given=options.protocols;
103
+ var protocols=given==null?[]:Array.isArray(given)?given.map(String):[String(given)];
104
+ var opened=await call('net.connect',{request:{url:href,protocols:protocols}});
105
+ return liveSocket(opened.id,href,opened.protocol);
70
106
  }})
71
107
  });
72
108
  async function load(source){
@@ -83,6 +119,7 @@ export const MODULE_WORKER_SOURCE = String.raw`(function(scope){
83
119
  return;
84
120
  }
85
121
  if(message.type==='cancel'){controller.abort();return;}
122
+ if(message.type==='socket'){var deliver=sockets.get(message.id);if(deliver)deliver(message);return;}
86
123
  if(message.type!=='start'||started)return;
87
124
  started=true;files=message.files||{};
88
125
  try{
@@ -87,6 +87,26 @@ export function networkRequest(request,declarations){
87
87
  return {url:url.href,origin:url.origin,method,headers:normalized,body:bytes,auth:declared.auth};
88
88
  }
89
89
 
90
+ // Live connections (stillmade.net.connect): a WebSocket to the wss:// form of a
91
+ // declared origin, opened by StillMade outside the Block's sandbox and relayed
92
+ // message by message. The host adds no key to it: a service that needs one is
93
+ // asked for a short-lived token with net.fetch first.
94
+ export const SOCKET_LIMITS=Object.freeze({sockets:4,messageBytes:1048576,runBytes:268435456,protocols:8});
95
+ const SUBPROTOCOL=/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/;
96
+ /** Check one live connection a Block opens. Returns {url, origin, protocols}. */
97
+ export function socketRequest(request,declarations){
98
+ if(!plain(request))fail('net.connect takes a wss:// URL and options');
99
+ const {url:raw,protocols=[]}=request;
100
+ if(typeof raw!=='string'||raw.length>NETWORK_LIMITS.urlLength)fail('net.connect needs a full wss:// URL');
101
+ let url;try{url=new URL(raw);}catch{fail('net.connect needs a full wss:// URL');}
102
+ if(url.protocol!=='wss:'||url.username||url.password||url.port)fail('net.connect opens wss:// URLs on the standard port, without credentials');
103
+ url.hash='';
104
+ const origin=`https://${url.host}`;
105
+ if(!declarations.some(entry=>entry.origin===origin))fail(`${origin} is not one of this Block's declared network origins`,'NETWORK_ORIGIN');
106
+ if(!Array.isArray(protocols)||protocols.length>SOCKET_LIMITS.protocols||new Set(protocols).size!==protocols.length||!protocols.every(name=>typeof name==='string'&&SUBPROTOCOL.test(name)))fail(`net.connect protocols are up to ${SOCKET_LIMITS.protocols} distinct subprotocol names`);
107
+ return {url:url.href,origin,protocols:[...protocols]};
108
+ }
109
+
90
110
  // The apps a module Block declares in permissions.connections, by public app ID:
91
111
  // - published OpenAPI adapters (app:openapi:<slug>): the server builds each
92
112
  // request from the reviewed definition and adds the person's own key or
@@ -0,0 +1,97 @@
1
+ // What a Block can do, in words a person reads at import, install and update,
2
+ // instead of its raw permission JSON. Every Block is held to what it declares:
3
+ // the host refuses anything else, so this summary is the whole of what a Block
4
+ // can reach. `level` says how much care a Block needs, from what it can reach:
5
+ // none it works only inside StillMade (media in, media out, project edits you accept)
6
+ // low it reaches what you choose at the moment (a folder you pick, the camera after Start),
7
+ // or talks to the sites it names
8
+ // medium it changes your files, reads your clipboard or cursor, or uses an account you connected
9
+ // high it runs a program on your computer, or can send what it reads from your device to a site
10
+ import {networkDeclarations,connectionDeclarations} from './network.js';
11
+
12
+ export const PERMISSION_LEVELS=Object.freeze(['none','low','medium','high']);
13
+ const rank=level=>PERMISSION_LEVELS.indexOf(level);
14
+ const higher=(a,b)=>rank(a)>=rank(b)?a:b;
15
+
16
+ const DESKTOP={
17
+ 'screen.capture':['Records your screen or a window you choose, after you press Start','low','device'],
18
+ 'camera.capture':['Uses your camera after you press Start','low','device'],
19
+ 'microphone.capture':['Uses your microphone after you press Start','low','device'],
20
+ 'cursor.track':['Sees where your cursor is on screen','medium','device'],
21
+ 'clipboard.read':['Reads text you copied','medium','device'],
22
+ 'clipboard.write':['Replaces the text you copied','low',null],
23
+ 'media.pick':['Opens media files you choose','none','chosen'],
24
+ 'notifications.show':['Shows notifications','none',null],
25
+ 'power.keep-awake':['Keeps your computer awake while it works','none',null],
26
+ 'native.tools':['Runs an approved program on your computer through StillMade','high',null],
27
+ 'folder.read':['Reads files in a folder you choose','low','device'],
28
+ 'folder.write':['Creates and replaces media and text files in a folder you choose','medium',null],
29
+ };
30
+ const HOSTED={
31
+ 'text.generate':'writes text with AI','audio.speech':'turns text into speech','audio.transcribe':'transcribes audio',
32
+ 'audio.music':'generates music','audio.sfx':'generates sound effects','image.generate':'generates images',
33
+ 'video.generate':'generates video','image.describe':'describes images','media.analyze':'analyzes media',
34
+ 'web.fetch':'reads a public web page','web.research':'researches a topic on the web','timeline.propose':'suggests timeline edits',
35
+ 'workspace.propose':'suggests workspace edits','connection.execute':'uses an app you connected','comfyui.execute':'runs a ComfyUI workflow',
36
+ };
37
+ const ACTIONS={
38
+ 'final.render':'renders your final video','stock.search':'searches stock media','presenter.generate':'makes a presenter video',
39
+ 'quality.analyze':'checks quality','image.generate':'generates images','image.generate.batch':'generates images in batches',
40
+ 'media.generate':'generates media','youtube.upload':'uploads to your YouTube channel after you confirm',
41
+ };
42
+ const app=entry=>entry.slug.replace(/[-_]+/g,' ');
43
+ // Hosted operations and actions that send what a Block gives them to a place the
44
+ // Block chooses: a web address, a connected app, a public upload.
45
+ const OUTWARD_SERVICES=Object.freeze({'web.fetch':'web pages it chooses','web.research':'web searches','connection.execute':'a connected app'});
46
+ const OUTWARD_ACTIONS=Object.freeze({'youtube.upload':'your YouTube channel'});
47
+
48
+ const host=origin=>{try{return new URL(origin).host;}catch{return String(origin);}};
49
+ const list=items=>items.length<=2?items.join(' and '):`${items.slice(0,-1).join(', ')} and ${items.at(-1)}`;
50
+
51
+ /** Plain-language lines and an overall level for a manifest's permissions. */
52
+ export function summarizePermissions(manifest){
53
+ const permissions=manifest?.permissions||{},lines=[];let level='none';
54
+ const add=(id,text,lineLevel,family)=>{lines.push({id,text,level:lineLevel,family});level=higher(level,lineLevel);};
55
+ const project=Array.isArray(permissions.project)?permissions.project:[];
56
+ const reads=project.filter(scope=>/^context\.[a-zA-Z]+\.read$/.test(scope)||['project.read','timeline.read','asset.read'].includes(scope));
57
+ if(reads.length)add('project.read','Reads the parts of this project it works on','none','project');
58
+ if(project.some(scope=>/\.propose$/.test(scope)||scope==='project.patch'))add('project.propose','Suggests changes to your project that you accept or decline','none','project');
59
+ if(project.includes('asset.create')||project.includes('asset.version.append'))add('asset.create','Adds media to your project','none','project');
60
+ if(project.includes('work.shared'))add('work.shared','Shares work with people on this project','low','project');
61
+ const hosted=(Array.isArray(permissions.capabilities)?permissions.capabilities:[]).map(name=>HOSTED[name]||name);
62
+ if(hosted.length)add('capabilities',`Uses StillMade services that ${list(hosted)}, with credits you approve`,'low','service');
63
+ const actions=(Array.isArray(permissions.actions)?permissions.actions:[]).map(name=>ACTIONS[name]||name);
64
+ if(actions.length)add('actions',`Asks StillMade to do work that ${list(actions)}`,'low','service');
65
+ let network=[],connections=[];
66
+ try{network=networkDeclarations(permissions.network);}catch{}
67
+ try{connections=connectionDeclarations(permissions.connections);}catch{}
68
+ if(network.length){
69
+ const plainOrigins=network.filter(entry=>!entry.auth).map(entry=>host(entry.origin)),keyed=network.filter(entry=>entry.auth).map(entry=>host(entry.origin));
70
+ if(plainOrigins.length)add('network',`Connects to ${list(plainOrigins)}`,'low','network');
71
+ if(keyed.length)add('network.auth',`Uses a key you enter to connect to ${list(keyed)}`,'medium','network');
72
+ }
73
+ if(connections.length)add('connections',`Uses ${connections.length===1?'an account':'accounts'} you connected (${list(connections.map(app))})`,'medium','network');
74
+ const desktop=Array.isArray(permissions.desktop)?permissions.desktop:[];
75
+ for(const scope of desktop)if(DESKTOP[scope])add(scope,DESKTOP[scope][0],DESKTOP[scope][1],'device');
76
+ // The one combination that matters most: something read from the device, and a way out.
77
+ const services=Array.isArray(permissions.capabilities)?permissions.capabilities:[],asked=Array.isArray(permissions.actions)?permissions.actions:[];
78
+ const fromDevice=desktop.filter(scope=>DESKTOP[scope]?.[2]==='device'),outward=[...network.map(entry=>host(entry.origin)),...connections.map(app),...services.filter(name=>OUTWARD_SERVICES[name]).map(name=>OUTWARD_SERVICES[name]),...asked.filter(name=>OUTWARD_ACTIONS[name]).map(name=>OUTWARD_ACTIONS[name])];
79
+ const dataLeaves=fromDevice.length>0&&outward.length>0;
80
+ if(dataLeaves)add('data.leaves',`Can send what it reads from your computer to ${list([...new Set(outward)])}`,'high','network');
81
+ const inside=!network.length&&!connections.length;
82
+ return {level,lines,dataLeaves,staysInStillMade:inside&&!desktop.length,headline:headline(level,inside)};
83
+ }
84
+
85
+ function headline(level,inside){
86
+ if(level==='none')return 'Works only inside StillMade';
87
+ if(level==='low')return inside?'Uses only what you choose':'Uses what you choose and the sites it names';
88
+ if(level==='medium')return 'Can change files or use your accounts';
89
+ return 'Needs your trust: it can run programs or send data from your computer';
90
+ }
91
+
92
+ /** What an update adds: lines in next that previous lacked (by id), and whether its level rose. */
93
+ export function permissionChanges(previousManifest,nextManifest){
94
+ const before=summarizePermissions(previousManifest),after=summarizePermissions(nextManifest),known=new Set(before.lines.map(line=>line.id+'\u0000'+line.text));
95
+ const added=after.lines.filter(line=>!known.has(line.id+'\u0000'+line.text));
96
+ return {added,removed:before.lines.filter(line=>!after.lines.some(next=>next.id===line.id&&next.text===line.text)),levelRaised:rank(after.level)>rank(before.level),before,after};
97
+ }
@@ -0,0 +1,72 @@
1
+ // Which permissions a Block's own source uses, so a creator never has to work them
2
+ // out: the CLI reports what the source calls but does not declare (StillMade would
3
+ // refuse it at run time) and what it declares but never calls (people would be
4
+ // told it can do more than it does), and `permissions --fix` corrects the
5
+ // manifest. Advice only: it reads string literals in the view, the JavaScript and
6
+ // the module files, so a call built at run time can hide from it, and it never
7
+ // blocks a test, pack or release.
8
+ import {DESKTOP_PERMISSIONS} from './desktop-permissions.js';
9
+ import {networkDeclarations,networkOrigin} from './network.js';
10
+
11
+ const OPERATION_SCOPES={
12
+ 'clipboard.read':'clipboard.read','clipboard.write':'clipboard.write','cursor.track':'cursor.track',
13
+ 'media.pick':'media.pick','notifications.show':'notifications.show','power.keep-awake':'power.keep-awake',
14
+ };
15
+ const RECORDING=['screen.capture','camera.capture','microphone.capture'];
16
+ const TEXT_FILE=/\.(m?js|cjs|jsx|ts|tsx|html?|json)$/i;
17
+ const decoder=new TextDecoder('utf-8',{fatal:false});
18
+
19
+ /** The text a package's code is written in: interface, JavaScript and module text files. */
20
+ export function packageSourceText(pkg,files={},{view=true}={}){
21
+ const parts=[...(view?[pkg?.view?.html,pkg?.view?.javascript]:[]),pkg?.code];
22
+ for(const [name,value] of Object.entries(files||{})){
23
+ if(!TEXT_FILE.test(name.replace(/^view:/,''))||!view&&name.startsWith('view:'))continue;
24
+ parts.push(typeof value==='string'?value:decoder.decode(value instanceof Uint8Array?value:new Uint8Array(value)));
25
+ }
26
+ return parts.filter(part=>typeof part==='string').join('\n');
27
+ }
28
+
29
+ const literal=(text,value)=>new RegExp(`['"\`]${value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}['"\`]`).test(text);
30
+
31
+ /** Desktop scopes and network origins the source uses, and how they compare with the manifest. */
32
+ export function permissionUsage(pkg,files={}){
33
+ const text=packageSourceText(pkg,files),manifest=pkg?.manifest||{},declaredDesktop=manifest.permissions?.desktop||[];
34
+ const desktop=new Set();
35
+ for(const [operation,scope] of Object.entries(OPERATION_SCOPES))if(literal(text,operation))desktop.add(scope);
36
+ const folderCalls=['folder.pick','folder.shared','folder.list','folder.read','folder.write','folder.forget'].filter(operation=>literal(text,operation));
37
+ if(folderCalls.length)desktop.add('folder.read');
38
+ if(literal(text,'folder.write')||folderCalls.includes('folder.pick')&&/write\s*:\s*true/.test(text))desktop.add('folder.write');
39
+ if(/['"`]tool\.[a-z.]+['"`]/.test(text))desktop.add('native.tools');
40
+ // Which device a recording or live feed uses is decided at run time; any call keeps the declared recording scopes.
41
+ const records=literal(text,'record.start')||literal(text,'live.start');
42
+ let declaredOrigins=[];try{declaredOrigins=networkDeclarations(manifest.permissions?.network).map(entry=>entry.origin);}catch{}
43
+ // Network calls run in module code, never in the interface, whose links are only text.
44
+ const code=packageSourceText(pkg,files,{view:false}),calls=/\bnet\s*\.\s*(fetch|connect)\b/.test(code),origins=new Set();
45
+ for(const match of code.matchAll(/['"`]((?:https|wss):\/\/[a-z0-9.-]+(?::\d+)?)[/'"`?#]/gi)){
46
+ try{origins.add(networkOrigin(match[1].replace(/^wss:/i,'https:')));}catch{}
47
+ }
48
+ const undeclared=[],unused=[];
49
+ for(const scope of desktop)if(!declaredDesktop.includes(scope))undeclared.push({family:'desktop',scope});
50
+ for(const scope of declaredDesktop){
51
+ if(RECORDING.includes(scope)){if(!records)unused.push({family:'desktop',scope});continue;}
52
+ if(!desktop.has(scope)&&DESKTOP_PERMISSIONS.includes(scope))unused.push({family:'desktop',scope});
53
+ }
54
+ if(calls)for(const origin of origins)if(!declaredOrigins.includes(origin))undeclared.push({family:'network',scope:origin});
55
+ if(!calls)for(const origin of declaredOrigins)unused.push({family:'network',scope:origin});
56
+ return {desktop:[...desktop],origins:[...origins],undeclared,unused};
57
+ }
58
+
59
+ /** The manifest with what the source uses declared and what it never uses removed. */
60
+ export function fixPermissions(manifest,usage){
61
+ const permissions={...manifest.permissions};
62
+ let desktop=[...(permissions.desktop||[])];
63
+ desktop=desktop.filter(scope=>!usage.unused.some(item=>item.family==='desktop'&&item.scope===scope));
64
+ for(const item of usage.undeclared)if(item.family==='desktop'&&!desktop.includes(item.scope))desktop.push(item.scope);
65
+ if(desktop.includes('folder.write')&&!desktop.includes('folder.read'))desktop.push('folder.read');
66
+ if(desktop.length||permissions.desktop)permissions.desktop=DESKTOP_PERMISSIONS.filter(scope=>desktop.includes(scope));
67
+ let network=[...(permissions.network||[])];
68
+ network=network.filter(entry=>!usage.unused.some(item=>item.family==='network'&&item.scope===(typeof entry==='string'?entry:entry.origin)));
69
+ for(const item of usage.undeclared)if(item.family==='network')network.push(item.scope);
70
+ if(network.length||permissions.network)permissions.network=network;
71
+ return {...manifest,permissions};
72
+ }
@@ -83,7 +83,7 @@ export interface StillMadeModule {
83
83
  media: {transform(request: {operation: string; [key: string]: unknown}): Promise<StillMadeMedia | {sampleRate: number; duration: number; samples: Float32Array}>};
84
84
  actions: {run(action: {operation: string; request: Record<string, unknown>}): Promise<unknown>};
85
85
  hosted: {run(operation: string, request: Record<string, unknown>): Promise<unknown>};
86
- net: {fetch(url: string | URL, init?: {method?: string; headers?: Record<string, string> | Array<[string, string]>; body?: ArrayBuffer | ArrayBufferView | Blob | string}): Promise<Response>};
86
+ net: {fetch(url: string | URL, init?: {method?: string; headers?: Record<string, string> | Array<[string, string]>; body?: ArrayBuffer | ArrayBufferView | Blob | string}): Promise<Response>; connect(url: string | URL, options?: {protocols?: string | string[]}): Promise<{url: string; protocol: string; readonly readyState: number; onmessage: ((event: {data: string | ArrayBuffer}) => void) | null; onclose: ((event: {code: number; reason: string; wasClean: boolean}) => void) | null; send(data: string | ArrayBuffer | ArrayBufferView | Blob): Promise<unknown>; close(code?: number, reason?: string): Promise<{code: number; reason: string; wasClean: boolean}>; closed: Promise<{code: number; reason: string; wasClean: boolean}>; addEventListener(type: 'message' | 'close', listener: (event: Event & Record<string, unknown>) => void): void; removeEventListener(type: 'message' | 'close', listener: (event: Event & Record<string, unknown>) => void): void; [Symbol.asyncIterator](): AsyncIterator<string | ArrayBuffer>}>};
87
87
  connections: {call(appId: string, operation: string, input?: Record<string, unknown>): Promise<unknown>};
88
88
  storage: {info(): Promise<Record<string, unknown>>; list(prefix?: string, options?: {after?: string}): Promise<{items: Array<{key: string; kind: 'value' | 'file'; bytes: number; mediaType: string; updatedAt: string}>; next?: string}>; get<T = unknown>(key: string): Promise<T | undefined>; set(key: string, value: unknown): Promise<unknown>; remove(key: string): Promise<{key: string; removed: boolean}>; putFile(key: string, data: ArrayBuffer | ArrayBufferView | Blob | string, options?: {mediaType?: string}): Promise<unknown>; getFile(key: string): Promise<{key: string; mediaType: string; bytes: number; data: ArrayBuffer} | null>};
89
89
  project: {read(fields?: string[]): Promise<Record<string, unknown>>};
@@ -21,16 +21,19 @@ export function createSchemaFields(lockedInputKeys=[]) {
21
21
  const kept=second&&first.length+second.length<280?first+' '+second:first;
22
22
  return kept.length>280?kept.slice(0,279).trimEnd()+'…':kept;
23
23
  }
24
- function controls(schema,bindings,values,{canEdit=true,busy=false}={}) {
24
+ function controls(schema,bindings,values,{canEdit=true,busy=false,more=[],hide=[]}={}) {
25
25
  inert(schema);inert(bindings);inert(values);let count=0;const tree=[],actions=[];
26
26
  const action=(kind,path,s)=>{const id=`field-${actions.length}`;actions.push({id,kind,path,s});return id;};
27
+ // A single value is a Block kit field (label row and control); a group or list stays a plain section.
28
+ const fieldSection=(s,children)=>({tag:'section',...(s.type==='object'&&s.properties||s.type==='array'?{}:{className:'sm-field'}),children});
27
29
  function fields(s,path,value,label,required,depth) {
28
30
  if(++count>150||depth>5)return [{tag:'p',text:'This task needs a custom interface for its remaining fields.'}];
29
31
  // A description sits behind an (i) beside its label, one or two sentences:
30
32
  // connected apps describe fields at length for AI models, not people.
31
- const name=String(label).slice(0,240)+(required?' *':' (optional)'),about=hint(s.description);
33
+ // A required field carries a star; most app fields are optional, so they say nothing (October 24, 2026).
34
+ const name=String(label).slice(0,240)+(required?' *':''),about=hint(s.description);
32
35
  const result=[about?{tag:'div',className:'sm-label',children:[{tag:'label',text:name},{tag:'details',className:'sm-hint',children:[{tag:'summary',label:'About '+String(label).slice(0,200)},{tag:'p',text:about}]}]}:{tag:'label',text:name}],disabled=busy||!canEdit||locked.has(path[0]);
33
- if(s.type==='object'&&s.properties){for(const key of Object.keys(s.properties))result.push({tag:'section',children:fields(s.properties[key],path.concat(key),value?.[key],s.properties[key].title||key,(s.required||[]).includes(key),depth+1)});}
36
+ if(s.type==='object'&&s.properties){for(const key of Object.keys(s.properties))result.push(fieldSection(s.properties[key],fields(s.properties[key],path.concat(key),value?.[key],s.properties[key].title||key,(s.required||[]).includes(key),depth+1)));}
34
37
  else if(s.type==='array'){
35
38
  const rows=Array.isArray(value)?value:[];
36
39
  rows.slice(0,20).forEach((item,index)=>result.push({tag:'section',className:'connection-array',children:fields(s.items||{},path.concat(index),item,'Item '+(index+1),true,depth+1).concat([{tag:'button',text:'Remove item',action:action('remove',path.concat(index),s),disabled}])}));
@@ -43,7 +46,11 @@ export function createSchemaFields(lockedInputKeys=[]) {
43
46
  else result.push({tag:'p',text:'Select a compatible upstream value for this field.'});
44
47
  return result;
45
48
  }
46
- for(const [port,field] of Object.entries(bindings)){const s=schema.properties?.[field];if(!s)throw Error('Choose a schema-declared input.');tree.push({tag:'section',children:fields(s,[port],values[port],s.title||field,(schema.required||[]).includes(field),0)});}
49
+ // Fields a Block names in `more` fold into a closed More options; fields in `hide` (an app's paging or
50
+ // debugging settings) are not shown and keep their defaults. A required field is always shown.
51
+ const folded=[];
52
+ for(const [port,field] of Object.entries(bindings)){const s=schema.properties?.[field];if(!s)throw Error('Choose a schema-declared input.');const required=(schema.required||[]).includes(field);if(!required&&Array.isArray(hide)&&hide.includes(field))continue;const section=fieldSection(s,fields(s,[port],values[port],s.title||field,required,0));(Array.isArray(more)&&more.includes(field)&&!required?folded:tree).push(section);}
53
+ if(folded.length)tree.push({tag:'details',className:'sm-more',children:[{tag:'summary',text:`More options · ${folded.length}`},...folded]});
47
54
  return {tree,actions};
48
55
  }
49
56
  function change(values,actions,event) {
@@ -11,6 +11,7 @@ import {validateComfyWorkflow,validateComfyExpectations,testComfyOutputs} from '
11
11
  import {CAPABILITY_LIMITS,validateCapability,prepareCapabilityInvocation,validateCapabilityExpectations,testCapabilityOutputs} from './capabilities.js';
12
12
  import {validateNativeApplicationPackage,NATIVE_APPLICATION_RUNTIME_REQUIREMENT} from './native-application-package.js';
13
13
  import {validateModuleDescriptor,MODULE_LIMITS} from './module-runtime.js';
14
+ import {validateMocks} from './mocks.js';
14
15
  export function validatePackage(pkg) {
15
16
  safeDocument(pkg);
16
17
  if(pkg?.manifest?.runtime==='native-application')return validateNativeApplicationPackage(pkg);
@@ -49,9 +50,12 @@ export function validatePackage(pkg) {
49
50
  validateValues(pkg.manifest.inputs,fixture.input);
50
51
  validateComfyExpectations(pkg.manifest,fixture.expectations);
51
52
  }
53
+ // A module fixture may record what the host answers (mocks, the tests/mocks.json shape): the
54
+ // hosted calls, actions and transforms it makes then cost nothing and give the same result each run.
52
55
  if(pkg.manifest.runtime === 'module')for(const fixture of pkg.tests){
53
- assert(fixture&&typeof fixture.name==='string'&&fixture.name.trim().length>0&&fixture.name.length<=200&&Object.keys(fixture).every(key=>['name','input','expected'].includes(key)),'TEST','Module fixtures need a name, input and the exact expected outputs');
56
+ assert(fixture&&typeof fixture.name==='string'&&fixture.name.trim().length>0&&fixture.name.length<=200&&Object.keys(fixture).every(key=>['name','input','expected','mocks'].includes(key)),'TEST','Module fixtures need a name, input and the exact expected outputs, and may record mocks');
54
57
  validateValues(pkg.manifest.inputs,fixture.input);validateValues(pkg.manifest.outputs,fixture.expected,'output');
58
+ if(fixture.mocks!==undefined)validateMocks(fixture.mocks);
55
59
  }
56
60
  if(pkg.manifest.runtime === 'capability'){
57
61
  assert(pkg.tests.length<=CAPABILITY_LIMITS.fixtures,'TESTS_REQUIRED','Include 1–3 explicitly approved remote fixtures');
@@ -69,7 +73,10 @@ export function validatePackage(pkg) {
69
73
  validatePortableMetadata(pkg);
70
74
  return true;
71
75
  }
72
- export async function testPackage(pkg, { timeoutMs = 10000, wasmLocation, comfyui, capability, desktop, module } = {}) {
76
+ // moduleFor(fixture): the module executor for one fixture (one answering its recorded mocks), in place of `module`.
77
+ // onTest hears each fixture start and end ({phase, index, total, name, passed}), so a review can show the test
78
+ // that is running; the returned results stay the only verdict.
79
+ export async function testPackage(pkg, { timeoutMs = 10000, wasmLocation, comfyui, capability, desktop, module, moduleFor, onTest } = {}) {
73
80
  validatePackage(pkg);
74
81
  assert(pkg.manifest.runtime!=='native-application','NATIVE_APPLICATION_RUNTIME_UNAVAILABLE',NATIVE_APPLICATION_RUNTIME_REQUIREMENT);
75
82
  const results = [];
@@ -77,24 +84,30 @@ export async function testPackage(pkg, { timeoutMs = 10000, wasmLocation, comfyu
77
84
  const ceiling = pkg.manifest.runtime === 'module' ? MODULE_LIMITS.wallClockMs : 10000;
78
85
  const budget = Math.max(0, Math.min(ceiling, Number.isFinite(timeoutMs) ? timeoutMs : 10000));
79
86
  const deadline = Date.now() + budget;
80
- for (const test of pkg.tests) {
87
+ const tell = event => { try { onTest?.(event); } catch {} }, total = pkg.tests.length;
88
+ for (const [index, test] of pkg.tests.entries()) {
89
+ tell({ phase: 'start', index, total, name: test?.name || 'Unnamed' });
81
90
  const controller = new AbortController();
82
91
  const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
83
92
  try {
84
93
  assert(Date.now() < deadline, 'TIMEOUT', 'Fixture suite exceeded its time budget');
85
94
  assert(typeof test.name === 'string' && test.name.length > 0, 'TEST', 'A test needs a name');
86
- const { outputs, metadata, state } = await runPackageAsync(pkg, test.input, { signal: controller.signal, wasmLocation, comfyui, capability, desktop, module,...(pkg.manifest.state?{state:test.state??initialBlockState(pkg.manifest)}:{}) });
95
+ const { outputs, metadata, state, logs } = await runPackageAsync(pkg, test.input, { signal: controller.signal, wasmLocation, comfyui, capability, desktop, module:typeof moduleFor==='function'?moduleFor(test):module,...(pkg.manifest.state?{state:test.state??initialBlockState(pkg.manifest)}:{}) });
87
96
  assert(Date.now() < deadline, 'TIMEOUT', 'Fixture suite exceeded its time budget');
88
97
  if(pkg.manifest.runtime === 'comfyui')testComfyOutputs(pkg.manifest,outputs,test.expectations,{metadata});
89
98
  else if(pkg.manifest.runtime === 'capability')testCapabilityOutputs(pkg.manifest,outputs,test.expectations);
90
99
  else {
91
100
  const actual=stableStringify(outputs),expected=stableStringify(test.expected);
92
- assert(actual===expected,'BEHAVIOR',`Output differs from expected fixture. Expected ${expected.slice(0,1200)}; received ${actual.slice(0,1200)}`);
101
+ // Each output that differs, with both values, so a long output (a transcript) never hides the
102
+ // one that differs; a module's failed host calls (in its log) say why (October 22, 2026).
103
+ const differing=[...new Set([...Object.keys(test.expected||{}),...Object.keys(outputs||{})])].filter(key=>stableStringify(outputs?.[key])!==stableStringify(test.expected?.[key]));
104
+ const failedCalls=(logs||[]).filter(line=>/ failed: /.test(line)).slice(0,3);
105
+ assert(actual===expected,'BEHAVIOR',`Output differs from expected fixture${differing.length?` in ${differing.join(', ')}`:''}. ${differing.length?differing.slice(0,4).map(key=>`${key}: expected ${String(stableStringify(test.expected?.[key])).slice(0,600)}; received ${String(stableStringify(outputs?.[key])).slice(0,600)}`).join('. '):`Expected ${expected.slice(0,1200)}; received ${actual.slice(0,1200)}`}${failedCalls.length?`. Host calls that failed: ${failedCalls.join('; ')}`:''}`);
93
106
  }
94
107
  if(pkg.manifest.state)assert(stableStringify(state)===stableStringify(test.expectedState),'STATE_BEHAVIOR','State differs from expected fixture');
95
108
  results.push({ name: test.name, passed: true });
96
109
  } catch (e) { results.push({ name: test?.name || 'Unnamed', passed: false, code: controller.signal.aborted ? 'TIMEOUT' : e.code || 'TEST', message: controller.signal.aborted ? 'Fixture suite exceeded its time budget' : e.message }); }
97
- finally { clearTimeout(timer); }
110
+ finally { clearTimeout(timer); const result = results.at(-1); tell({ phase: 'end', index, total, name: result.name, passed: result.passed, ...(result.passed ? {} : { code: result.code, message: result.message }) }); }
98
111
  }
99
- return { digest: await digest(pkg), sdkVersion: '0.1.0', passed: results.every(r => r.passed), results,...(requiresDesktopExecution(pkg)?{execution:typeof desktop==='function'?'desktop-host-executor':'unavailable',liveVerified:false,reviewRequired:true}:pkg.manifest.runtime==='module'?{execution:typeof module==='function'?'module-executor':'unavailable',reviewRequired:typeof module!=='function'}:pkg.manifest.runtime==='capability'?{execution:typeof capability==='function'?'host-executor':'unavailable',liveVerified:false,reviewRequired:true}:{}) };
112
+ return { digest: await digest(pkg), sdkVersion: '0.1.0', passed: results.every(r => r.passed), results,...(requiresDesktopExecution(pkg)?{execution:typeof desktop==='function'?'desktop-host-executor':'unavailable',liveVerified:false,reviewRequired:true}:pkg.manifest.runtime==='module'?{execution:typeof module==='function'||typeof moduleFor==='function'?'module-executor':'unavailable',reviewRequired:typeof module!=='function'&&typeof moduleFor!=='function'}:pkg.manifest.runtime==='capability'?{execution:typeof capability==='function'?'host-executor':'unavailable',liveVerified:false,reviewRequired:true}:{}) };
100
113
  }
@@ -30,6 +30,7 @@ export const VIEW_BRIDGE=Object.freeze([
30
30
  {name:'previewMedia',group:'Media',signature:'previewMedia(value: StillMadeVideo | StillMadeAudio): Promise<{kind: "video" | "audio"; url: string}>',summary:'A playable blob: URL for audio or video the view may play. Call releaseMedia when done.',where:all},
31
31
  {name:'releaseMedia',group:'Media',signature:'releaseMedia(url: string): void',summary:'Frees a URL previewMedia returned.',where:all},
32
32
  {name:'asset',group:'Media',signature:'asset(path: string): string',summary:'A blob: URL for one of the view\'s own asset files, for <img>, new FontFace() or <video>.',where:frame},
33
+ {name:'live',group:'Media',signature:'readonly live: {start(options: {camera?: boolean; microphone?: boolean; fps?: number; width?: number; sampleRate?: number}): Promise<{live: true; camera: boolean; microphone: boolean; fps: number; width: number; height: number; sampleRate: number}>; stop(): Promise<{live: false}>; status(): Promise<{live: boolean; [key: string]: unknown}>; onFrame(callback: (frame: ImageBitmap, info: {index: number; timestamp: number; width: number; height: number}) => void): () => void; onAudio(callback: (samples: Float32Array, info: {index: number; timestamp: number; sampleRate: number}) => void): () => void; onEnd(callback: (event: {reason: string}) => void): () => void}',summary:'The camera, the microphone or both as they happen (declare camera.capture and microphone.capture). Nothing reaches the view until the person presses Start in StillMade, which then shows that the device is on and a Turn off button. Frames arrive as ImageBitmaps, closed when your callback returns, so draw or copy them there; a slow view gets fewer frames, never a backlog. Audio arrives as mono Float32Array chunks. Nothing is saved. In browsers and StillMade Desktop.',where:all},
33
34
  {name:'assetBytes',group:'Media',signature:'assetBytes(path: string): ArrayBuffer',summary:'A copy of one of the view\'s own asset files.',where:frame},
34
35
  // Generated interface
35
36
  {name:'render',group:'Generated interface',signature:'render(targetId: string, tree: Array<Record<string, unknown>>): void',summary:'Renders a tree of StillMade controls into an element, for standard views that build their interface from data.',where:all},
@@ -40,7 +41,7 @@ export const VIEW_BRIDGE=Object.freeze([
40
41
  {name:'onSharedWorkRefresh',group:'Connected work',signature:'onSharedWorkRefresh(callback: () => void): () => void',summary:'Called when shared work changes elsewhere; read it again.',where:saved},
41
42
  {name:'previewSharedWorkMedia',group:'Connected work',signature:'previewSharedWorkMedia(contributionId: string): Promise<{url: string; kind: "image" | "video" | "audio"}>',summary:'A display copy of media attached to a shared work contribution.',where:saved},
42
43
  {name:'releaseSharedWorkMedia',group:'Connected work',signature:'releaseSharedWorkMedia(url: string): void',summary:'Frees a URL previewSharedWorkMedia returned.',where:saved},
43
- {name:'desktop',group:'Connected work',signature:'desktop(operation: string, args?: Record<string, unknown>): Promise<Record<string, unknown>>',summary:'Runs a declared desktop permission after the person grants it. Recording (record.start, record.stop, record.cancel, record.status) and cutting a recording (record.cut) work in browsers and in StillMade Desktop; clipboard, media.pick, notifications, keep-awake and native tools need StillMade Desktop.',where:`${all} for recording; ${desktop} for the rest`},
44
+ {name:'desktop',group:'Connected work',signature:'desktop(operation: string, args?: Record<string, unknown>): Promise<Record<string, unknown>>',summary:'Runs a declared desktop permission after the person grants it. Recording (record.start, record.stop, record.cancel, record.status), cutting a recording (record.cut) and folders the person picks (folder.*; Chrome and Edge in a browser) work in browsers and in StillMade Desktop; clipboard, media.pick, notifications, keep-awake and native tools need StillMade Desktop. Picking or pressing Start is the consent; StillMade asks once only about the clipboard, the cursor and desktop programs.',where:`${all} for recording and folders; ${desktop} for the rest`},
44
45
  ]);
45
46
  export const VIEW_BRIDGE_MEMBERS=Object.freeze(VIEW_BRIDGE.map(member=>member.name));
46
47
 
@@ -1,18 +0,0 @@
1
- import {taskSelectionView} from './task-selection.js';
2
- import {connectedAppView as originalView} from '../1.0.0/view.js';
3
- import {metaTestView,metaTestContract} from '../../../meta-ads/versions/1.2.0/workspace.js';
4
- import {gmailWorkspaceView} from '../../../gmail/versions/1.0.0/workspace.js';
5
- // Result tables from the shared preview: scroll sideways instead of squeezing.
6
- const RESULT_CSS='.connection-table{overflow-x:auto;display:grid;gap:4px}#connection-result table{border-collapse:collapse;width:100%;font-size:14px}#connection-result th,#connection-result td{text-align:left;vertical-align:top;padding:6px 8px;border-bottom:1px solid var(--border);overflow-wrap:break-word}#connection-result thead th{font-weight:600;white-space:nowrap}.connection-fields th{width:35%;font-weight:500}#connection-result details{border-top:1px solid var(--border);padding:6px 0}#connection-result summary{cursor:pointer;font-weight:500}';
7
- export function connectedAppWorkspace(appId,schema,bindings){
8
- if(appId==='app:meta-ads')return {sharedWork:metaTestContract,view:metaTestView()};
9
- if(appId==='app:gmail')return gmailWorkspaceView(schema,bindings);
10
- return {};
11
- }
12
- export function connectedAppView(schema,bindings,{appId}={}){
13
- const workspace=connectedAppWorkspace(appId,schema,bindings),view=originalView(workspace.schema||schema,bindings);
14
- view.javascript=view.javascript.replace('canEdit=true','canEdit=false').replace("[{tag:'h3',text:'Result'},{tag:'output',className:'connection-result',text:JSON.stringify(shared.outputs.result,null,2).slice(0,10000)}]",'StillMade.schemaFields.preview(shared.outputs.result)');
15
- if(workspace.view){view.html=workspace.view.html+view.html;view.css=workspace.view.css+'\n'+view.css;view.javascript+='\n'+workspace.view.javascript;}
16
- if(workspace.heading){view.html='<header><h2>'+workspace.heading+'</h2><p>'+workspace.description+'</p></header>'+view.html;view.html=view.html.replace('Review and run task',workspace.runLabel);}
17
- const tasks=taskSelectionView();view.javascript=view.javascript.replace('if(busy||!canEdit)return;try{draft=StillMade.schemaFields.change','if(busy||!canEdit||!actions.some(item=>item.id===event.action))return;try{draft=StillMade.schemaFields.change');return {html:tasks.html+view.html,css:tasks.css+'\n'+view.css+'\n'+RESULT_CSS,javascript:view.javascript+'\n'+tasks.javascript};
18
- }
@@ -1,6 +0,0 @@
1
- // Draft controls remain in the existing project-local view state. Reusing a
2
- // mailbox grants neither cross-project draft access nor permission to send.
3
- export function gmailWorkspaceView(schema,bindings){
4
- const fieldNames=Object.keys(schema.properties||{}),known=['to','recipient','recipients','subject','body','text_body','html_body','thread_id','threadId'],ordered=[...known.filter(key=>fieldNames.includes(key)),...fieldNames.filter(key=>!known.includes(key))],copy={...schema,properties:Object.fromEntries(ordered.map(key=>[key,{...schema.properties[key],title:({to:'To',recipient:'To',recipients:'To',subject:'Subject',body:'Message',text_body:'Message',html_body:'HTML message',thread_id:'Selected thread',threadId:'Selected thread'})[key]||schema.properties[key].title||key}]))};
5
- return {schema:copy,heading:'Email workspace',description:'Keep this project’s selected thread and draft here. Reusing your mailbox in another project keeps its draft separate. Reading or attaching a thread never authorizes sending.',runLabel:'Review email task'};
6
- }