From 9d669770f82383fbb6a8c10a471014892b4e73d3 Mon Sep 17 00:00:00 2001 From: timbogdanov Date: Wed, 19 Aug 2026 08:23:44 -0500 Subject: [PATCH] Let a model write the program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last piece of the DSL work: describe a pattern, and a model writes a program the app then validates and renders exactly as if it had been typed by hand. The program lands in the panel's editor rather than on the canvas, so it can be read and corrected before anything is rendered. Structured outputs do the work. output_config.format constrains the reply to a JSON schema, so there is no prose to parse out of and a bad program is a validation error rather than a parse failure. The schema and the system prompt are both generated from DSL_SHAPES and DSL_RANGES, so neither can drift from the renderer: a primitive added to the DSL appears in both automatically. What the schema cannot express — numeric bounds, lengths, recursion — still goes through dslValidateShape's clamping, which is where it belonged. 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. The request uses claude-opus-5, where thinking is on by default and max_tokens caps thinking plus output together, so it is sized for both; effort is low, because writing a small program is not work that deeper reasoning improves. Sampling parameters and budget_tokens are rejected by that model and are absent. The key lives in localStorage, is sent only to Anthropic, and never appears in a request body or in the packaged app — both checked. With no key the control is absent rather than broken, and aiWrite() refuses before reaching the network. electron/main.js widens connect-src to api.anthropic.com and nothing else. Not https:, which would let a compromised renderer phone anywhere; verify-electron.py now fails on that, and I checked it fails on https: rather than trusting it. Eighteen behaviour checks cover the request shape and every failure mode — refusal, HTTP error, non-JSON reply, empty reply, unknown shape, empty description, absent key. The model is stubbed; no test needs a key or a network. Two harness bugs surfaced doing this. grabFunction() sliced from `function` and dropped a preceding `async`, so the extracted copy threw "await is only valid in async functions" — an error about the test harness wearing the costume of an app bug; both copies are fixed. And verify-stamps.py scanned the whole script for three-string arrays, so the schema's ['circle','square','diamond'] was read as a stamp and failed to decode as pattern data; it now reads the STAMPS table only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UCrKKTD2mwHx8Fm1EnJiQ3 --- app/patternfront.html | 150 ++++++++++++++++++++++++++++++++++++++ electron/main.js | 7 +- tools/lib/extract.js | 5 +- tools/verify-behaviour.js | 127 +++++++++++++++++++++++++++++++- tools/verify-electron.py | 15 ++++ tools/verify-stamps.py | 10 ++- 6 files changed, 310 insertions(+), 4 deletions(-) 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