diff --git a/app/patternfront.html b/app/patternfront.html index 96d45ba..09a86c4 100644 --- a/app/patternfront.html +++ b/app/patternfront.html @@ -550,6 +550,23 @@ font:inherit;padding:0.4rem"> +
+
Describe it instead
+ Optional. Your key is stored in this browser and is sent only to + Anthropic, never to this project. The program it writes lands in the box above, + where you can read and edit it before rendering. +
+ +
+ + @@ -1682,6 +1699,139 @@ $('dslX').onclick=()=>closeOv('ovDsl'); $('ovDsl').addEventListener('pointerdown',e=>{if(e.target===$('ovDsl'))closeOv('ovDsl');}); $('dslSrc').oninput=dslPreviewStat; +/* ═══════ writing a program with a model ═══════ + The DSL is the contract. A model writes a program; the app validates and renders + it exactly as if it had been typed by hand, so nothing reaches the canvas that + the panel could not have produced on its own — and the user can read the program + before rendering it. + + Structured outputs do the heavy lifting: output_config.format constrains the + reply to the schema below, so there is no prose to parse out and a malformed + program is a validation error rather than a parse failure. Note what the schema + CANNOT say — numeric bounds, string lengths and recursion are not supported — + so every range still goes through dslValidateShape's clamping, which is where + it belonged anyway. + + Raw fetch rather than the Anthropic SDK: the renderer is one HTML file with no + bundler and no node_modules, which is the documented raw-HTTP case. */ +const AI_URL='https://api.anthropic.com/v1/messages'; +const AI_MODEL='claude-opus-5'; +const AI_KEY='pf.aikey'; +/* The schema is built from the DSL's own tables, so it cannot drift from the + renderer: a primitive or parameter added to DSL_SHAPES/DSL_RANGES appears here + automatically. Every parameter is optional — the model supplies what a shape + needs — but `type` is required, since without it there is no shape at all. */ +function aiSchema(){ + const num={type:'number'},int={type:'integer'},bool={type:'boolean'},str={type:'string'}; + const shapeProps={type:{type:'string',enum:Object.keys(DSL_SHAPES)}, + axis:{type:'string',enum:['h','v']}, + orientation:{type:'string',enum:['up','down']}, + shape:{type:'string',enum:['circle','square','diamond']}, + font:{type:'string',enum:['5x7','3x5']}, + glyphs:str,seed:int,blue:bool,angle:int}; + for(const k of Object.keys(DSL_RANGES)) + shapeProps[k]=(k==='level'||k==='density')?num:int; + return {type:'object',additionalProperties:false, + required:['canvas','layers','post'], + properties:{ + canvas:{type:'object',additionalProperties:false, + required:['width','height','scale','autoSize'], + properties:{width:int,height:int,scale:int,autoSize:bool}}, + layers:{type:'array',items:{type:'object',additionalProperties:false, + required:['op','shape'], + properties:{op:{type:'string',enum:['set','union','intersect','xor','subtract']}, + shape:{type:'object',additionalProperties:false,required:['type'], + properties:shapeProps}}}}, + post:{type:'object',additionalProperties:false, + required:['mirrorX','mirrorY','rotate90','invert'], + properties:{mirrorX:bool,mirrorY:bool,rotate90:int,invert:bool}}}}; +} +/* Built from the same tables, for the same reason. */ +function aiSystem(){ + const ranges=Object.entries(DSL_RANGES) + .map(([k,[lo,hi]])=>k+' '+lo+'-'+hi).join(', '); + return [ + 'You write programs for a 1-bit tiling pattern renderer used to make OpenFront', + 'territory patterns. Answer only with a program matching the schema.', + '', + 'Primitives: '+Object.keys(DSL_SHAPES).join(', ')+'.', + 'Parameter ranges: '+ranges+'.', + '', + 'Layers compose in order. The first layer is always "set"; later layers use', + 'union, intersect, xor or subtract. Give each shape only the parameters that', + 'primitive takes.', + '', + 'Leave canvas.autoSize true unless the user asks for an exact size: the canvas', + 'is then sized to a common multiple of every layer\'s period, which is what', + 'makes the result tile seamlessly. Canvas limits are '+DSL_MIN_W+'-'+DSL_MAX_W+ + ' wide and '+DSL_MIN_H+'-'+DSL_MAX_H+' tall; scale 0-7 magnifies by 2^scale.', + 'A good default canvas is 32x32 at scale 1.', + ].join('\n'); +} +const aiKey=()=>{try{return store.get(AI_KEY)||'';}catch{return '';}}; +function aiSync(){ + const has=!!aiKey(); + $('aiAsk').hidden=!has; + $('aiForget').disabled=!has; +} +function aiSay(msg,ok){ + $('aiStat').innerHTML=''+msg+''; +} +$('aiSave').onclick=()=>{ + const v=$('aiKey').value.trim(); + if(!v){aiSay('Paste a key first',false);return;} + store.set(AI_KEY,v);$('aiKey').value='';aiSync(); + aiSay('Key saved in this browser',true);}; +$('aiForget').onclick=()=>{ + store.set(AI_KEY,'');$('aiKey').value='';aiSync();aiSay('Key forgotten',true);}; +/* Separated from the button so it can be tested against a stubbed fetch — no test + in this repo ever needs a key or a network. */ +async function aiWrite(desc,fetchImpl){ + const key=aiKey(); + if(!key) throw new Error('No API key set'); + if(!desc.trim()) throw new Error('Describe the pattern first'); + const res=await (fetchImpl||fetch)(AI_URL,{ + method:'POST', + headers:{'content-type':'application/json','x-api-key':key, + 'anthropic-version':'2023-06-01', + // Required for a browser-origin request; the renderer is one. + 'anthropic-dangerous-direct-browser-access':'true'}, + body:JSON.stringify({ + model:AI_MODEL, + // Thinking is on by default on this model and max_tokens caps thinking plus + // output together, so this is sized for both. Low effort: writing a small + // program is not the kind of work deeper reasoning improves. + max_tokens:4000, + output_config:{effort:'low',format:{type:'json_schema',schema:aiSchema()}}, + system:aiSystem(), + messages:[{role:'user',content:desc}]}), + }); + if(!res.ok){ + let detail=''; + try{const e=await res.json();detail=e&&e.error&&e.error.message||'';}catch{} + throw new Error('The model service said '+res.status+(detail?': '+detail:''));} + const data=await res.json(); + // Safety classifiers can decline; that is a 200 with no usable content, so it + // has to be checked before reading the reply. + if(data.stop_reason==='refusal') throw new Error('The model declined this request'); + const text=(data.content||[]).filter(b=>b.type==='text').map(b=>b.text).join(''); + if(!text) throw new Error('The model returned nothing to render'); + let prog; + try{ prog=JSON.parse(text); } + catch{ throw new Error('The model did not return a program'); } + dslValidate(prog); // throws with a readable reason + return prog; +} +$('aiGo').onclick=async()=>{ + const btn=$('aiGo');btn.disabled=true;aiSay('Writing…',true); + try{ + const prog=await aiWrite($('aiWhat').value); + dslShow(prog); + aiSay('Written. Read it above, then Replace canvas.',true); + }catch(e){ aiSay(e.message,false); } + finally{ btn.disabled=false; }}; +aiSync(); + $('dslGo').onclick=()=>{ let r; try{ r=dslRender(JSON.parse($('dslSrc').value)); } diff --git a/electron/main.js b/electron/main.js index 7a75806..3117f18 100644 --- a/electron/main.js +++ b/electron/main.js @@ -76,7 +76,12 @@ async function serve(request) { 'content-security-policy': "default-src 'none'; img-src 'self' data: blob:; " + "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; " + - "font-src 'self'; connect-src 'self' blob: data:; form-action 'none'; " + + // api.anthropic.com is the one outbound origin the renderer may reach, and + // only when the user has set their own key. Widened to that host rather + // than to https:, so a compromised renderer still cannot phone anywhere + // else. Everything the app does without a key stays entirely local. + "font-src 'self'; connect-src 'self' blob: data: https://api.anthropic.com; " + + "form-action 'none'; " + "base-uri 'none'; frame-ancestors 'none'", }, }); diff --git a/tools/lib/extract.js b/tools/lib/extract.js index 825380e..c17045e 100644 --- a/tools/lib/extract.js +++ b/tools/lib/extract.js @@ -54,8 +54,11 @@ function matchBrace(text, from) { /** Source of `function (...) { ... }`, braces balanced. */ function grabFunction(js, name) { - const at = js.indexOf(`function ${name}(`); + let at = js.indexOf(`function ${name}(`); if (at < 0) throw new Error(`function ${name} not found in the editor`); + // Include a preceding `async`; without it the extracted copy throws + // "await is only valid in async functions" and reads as an app bug. + if (js.slice(Math.max(0, at - 6), at) === 'async ') at -= 6; return js.slice(at, matchBrace(js, js.indexOf('{', js.indexOf(')', at))) + 1); } diff --git a/tools/verify-behaviour.js b/tools/verify-behaviour.js index ca8edd8..7b6884c 100755 --- a/tools/verify-behaviour.js +++ b/tools/verify-behaviour.js @@ -60,8 +60,12 @@ function matchBrace(text, from, open = '{', close = '}') { } function grabFunction(name) { - const at = js.indexOf(`function ${name}(`); + let at = js.indexOf(`function ${name}(`); if (at < 0) throw new Error(`function ${name} not found`); + // Take the `async` with it. Slicing from `function` silently drops the keyword + // and the extracted copy then throws "await is only valid in async functions" + // — an error about the test harness wearing the costume of an app bug. + if (js.slice(Math.max(0, at - 6), at) === 'async ') at -= 6; const brace = js.indexOf('{', js.indexOf(')', at)); return js.slice(at, matchBrace(js, brace) + 1); } @@ -540,6 +544,126 @@ function duotoneTests() { JSON.stringify(richCanvas)); } +/* ── 6c. the model writes a program, and every way that can go wrong ───── */ +// +// The AI path is the only part of the app that touches the network, so it is the +// only part that can fail in ways the user did not cause. None of these tests +// needs a key or a network: aiWrite() takes the fetch implementation as an +// argument precisely so the model can be stubbed. +// +// What matters is not that the happy path works — it is that every failure lands +// as a message rather than as a broken editor, and that a program from a model +// goes through exactly the same validation as one typed by hand. + +async function aiTests() { + console.log('\n=== writing a program with a model ==='); + + const GOOD = { + canvas: { width: 32, height: 32, scale: 1, autoSize: true }, + layers: [{ op: 'set', shape: { type: 'diagonal', dx: 1, dy: 1, period: 8, thickness: 3 } }], + post: { mirrorX: false, mirrorY: false, rotate90: 0, invert: false }, + }; + const reply = (body, ok = true, status = 200) => async () => ({ + ok, status, + json: async () => body, + }); + const asText = (obj) => ({ stop_reason: 'end_turn', content: [{ type: 'text', text: JSON.stringify(obj) }] }); + + const ctx = sandbox({ + store: { get: (k) => (k === 'pf.aikey' ? 'sk-test' : ''), set() {} }, + $: () => ({ hidden: false, disabled: false, value: '', innerHTML: '' }), + }); + run(ctx, [ + grabConst('DSL_MIN_W'), grabConst('DSL_SHAPES'), grabConst('DSL_CANVAS_LOCKED'), + grabConst('DSL_RANGES'), grabConst('floorMod'), grabFunction('pyRound'), + grabFunction('gcd'), grabFunction('lcm'), grabFunction('hash2'), + grabConst('F57'), grabConst('F35'), + grabFunction('dslValidateShape'), grabFunction('dslValidate'), + grabConst('AI_URL'), grabConst('AI_MODEL'), grabConst('AI_KEY'), + grabFunction('aiSchema'), grabFunction('aiSystem'), + grabConst('aiKey'), grabFunction('aiWrite'), + ].join('\n')); + const aiWrite = ctx.aiWrite; + const failed = async (fetchImpl, desc = 'stripes') => { + try { await aiWrite(desc, fetchImpl); return null; } + catch (e) { return e.message; } + }; + + // The request itself: a wrong model or a rejected parameter is a 400 nobody + // would guess from the UI, so the shape is pinned here rather than discovered. + let sent = null; + await aiWrite('diagonal stripes', async (url, init) => { + sent = { url, body: JSON.parse(init.body), headers: init.headers }; + return { ok: true, status: 200, json: async () => asText(GOOD) }; + }); + chk('it posts to the messages endpoint', sent.url === 'https://api.anthropic.com/v1/messages', sent.url); + chk('it asks for the current model', sent.body.model === 'claude-opus-5', sent.body.model); + chk('it sends no rejected sampling parameters', + !('temperature' in sent.body) && !('top_p' in sent.body) && !('top_k' in sent.body)); + chk('it sends no removed thinking budget', + !sent.body.thinking || !('budget_tokens' in sent.body.thinking)); + chk('it leaves room for thinking as well as output', sent.body.max_tokens >= 2000, + String(sent.body.max_tokens)); + chk('it constrains the reply to the schema', + sent.body.output_config.format.type === 'json_schema'); + chk('the browser-origin header is set', + sent.headers['anthropic-dangerous-direct-browser-access'] === 'true'); + chk('the key travels in the header, never the body', + sent.headers['x-api-key'] === 'sk-test' && !JSON.stringify(sent.body).includes('sk-test')); + + // The schema is generated from the DSL's own tables, so it cannot drift. + const schema = ctx.aiSchema(); + const shapeEnum = schema.properties.layers.items.properties.shape.properties.type.enum; + // `const` bindings are not properties of the vm context, so ask for the value. + const primitiveCount = vm.runInContext('Object.keys(DSL_SHAPES).length', ctx); + chk('the schema offers every primitive the renderer has', + shapeEnum.length === primitiveCount, `${shapeEnum.length} primitives`); + chk('the prompt names the primitives too', + ctx.aiSystem().includes('diagonal') && ctx.aiSystem().includes('halftone')); + + // A well-formed reply is validated, not trusted. + const prog = await aiWrite('diagonal stripes', reply(asText(GOOD))); + chk('a well-formed program comes back validated', + prog.layers[0].shape.type === 'diagonal' && prog.layers[0].op === 'set'); + + // Every failure mode, in the order a user would meet them. + chk('an unknown shape is rejected, not rendered', + (await failed(reply(asText({ layers: [{ op: 'set', shape: { type: 'nope' } }] })))) === + 'unknown shape type "nope"'); + chk('a reply that is not a program says so', + (await failed(reply({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'sorry!' }] }))) === + 'The model did not return a program'); + chk('an empty reply says so', + (await failed(reply({ stop_reason: 'end_turn', content: [] }))) === + 'The model returned nothing to render'); + chk('a refusal is reported, not parsed', + (await failed(reply({ stop_reason: 'refusal', content: [] }))) === + 'The model declined this request'); + const http = await failed(reply({ error: { message: 'invalid x-api-key' } }, false, 401)); + chk('an HTTP error carries the status and the reason', + http.includes('401') && http.includes('invalid x-api-key'), http); + chk('an empty description is refused before any request', + (await failed(() => { throw new Error('should not have been called'); }, ' ')) === + 'Describe the pattern first'); + + // With no key the path refuses cleanly rather than sending an unauthenticated + // request — the panel hides the control, and this is the belt to that braces. + const noKey = sandbox({ store: { get: () => '', set() {} }, + $: () => ({ hidden: false, disabled: false, value: '', innerHTML: '' }) }); + run(noKey, [grabConst('AI_KEY'), grabConst('aiKey'), grabConst('AI_URL'), grabConst('AI_MODEL'), + grabConst('DSL_MIN_W'), grabConst('DSL_SHAPES'), grabConst('DSL_RANGES'), + grabFunction('aiSchema'), grabFunction('aiSystem'), + grabConst('DSL_CANVAS_LOCKED'), grabConst('floorMod'), grabFunction('pyRound'), + grabFunction('gcd'), grabFunction('lcm'), grabFunction('hash2'), + grabConst('F57'), grabConst('F35'), + grabFunction('dslValidateShape'), grabFunction('dslValidate'), + grabFunction('aiWrite')].join('\n')); + let noKeyMsg = null; + try { await noKey.aiWrite('stripes', () => { throw new Error('should not have been called'); }); } + catch (e) { noKeyMsg = e.message; } + chk('with no key it never reaches the network', noKeyMsg === 'No API key set', String(noKeyMsg)); +} + /* ── 7. grab: 8-connected flood fill over an ASCII scene ───────────────── */ function grabTests() { @@ -760,6 +884,7 @@ oversize.then(async () => { loadTests(); previewTests(); duotoneTests(); + await aiTests(); fitTests(); grabTests(); clearTests(); diff --git a/tools/verify-electron.py b/tools/verify-electron.py index 97a5b33..98be760 100755 --- a/tools/verify-electron.py +++ b/tools/verify-electron.py @@ -129,6 +129,21 @@ def main() -> int: # so nothing else in this suite would notice. chk('the mac bundle is ad-hoc signed at minimum', 'identity: "-"' in yml) + # The renderer may reach exactly one outbound origin, and only when the user + # has set their own key. `https:` or `*` here would let a compromised renderer + # phone anywhere; this check is what keeps the widening honest. + print("\n=== outbound network ===") + main = read("main.js") + connect = re.search(r"connect-src ([^\"]+)", main) + # The directive ends at the ';' that separates it from the next one. + hosts = connect.group(1).split(";")[0].split() if connect else [] + chk("connect-src names the AI provider and nothing wider", + "https://api.anthropic.com" in hosts + and not any(h in ("https:", "*", "http:") for h in hosts), + " ".join(hosts)) + chk("the key is never written into the packaged app", + "sk-ant" not in read_root("app/patternfront.html")) + print() if fails: print(f"*** {len(fails)} FAILURE(S) ***") diff --git a/tools/verify-stamps.py b/tools/verify-stamps.py index c869071..afd523c 100755 --- a/tools/verify-stamps.py +++ b/tools/verify-stamps.py @@ -60,7 +60,15 @@ def encode(w: int, h: int, scale: int, bits) -> str: def main() -> int: src = open(APP, encoding="utf-8").read() js = "\n".join(re.findall(r"", src, re.S)) - stamps = re.findall(r"\['([a-z]+)','([a-z0-9_-]+)','([A-Za-z0-9_-]+)'\]", js) + # Only the STAMPS table. Scanning the whole script for three-string arrays + # matched anything shaped like one — an enum of ['circle','square','diamond'] + # was read as a stamp and failed to decode as pattern data. + block = re.search(r"const STAMPS=\[\n(.*?)\n\];", js, re.S) + if not block: + print("no STAMPS table found") + return 1 + stamps = re.findall(r"\['([a-z]+)','([a-z0-9_-]+)','([A-Za-z0-9_-]+)'\]", + block.group(1)) if not stamps: print("no STAMPS table found") return 2