Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c6065d9
feat(skill-runtime): add client-side skill-script execution substrate
anfibiacreativa Jun 27, 2026
22ea9ad
Update worklog
claude Jun 27, 2026
a4d7218
docs(skill-runtime): document orchestration round-trip and triggers
anfibiacreativa Jun 27, 2026
bc3507a
feat(chat): orchestrate client-executed skill-scripts via skill_run_s…
anfibiacreativa Jun 27, 2026
93cd340
feat(skills): author docx-to-markdown as a script-carrying skill
anfibiacreativa Jun 27, 2026
a229bd8
Update worklog
claude Jun 27, 2026
d65da35
Update worklog
claude Jun 27, 2026
aee5f72
test(skills): end-to-end skill-script round-trip with real worker exe…
anfibiacreativa Jun 27, 2026
63b1b99
refactor(chat): script-skills via scripts/ layout + host-injected dep…
anfibiacreativa Jun 29, 2026
ee257bf
Update worklog
claude Jun 29, 2026
d958048
fix(chat): resolve marketplace skills under the ew/ namespace
anfibiacreativa Jun 29, 2026
4900a60
Update worklog
claude Jun 29, 2026
6ce1261
feat(chat): resolve attachmentRef to bytes for script-skills (bytes s…
anfibiacreativa Jun 29, 2026
f822f55
Update worklog
claude Jun 29, 2026
0a43b87
test(skill-runtime): security suite — sandbox isolation, no creds/PII…
anfibiacreativa Jun 29, 2026
55ce84c
Update worklog
claude Jun 29, 2026
24c3788
fix(skill-runtime): resolve dependency URLs against module origin, no…
anfibiacreativa Jun 29, 2026
a017fe4
feat(chat): accept .docx in the attachment picker and drop filter
anfibiacreativa Jun 29, 2026
b4c7dcc
docs(skill-runtime): marketplace providers, security model, migration…
anfibiacreativa Jun 29, 2026
fa34bce
Update worklog
claude Jun 29, 2026
0996b93
fix(chat): render empty directive body as DocumentFragment instead of…
anfibiacreativa Jun 29, 2026
1b43691
Update worklog
claude Jun 29, 2026
ffaa268
Update worklog
claude Jun 30, 2026
f5c44ca
fix(skill-runtime): explicitly neuter localStorage/sessionStorage/doc…
anfibiacreativa Jun 30, 2026
92d15c8
merge: skill-script runtime + marketplace resolve + security suite + …
anfibiacreativa Jun 30, 2026
a2cc04f
merge: chat empty-directive render fix
anfibiacreativa Jun 30, 2026
32bfb48
merge: explicit worker storage/document neutering
anfibiacreativa Jun 30, 2026
eb96fd6
refactor(chat): move skill body to a test fixture; da-nx ships no bui…
anfibiacreativa Jul 1, 2026
6c2bd27
Update worklog
claude Jul 1, 2026
ad90e4e
fix(chat): resolve eslint errors in skill-script controller + tests
anfibiacreativa Jul 1, 2026
04cc90a
Update worklog
claude Jul 1, 2026
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
192 changes: 192 additions & 0 deletions WORKLOG.md

Large diffs are not rendered by default.

513 changes: 513 additions & 0 deletions docs/skill-script-runtime.md

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions nx2/blocks/chat/chat-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { loadIms } from '../../utils/ims.js';
import { AGENT_EVENT, ROLE, TOOL_NAME, TOOL_STATE } from './constants.js';
import { readStream } from './utils/stream.js';
import { loadMessages, saveMessages, resetSession } from './utils/persistence.js';
import { runSkillScript, isClientEligible } from '../../utils/skill-runtime/index.js';
import { resolveSkill } from './utils/skill-script-loader.js';

const SKILL_RUN_SCRIPT = 'skill_run_script';

function affectedFolders(toolName, input) {
const { org, repo } = input ?? {};
Expand Down Expand Up @@ -152,6 +156,34 @@ export default class ChatController {
this._update();
}

/**
* Record a skill_run_script tool result using the virtual-message pattern so
* _messagesForAgent() can replay it as an ASSISTANT tool-call + TOOL tool-result
* pair on the next POST. Updates the tool card to DONE or ERROR.
*/
_recordSkillResult(toolCallId, toolName, callInput, output, isError) {
const next = new Map(this._toolCards ?? []);
const state = isError ? TOOL_STATE.ERROR : TOOL_STATE.DONE;
next.set(toolCallId, { toolName, input: callInput, state, output });
this._messages = [
...this._messages,
{
role: ROLE.ASSISTANT,
virtual: true,
turnId: this._currentTurnId,
toolResult: { output },
content: [{
type: AGENT_EVENT.TOOL_CALL,
toolCallId,
toolName,
input: callInput,
}],
},
];
this._toolCards = next;
this._update();
}

stop() {
this._abortController?.abort();
this._done();
Expand Down Expand Up @@ -183,6 +215,66 @@ export default class ChatController {
if (type === AGENT_EVENT.TOOL_CALL) {
if (next.has(toolCallId)) return; // duplicate — ignore
next.set(toolCallId, { toolName, input, state: TOOL_STATE.RUNNING });

// Client-executed skill-script: resolve manifest client-side (trusted), run the
// script via the substrate, then record the result as a virtual message and
// continue streaming. NEVER trust capability hints from the agent's tool args.
if (toolName === SKILL_RUN_SCRIPT) {
this._toolCards = next;
this._update();
const { skillId, input: skillInput } = input ?? {};
(async () => {
const resolved = await resolveSkill(skillId);
if (resolved.error) {
this._recordSkillResult(toolCallId, toolName, input, { error: resolved.error }, true);
this._done();
return;
}
const { manifest, moduleUrl } = resolved;
// isClientEligible uses the trusted manifest — never the agent's args.
if (!isClientEligible(manifest.capabilities)) {
this._recordSkillResult(toolCallId, toolName, input, { error: 'requires server runtime' }, true);
this._done();
return;
}

// Resolve attachment reference client-side — bytes NEVER come from agent args.
// If attachmentRef is present, look it up in _pendingAttachments by id and
// inject bytesBase64, fileName, mediaType into the effective skill input.
let effectiveInput = skillInput ?? {};
const { attachmentRef } = effectiveInput;
if (attachmentRef !== undefined) {
const attachment = (this._pendingAttachments ?? []).find((a) => a.id === attachmentRef);
if (!attachment) {
this._recordSkillResult(toolCallId, toolName, input, { error: `attachment ${attachmentRef} not found` }, true);
this._done();
return;
}
const { dataBase64, fileName, mediaType } = attachment;
// Merge: non-attachment fields from skillInput co-exist; attachmentRef removed.
const { attachmentRef: _, ...rest } = effectiveInput;
effectiveInput = { bytesBase64: dataBase64, fileName, mediaType, ...rest };
}

const result = await runSkillScript({ manifest, moduleUrl, input: effectiveInput });
const resultOutput = result.error ? { error: result.error } : { output: result.json };
this._recordSkillResult(toolCallId, toolName, input, resultOutput, !!result.error);
// Re-engage the agent with the tool result so it can continue reasoning.
try {
await this._stream(this._pageContextForAgent());
} catch (err) {
if (err.name !== 'AbortError') {
this._messages = [
...this._messages,
{ role: ROLE.ASSISTANT, content: `Error: ${err.message}` },
];
}
} finally {
this._done();
}
})();
return;
}
} else if (type === AGENT_EVENT.TOOL_APPROVAL_REQUEST) {
const existingCard = next.get(toolCallId);
const settled = existingCard?.state;
Expand Down
4 changes: 3 additions & 1 deletion nx2/blocks/chat/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ class NxChat extends LitElement {
|| f.type === 'application/pdf'
|| f.type === 'text/markdown'
|| f.name?.endsWith('.md')
|| f.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|| f.name?.toLowerCase().endsWith('.docx')
));
await this._onFilesSelected(accepted);
}
Expand Down Expand Up @@ -539,7 +541,7 @@ class NxChat extends LitElement {
<input
class="chat-file-input"
type="file"
accept="image/*,text/markdown,.md,application/pdf,.pdf"
accept="image/*,text/markdown,.md,application/pdf,.pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.docx"
multiple
hidden
@change=${this._onFileInputChange}
Expand Down
16 changes: 15 additions & 1 deletion nx2/blocks/chat/renderers.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,21 @@ const { unified, remarkParse, remarkGfmNoLink, mdast2hast, hastToDom } = await i
const parser = unified().use(remarkParse).use(remarkGfmNoLink);

function toDOM(hast) {
return hastToDom(sanitizeLinks(linkifyBareUrls(hast)), { fragment: true });
const result = hastToDom(sanitizeLinks(linkifyBareUrls(hast)), { fragment: true });
// hastToDom returns a full Document (nodeType 9) when the hast root has no
// children or contains an <html> element — e.g. an empty directive body
// produces an empty root whose children.length === 0 triggers createDocument()
// instead of createDocumentFragment(). Inserting a Document into a Lit
// binding causes HierarchyRequestError, so we extract the body children into
// a DocumentFragment instead.
if (result.nodeType === Node.DOCUMENT_NODE) {
const frag = document.createDocumentFragment();
if (result.body) {
while (result.body.firstChild) frag.append(result.body.firstChild);
}
return frag;
}
return result;
}

function renderMessageContent(text) {
Expand Down
110 changes: 110 additions & 0 deletions nx2/blocks/chat/utils/skill-script-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// DEMO ONLY — prod target is adobe/skills (pending PR approval).
const MARKETPLACE_RAW_BASE = 'https://raw.githubusercontent.com/exp-workspace/skills/main/ew';

// Map execution_runtimes values to file extensions
const RUNTIME_EXT = { js: '.js' };

/**
* Parse flat execution_* frontmatter keys from a skill.md string into a structured
* manifest object.
*
* Expected frontmatter shape (flat keys, no nested YAML block):
* execution_entry: convert
* execution_runtimes: js
* execution_capabilities: # empty = client-eligible
* execution_timeout_ms: 5000
*
* @param {string} text - raw skill.md content
* @returns {{ entry: string, runtimes: string[], capabilities: string[], timeoutMs: number }|null}
*/
export function parseSkillFrontmatter(text) {
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return null;

const fm = fmMatch[1];
const get = (key) => {
// Use [ \t]* (not \s*) to avoid consuming newlines before the value.
const m = fm.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'));
return m ? m[1].trim() : '';
};

const entry = get('execution_entry');
if (!entry) return null;

const runtimesRaw = get('execution_runtimes');
const runtimes = runtimesRaw
? runtimesRaw.split(',').map((r) => r.trim()).filter(Boolean)
: [];

const capabilitiesRaw = get('execution_capabilities');
const capabilities = capabilitiesRaw
? capabilitiesRaw.split(',').map((c) => c.trim()).filter(Boolean)
: [];

const dependenciesRaw = get('execution_dependencies');
const dependencies = dependenciesRaw
? dependenciesRaw.split(',').map((d) => d.trim()).filter(Boolean)
: [];

const timeoutRaw = get('execution_timeout_ms');
const timeoutMs = timeoutRaw ? parseInt(timeoutRaw, 10) : 5000;

return { entry, runtimes, capabilities, dependencies, timeoutMs };
}

/**
* Resolve the skill manifest and script module URL for a given skillId.
*
* Fetches skill.md and script.js from the curated GH marketplace (TRUSTED source).
* The script text is turned into a blob URL so the browser accepts it as an ES module
* (raw.githubusercontent.com serves text/plain, which browsers reject for import()).
*
* Eligibility is determined CLIENT-SIDE from the fetched manifest — never from the
* agent's tool args.
*
* @param {string} skillId - skill identifier; may be prefixed with `ao:` (reserved)
* @returns {Promise<{ manifest: object, moduleUrl: string }|{ error: string }>}
*/
export async function resolveSkill(skillId) {
if (!skillId) return { error: 'missing skillId' };

// Marketplace skills (ao: prefix) — reserved seam, not yet implemented
if (skillId.startsWith('ao:')) {
return { error: 'ao marketplace skills not yet supported' };
}

const skillMdUrl = `${MARKETPLACE_RAW_BASE}/${skillId}/skill.md`;

let mdText;
try {
const resp = await fetch(skillMdUrl);
if (!resp.ok) return { error: `skill.md not found for ${skillId} (${resp.status})` };
mdText = await resp.text();
} catch (err) {
return { error: `failed to fetch skill.md: ${err.message}` };
}

const manifest = parseSkillFrontmatter(mdText);
if (!manifest) return { error: `invalid or missing frontmatter in skill.md for ${skillId}` };

// Build scripts/<entry>.<ext> path — extension from the first declared js runtime
const primaryRuntime = manifest.runtimes.find((r) => RUNTIME_EXT[r]) ?? 'js';
const ext = RUNTIME_EXT[primaryRuntime] ?? '.js';
const scriptUrl = `${MARKETPLACE_RAW_BASE}/${skillId}/scripts/${manifest.entry}${ext}`;

let scriptText;
try {
const resp = await fetch(scriptUrl);
if (!resp.ok) return { error: `script not found for ${skillId} (${resp.status})` };
scriptText = await resp.text();
} catch (err) {
return { error: `failed to fetch script: ${err.message}` };
}

// raw.githubusercontent.com serves text/plain; browsers reject that MIME type for
// ES module import(). Convert to a blob URL with the correct MIME type instead.
const blob = new Blob([scriptText], { type: 'text/javascript' });
const moduleUrl = URL.createObjectURL(blob);

return { manifest: { ...manifest, id: skillId }, moduleUrl };
}
Loading
Loading