From 125b1e69cd7b34b9b488a2b3f4d1092379df7cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:22:16 +0000 Subject: [PATCH 1/4] fix(runtime,cloud-connection): one binder for an artifact's script-action bodies and body hooks, called by AppPlugin and by install-local; list_actions reads the handler registry Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...install-local-handlers.integration.test.ts | 360 ++++++++++++++++++ ...ce-install-local-artifact-handlers.test.ts | 246 ++++++++++++ .../src/marketplace-install-local-plugin.ts | 55 +++ packages/runtime/src/action-execution.ts | 40 ++ packages/runtime/src/app-artifact-handlers.ts | 202 ++++++++++ packages/runtime/src/app-plugin.ts | 122 +----- packages/runtime/src/domains/mcp.ts | 17 + packages/runtime/src/http-dispatcher.test.ts | 13 + packages/runtime/src/index.ts | 5 + .../mcp-list-actions-handler-probe.test.ts | 120 ++++++ 10 files changed, 1076 insertions(+), 104 deletions(-) create mode 100644 packages/cli/test/package-install-local-handlers.integration.test.ts create mode 100644 packages/cloud-connection/src/marketplace-install-local-artifact-handlers.test.ts create mode 100644 packages/runtime/src/app-artifact-handlers.ts create mode 100644 packages/runtime/src/mcp-list-actions-handler-probe.test.ts diff --git a/packages/cli/test/package-install-local-handlers.integration.test.ts b/packages/cli/test/package-install-local-handlers.integration.test.ts new file mode 100644 index 00000000000..975581eb3cf --- /dev/null +++ b/packages/cli/test/package-install-local-handlers.integration.test.ts @@ -0,0 +1,360 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21321 — an app installed with `os package install ` runs its + * script actions and its body hooks exactly as the same artifact does under + * `os start --artifact`, on the real composition, across a reinstall and a + * restart. + * + * ## The defect, measured on the published train and again on `main` f39760864c + * + * `os start` (empty kernel) → `os package install ./dist/objectstack.json` (the + * documented air-gapped install-local path) registered the app's objects and + * declarations, and bound none of its handlers: + * + * - REST `POST /api/v1/actions//complete_task` → 404 RESOURCE_NOT_FOUND + * - MCP `run_action complete_task` → "No handler registered …" + * - MCP `list_actions` → still lists complete_task + * - a `beforeInsert` body hook → never ran (status null) + * + * before AND after a restart, while `os start --artifact` of the same file + * answered 200, `run_action ok`, and stamped the row. `AppPlugin.start` was the + * only binder; the install route and the ledger rehydrate never reached it. + * + * ## What each `it` reads + * + * One fixture, four phases — after the install, after a REINSTALL of the same + * file, after a RESTART on the same home, and the `--artifact` CONTROL on a + * fresh home — each probed through the doors a user and an agent actually use: + * the data route (the hook), the REST action door, and MCP over Streamable HTTP + * with a minted API key (`list_actions`, `run_action`). The hook APPENDS to + * `status`, so a hook bound twice answers `stampedstamped`: "exactly one binding + * after a reinstall" is read off a row, not off an internal registry. + * + * `ghost_task` is a declared, AI-exposed `script` action whose `target` no code + * ever registers. It is the advertisement half: `list_actions` must not offer + * what `run_action` refuses, so it is absent from every listing, and the run + * door's refusal is read beside it. + * + * ## Spawn shape + * + * `bin/run.js` with `NODE_ENV` unset (hence `requireBuiltCli`) — the operator's + * entrypoint, and the one whose bind is deterministic (no development + * auto-shift). Every workspace package the child loads, `@objectstack/runtime` + * and `@objectstack/cloud-connection` included, resolves through its `exports` + * to `dist/`: an ablation of either package's source reaches this file only + * after that package is rebuilt. Each `os start` gets its own process group and + * is stopped by signalling the group (`os start` supervises a `serve` + * grandchild). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + childEnv, + E2E_SECRET_KEY, + portContentionError, + randomPort, + requireBuiltCli, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RUN_JS = resolve(HERE, '../bin/run.js'); + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; + +const APP_ID = 'com.example.tasksapp'; +const OBJECT = 'tasks_app_task'; +const EMAIL = 'owner@example.com'; +const PASSWORD = 'Passw0rd!Passw0rd'; + +const COMPLETE_TASK = { + name: 'complete_task', + label: 'Complete Task', + objectName: OBJECT, + locations: ['record_header'], + type: 'script', + body: { + language: 'js', + capabilities: ['api.write'], + source: + `var id = ctx.recordId; await ctx.api.object('${OBJECT}').update({ id: id, status: 'done', done: true }); ` + + 'return { ok: true, id: id };', + }, + ai: { exposed: true, description: 'Mark a task as complete: sets its status to done and ticks the Done box.' }, +}; +const GHOST_TASK = { + name: 'ghost_task', + label: 'Ghost Task', + objectName: OBJECT, + type: 'script', + target: 'ghostTaskHandler', + ai: { exposed: true, description: 'Declared and AI-exposed; no code ever registers its handler.' }, +}; + +/** `dist/objectstack.json` as `os build` writes it for this app (schema defaults trimmed). */ +const ARTIFACT = { + manifest: { id: APP_ID, namespace: 'tasks_app', version: '0.1.0', type: 'app', name: 'Tasks App' }, + objects: [{ + name: OBJECT, + label: 'Task', + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'text', label: 'Status' }, + done: { type: 'boolean', label: 'Done' }, + }, + actions: [COMPLETE_TASK, GHOST_TASK], + }], + actions: [COMPLETE_TASK, GHOST_TASK], + hooks: [{ + name: 'tasks_app_stamp_status', + label: 'Stamp Status', + object: OBJECT, + events: ['beforeInsert'], + // APPENDS, so a double binding is visible on the row. + body: { language: 'js', source: "ctx.input.status = (typeof ctx.input.status === 'string' ? ctx.input.status : '') + 'stamped';" }, + onError: 'abort', + }], +}; + +const groups: ChildProcess[] = []; +const dirs: string[] = []; + +interface LiveStart { + child: ChildProcess; + base: string; + output: () => string; +} + +function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(process.execPath, [RUN_JS, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + cwd, + // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV` + // unset: the built entrypoint resolves commands from dist/ (#11464). + env: childEnv({ NODE_ENV: undefined, NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group: `os start` supervises a `serve` grandchild. + detached: true, + }); + groups.push(child); + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + if (READY.test(out)) settle(null); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(portContentionError(out, 'os start', port) + ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +interface Answer { status: number; body: any } + +async function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; +} + +async function authenticate(live: LiveStart, firstUser: boolean): Promise { + const res = await http( + live, 'POST', firstUser ? '/api/v1/auth/sign-up/email' : '/api/v1/auth/sign-in/email', '', + firstUser ? { email: EMAIL, password: PASSWORD, name: 'Owner' } : { email: EMAIL, password: PASSWORD }, + ); + const token = res.body?.token; + if (res.status !== 200 || typeof token !== 'string') { + throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + return token; +} + +function packageInstall(appDir: string, live: LiveStart): { exit: number | null; output: string } { + const r = spawnSync(process.execPath, [RUN_JS, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + cwd: appDir, + encoding: 'utf8', + env: childEnv({ NODE_ENV: undefined, NO_COLOR: '1' }), + timeout: 120_000, + }); + return { exit: r.status, output: `${r.stdout}\n${r.stderr}` }; +} + +/** One MCP JSON-RPC call over Streamable HTTP; the tool's JSON text, parsed. */ +async function mcpTool(live: LiveStart, apiKey: string, name: string, args: Record) { + const r = await fetch(`${live.base}/api/v1/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': apiKey }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), + }); + const text = await r.text(); + const data = text.split('\n').find((l) => l.startsWith('data:')); + const envelope = JSON.parse(data ? data.slice(5) : text); + const content = envelope?.result?.content?.[0]?.text; + let payload: any = content; + try { payload = JSON.parse(content); } catch { /* a refusal is prose */ } + return { isError: envelope?.result?.isError === true, payload }; +} + +interface Phase { + inserted: Answer; + restAction: Answer; + afterRest: Answer; + listed: string[]; + run: { isError: boolean; payload: any }; + afterRun: Answer; + ghostRun: { isError: boolean; payload: any }; +} + +let seq = 0; +async function probe(live: LiveStart, token: string): Promise { + const key = await http(live, 'POST', '/api/v1/keys', token, { name: `mcp-${++seq}` }); + const apiKey = key.body?.data?.key ?? key.body?.key; + if (typeof apiKey !== 'string') throw new Error(`POST /api/v1/keys answered ${key.status}: ${JSON.stringify(key.body)}`); + + const inserted = await http(live, 'POST', `/api/v1/data/${OBJECT}`, token, { name: `rest-${seq}` }); + const id = inserted.body?.data?.id ?? inserted.body?.id; + const restAction = await http(live, 'POST', `/api/v1/actions/${OBJECT}/complete_task`, token, { recordId: id }); + const afterRest = await http(live, 'GET', `/api/v1/data/${OBJECT}/${id}`, token); + + const second = await http(live, 'POST', `/api/v1/data/${OBJECT}`, token, { name: `mcp-${seq}` }); + const id2 = second.body?.data?.id ?? second.body?.id; + const list = await mcpTool(live, apiKey, 'list_actions', {}); + const rows: any[] = Array.isArray(list.payload?.actions) ? list.payload.actions : Array.isArray(list.payload) ? list.payload : []; + const run = await mcpTool(live, apiKey, 'run_action', { actionName: 'complete_task', objectName: OBJECT, recordId: id2 }); + const afterRun = await http(live, 'GET', `/api/v1/data/${OBJECT}/${id2}`, token); + const ghostRun = await mcpTool(live, apiKey, 'run_action', { actionName: 'ghost_task', objectName: OBJECT, recordId: id2 }); + return { inserted, restAction, afterRest, listed: rows.map((a) => a?.name), run, afterRun, ghostRun }; +} + +const rec = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body; + +const phases: Record<'install' | 'reinstall' | 'restart' | 'control', Phase | undefined> = { + install: undefined, reinstall: undefined, restart: undefined, control: undefined, +}; +const installs: Array<{ exit: number | null; output: string }> = []; + +beforeAll(async () => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + const root = mkdtempSync(join(tmpdir(), 'install-local-handlers-')); + dirs.push(root); + const appDir = join(root, 'app'); + mkdirSync(join(appDir, 'dist'), { recursive: true }); + writeFileSync(join(appDir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT, null, 2), 'utf8'); + // The runtime's cwd holds no project config, so `os start` boots the EMPTY kernel. + const runtimeDir = join(root, 'runtime'); + mkdirSync(runtimeDir, { recursive: true }); + const home = join(runtimeDir, 'home'); + const port = randomPort(); + + // ── boot 1: empty `os start`, install, probe, reinstall, probe ───────── + const first = await bootStart(runtimeDir, home, port); + const token = await authenticate(first, true); + installs.push(packageInstall(appDir, first)); + phases.install = await probe(first, token); + installs.push(packageInstall(appDir, first)); + phases.reinstall = await probe(first, token); + await stopGroup(first.child); + + // ── boot 2: same home and cwd — the ledger rehydrates on kernel:ready ── + const second = await bootStart(runtimeDir, home, port); + phases.restart = await probe(second, await authenticate(second, false)); + await stopGroup(second.child); + + // ── boot 3: the CONTROL — the same file as the boot artifact ─────────── + const controlDir = join(root, 'control'); + mkdirSync(controlDir, { recursive: true }); + const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]); + phases.control = await probe(third, await authenticate(third, true)); + await stopGroup(third.child); +}, 4 * BOOT_TIMEOUT_MS); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}, 60_000); + +describe('#21321: an install-local package runs its script actions and body hooks', () => { + it('both `os package install` runs succeed (harness health)', () => { + for (const run of installs) { + expect(run.exit, run.output).toBe(0); + expect(run.output).toMatch(/Package installed into the running kernel/); + } + }); + + for (const name of ['install', 'reinstall', 'restart', 'control'] as const) { + describe(`after ${name}`, () => { + it('the body hook fires exactly once on insert', () => { + const p = phases[name]!; + expect(p.inserted.status, JSON.stringify(p.inserted.body)).toBe(201); + expect(rec(p.inserted)?.status, 'null = the hook never ran; stampedstamped = it is bound twice').toBe('stamped'); + }); + + it('REST POST /api/v1/actions runs the script action body', () => { + const p = phases[name]!; + expect(p.restAction.status, JSON.stringify(p.restAction.body)).toBe(200); + expect(p.restAction.body?.data).toMatchObject({ ok: true }); + expect(rec(p.afterRest)).toMatchObject({ status: 'done', done: true }); + }); + + it('MCP run_action runs it, and list_actions advertises it', () => { + const p = phases[name]!; + expect(p.run.isError, JSON.stringify(p.run.payload)).toBe(false); + expect(p.run.payload).toMatchObject({ ok: true, action: 'complete_task', result: { ok: true } }); + expect(rec(p.afterRun)).toMatchObject({ status: 'done', done: true }); + expect(p.listed).toContain('complete_task'); + }); + + it('list_actions does not advertise a declared action run_action cannot run', () => { + const p = phases[name]!; + expect(p.ghostRun.isError, 'precondition: ghost_task has no handler anywhere').toBe(true); + expect(String(p.ghostRun.payload)).toMatch(/No handler registered/); + expect(p.listed, 'advertised an action the run door refuses').not.toContain('ghost_task'); + }); + }); + } +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-artifact-handlers.test.ts b/packages/cloud-connection/src/marketplace-install-local-artifact-handlers.test.ts new file mode 100644 index 00000000000..499a29c862c --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-artifact-handlers.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21321 — an installed package's script-action bodies and body hooks are + * BOUND, on the install route and on the `kernel:ready` rehydrate, through the + * same runtime function `AppPlugin.start` uses for a boot artifact. + * + * The defect: `os package install ` registered the package's metadata + * and nothing executable. Every door refused the installed `type: 'script'` + * action (REST 404, MCP `run_action` "No handler registered") before and after + * a restart, and its body hooks never fired — while `os start --artifact` of the + * same file dispatched normally, because `AppPlugin.start` was the only binder. + * + * What this file pins, against the engine-level outcome rather than a call: + * + * - install: the action handler is registered under `app:` and + * the body hook is handed to `bindHooks` under the same owner; + * - rehydrate: a ledger entry written by an earlier process binds the same + * way when a fresh plugin reaches `kernel:ready`; + * - reinstall: still exactly one handler per action and one binding per hook; + * - reinstall of a version that DROPPED its action and its hook: neither + * stays bound — the owner's previous set is torn down first. + * + * The binder is the REAL `@objectstack/runtime` export (resolved through its + * `exports`, i.e. its built `dist/`, like the plugin's own lazy import); the + * engine is a recording double that models only what the binder touches — + * including `bindHooksToEngine`'s own rule of unregistering a package's hooks + * only when handed a NON-empty list, which is what the dropped-hook case reads. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The first load of the runtime's dist paid at module top, never inside a +// clocked `it` (the clocked-window rule, `scripts/check-test-source-alias.mjs`): +// the plugin reaches the same module through a dynamic `import()`. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const APP_ID = 'com.example.tasksapp'; +const OWNER = `app:${APP_ID}`; + +const ACTION = { + name: 'complete_task', + label: 'Complete Task', + objectName: 'tasks_app_task', + type: 'script', + body: { language: 'js', capabilities: ['api.write'], source: 'return { ok: true };' }, + ai: { exposed: true, description: 'Mark a task as complete.' }, +}; +const HOOK = { + name: 'tasks_app_stamp_status', + object: 'tasks_app_task', + events: ['beforeInsert'], + body: { language: 'js', source: "ctx.input.status = 'stamped';" }, +}; + +/** The compiled-artifact shape `os build` writes and `os package install` sends. */ +function artifact(version: string, opts: { withHandlers: boolean }) { + return { + manifest: { id: APP_ID, namespace: 'tasks_app', version, type: 'app', name: 'Tasks App' }, + objects: [{ + name: 'tasks_app_task', + label: 'Task', + fields: { name: { type: 'text', label: 'Name' } }, + ...(opts.withHandlers ? { actions: [ACTION] } : {}), + }], + ...(opts.withHandlers ? { actions: [ACTION], hooks: [HOOK] } : {}), + }; +} + +/** The install route's normalization of a compiled bundle, as it lands in the ledger. */ +function flattened(bundle: ReturnType) { + const { manifest: meta, ...sections } = bundle; + return { ...meta, ...sections }; +} + +/** + * The engine surface the binder writes to, as state: an action Map keyed + * `:` (the engine's own key) and one hook list. + */ +function recordingEngine() { + const actions = new Map(); + let hooks: Array<{ name: string; packageId?: string }> = []; + const unregisterHooksByPackage = (packageId: string): number => { + const before = hooks.length; + hooks = hooks.filter((h) => h.packageId !== packageId); + return before - hooks.length; + }; + return { + actions, + hooksFor: (packageId: string) => hooks.filter((h) => h.packageId === packageId), + engine: { + syncSchemas: async () => undefined, + registerAction: (object: string, name: string, handler: unknown, pkg?: string) => { + actions.set(`${object}:${name}`, { handler, package: pkg }); + }, + removeActionsByPackage: (pkg: string) => { + for (const [key, entry] of actions) if (entry.package === pkg) actions.delete(key); + }, + listRegisteredActions: () => + [...actions].map(([key, entry]) => ({ + objectName: key.slice(0, key.indexOf(':')), + actionName: key.slice(key.indexOf(':') + 1), + ...(entry.package ? { package: entry.package } : {}), + })), + unregisterHooksByPackage, + // `bindHooksToEngine`'s own teardown fires only for a NON-empty list. + bindHooks: (list: Array<{ name: string }> | undefined, opts?: { packageId?: string; bodyRunner?: unknown }) => { + if (!Array.isArray(list) || list.length === 0) return; + if (opts?.packageId) unregisterHooksByPackage(opts.packageId); + expect(typeof opts?.bodyRunner, 'a body hook needs the sandbox runner').toBe('function'); + for (const h of list) hooks.push({ name: h.name, packageId: opts?.packageId }); + }, + }, + }; +} + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeCtx(rawApp: any, services: Record) { + const hooks = new Map(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { + logger, + ctx: { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }, + fire: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-handlers-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +async function bootPlugin(engine: Record) { + const rawApp = makeRawApp(); + const { ctx, fire, logger } = makeCtx(rawApp, { + manifest: { register: vi.fn() }, + auth: installerAuthService(), + objectql: withInstallerGrants(engine), + }); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await fire(); + const install = async (body: unknown) => { + const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest: body })); + expect(res.payload?.success, JSON.stringify(res.payload)).toBe(true); + return res; + }; + return { install, logger }; +} + +describe('#21321: install-local binds an installed package’s handlers', () => { + it('install — the script action is registered and the body hook is bound, both under app:', async () => { + const rec = recordingEngine(); + const { install } = await bootPlugin(rec.engine); + + await install(artifact('0.1.0', { withHandlers: true })); + + const entry = rec.actions.get('tasks_app_task:complete_task'); + expect(entry, 'the installed script action has no handler — every door refuses it').toBeDefined(); + expect(entry!.package).toBe(OWNER); + expect(typeof entry!.handler).toBe('function'); + expect(rec.hooksFor(OWNER).map((h) => h.name)).toEqual(['tasks_app_stamp_status']); + }); + + it('rehydrate — a ledger entry from an earlier process is bound at kernel:ready', async () => { + new LocalManifestSource(dir).write({ + packageId: APP_ID, + versionId: 'local', + manifestId: APP_ID, + version: '0.1.0', + // What the install route persists: the compiled bundle, flattened + // (its `manifest` meta lifted to the top level beside the sections). + manifest: flattened(artifact('0.1.0', { withHandlers: true })), + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, + }); + const rec = recordingEngine(); + await bootPlugin(rec.engine); + + expect(rec.actions.get('tasks_app_task:complete_task')?.package, 'a restart leaves the installed action unbound').toBe(OWNER); + expect(rec.hooksFor(OWNER).map((h) => h.name)).toEqual(['tasks_app_stamp_status']); + }); + + it('reinstall — still exactly one handler per action and one binding per hook', async () => { + const rec = recordingEngine(); + const { install } = await bootPlugin(rec.engine); + + await install(artifact('0.1.0', { withHandlers: true })); + await install(artifact('0.1.0', { withHandlers: true })); + + const owned = rec.engine.listRegisteredActions().filter((r) => r.package === OWNER); + expect(owned).toEqual([{ objectName: 'tasks_app_task', actionName: 'complete_task', package: OWNER }]); + expect(rec.hooksFor(OWNER)).toHaveLength(1); + }); + + it('reinstall of a version that dropped its action and hook — neither stays bound', async () => { + const rec = recordingEngine(); + const { install } = await bootPlugin(rec.engine); + + await install(artifact('0.1.0', { withHandlers: true })); + expect(rec.actions.size, 'precondition: the first version bound its action').toBe(1); + + await install(artifact('0.2.0', { withHandlers: false })); + + expect(rec.engine.listRegisteredActions().filter((r) => r.package === OWNER)).toEqual([]); + expect(rec.hooksFor(OWNER), 'a hook the new version dropped must stop firing').toEqual([]); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 2ef0f0ae8ba..939ed571751 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -311,6 +311,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const ql: any = ctx.getService('objectql'); if (ql && typeof ql.syncSchemas === 'function') await ql.syncSchemas(); } catch { /* non-fatal */ } + // [#21321] Bind the package's script-action bodies and body + // hooks — `register` above makes them declared, not runnable. + await this.bindArtifactHandlers(ctx, entry.manifest, entry.manifestId); // Replay translations + register seed datasets, but don't // re-run seeding — existing rows are already in the DB from // the original install, and multi-tenant orgs will replay @@ -944,6 +947,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx.logger?.warn?.(`[MarketplaceInstallLocal] syncSchemas failed for ${manifestId}: ${err?.message ?? err}`); } + // 4c. [#21321] Bind the package's script-action bodies and body hooks + // through the runtime's ONE binder — the call `AppPlugin.start` + // makes for a boot artifact. A reinstall replaces the previous + // version's set rather than adding to it. + await this.bindArtifactHandlers(ctx, manifest, manifestId); + // 5. Replicate the AppPlugin start-time side-effects that the // `manifest` service does NOT do on its own: // • load translation bundles into the i18n service @@ -1386,6 +1395,52 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 200); }; + /** + * [#21321] Bind an installed package's executable handlers — its + * `type: 'script'` action bodies and its body hooks — through + * `bindAppArtifactHandlers`, the runtime's ONE binder and the call + * `AppPlugin.start` makes for a boot artifact, under the same owner + * (`app:`). Called on the install route and on the + * `kernel:ready` rehydrate. + * + * Before this, `manifest.register` was the whole install: the package's + * actions and hooks were DECLARED and never bound, so every door refused + * its script actions ("No handler registered" over MCP, 404 over REST) + * before and after a restart, and its body hooks never fired — while an + * `os start --artifact` boot of the same file dispatched them. + * + * The binder replaces the owner's previous set, so a reinstall leaves each + * action with exactly one handler and stops whatever the new version + * dropped. ⛔ No second registration path lives here: a runtime without the + * binder (an older build, or a suite that mocks `@objectstack/runtime` + * without it) binds NOTHING and says so — the package's script actions then + * stay unrunnable, and `list_actions` does not advertise them. + * + * Resolved lazily through `@objectstack/runtime`, like every other runtime + * helper this plugin calls. Never throws. + */ + private bindArtifactHandlers = async (ctx: PluginContext, manifest: unknown, manifestId: string): Promise => { + let ql: IObjectQLEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + if (!ql) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] no objectql engine — the script actions and body hooks of ${manifestId} are NOT bound`); + return; + } + let bind: typeof import('@objectstack/runtime')['bindAppArtifactHandlers'] | undefined; + try { + const mod: any = await import('@objectstack/runtime'); + if (typeof mod?.bindAppArtifactHandlers === 'function') bind = mod.bindAppArtifactHandlers; + } catch { /* reported below */ } + if (!bind) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] this runtime has no bindAppArtifactHandlers — the script actions and body hooks of ${manifestId} are NOT bound: ` + + 'every door refuses those actions and the hooks never fire. Upgrade @objectstack/runtime alongside @objectstack/cloud-connection.', + ); + return; + } + bind(ql, manifest, { appId: manifestId, logger: ctx.logger, source: 'MarketplaceInstallLocal' }); + }; + /** * Replicate the start-time side-effects that AppPlugin runs for * statically-declared apps but the `manifest` service does NOT: diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index f8958cdbc34..932e00d2897 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -2521,6 +2521,46 @@ export async function executeRegisteredAction(_deps: ActionExecutionDeps, } +/** + * [#21321] The read-only twin of {@link executeRegisteredAction}: would the + * script door find a handler for this action? Returns a probe over ONE snapshot + * of the engine's handler registry (`listRegisteredActions()`, the engine's + * public enumeration of the Map `executeAction` reads), answering for an + * `(objectName, candidates)` pair by walking exactly the rotation the run door + * walks — `actionHandlerObjectKeys(objectName)` × the handler-key candidates — + * and dispatching nothing. + * + * It exists so an ADVERTISING surface reads the same source the run doors do. + * MCP `list_actions` used to admit a script action on its declaration alone + * (`target || body`), so a declaration nothing had bound — measured: an + * `os package install`ed package, before its bodies were bound — was listed + * and then refused by `run_action` with "No handler registered". + * + * An engine that cannot enumerate its handlers (no `listRegisteredActions`) + * answers `false` for everything: the listing then cannot vouch for any script + * action, and saying nothing is the honest answer — never a fall-back to the + * declaration, which is the very source this replaces. + */ +export function registeredActionHandlerProbe(_deps: ActionExecutionDeps, + ql: any, +): (objectName: string, candidates: string[]) => boolean { + const registered = new Set(); + if (ql && typeof ql.listRegisteredActions === 'function') { + for (const row of ql.listRegisteredActions() as Array<{ objectName: string; actionName: string }>) { + registered.add(`${row.objectName}:${row.actionName}`); + } + } + return (objectName, candidates) => { + for (const obj of actionHandlerObjectKeys(objectName)) { + for (const key of candidates) { + if (registered.has(`${obj}:${key}`)) return true; + } + } + return false; + }; +} + + /** * Resolve the DECLARATION behind a `/` route pair — the * single source the REST `/actions` route reads for the ADR-0066 D4 diff --git a/packages/runtime/src/app-artifact-handlers.ts b/packages/runtime/src/app-artifact-handlers.ts new file mode 100644 index 00000000000..1f15dc45aba --- /dev/null +++ b/packages/runtime/src/app-artifact-handlers.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE binder of an app artifact's executable handlers (#21321). + * + * An artifact declares two kinds of server-side code as DATA: a `type: 'script'` + * action carrying an inline `body`, and a hook carrying an inline `body` (plus + * the `functions` a hook may name, when the bundle is code rather than JSON). + * Registering the artifact's metadata (`manifest.register`) makes the action and + * the hook DECLARED; only this function makes them RUN — it compiles each body + * through the QuickJS sandbox and hands the handler to the engine: + * + * - hook bodies (and bundle functions) through `ql.bindHooks(...)`, + * - action bodies through `ql.registerAction(object, name, handler, owner)`, + * + * both under ONE owner, `app:` ({@link appArtifactHandlerOwner}). + * + * ## Why this is a function and not a block inside `AppPlugin.start` + * + * It was that block, and `AppPlugin.start` was its only caller — so an app that + * reached the runtime any other way was declared and never bound. The measured + * case is `os package install ` (the install-local plugin in + * `@objectstack/cloud-connection`): the installed package's objects, actions and + * hooks all registered, every door refused its script actions (REST 404, MCP + * `run_action` "No handler registered"), its body hooks never fired, and an + * `os start --artifact` boot of the same file dispatched normally. Every path + * that brings an artifact into a running engine calls this function — there is + * no second registration path to drift from it: + * + * - `AppPlugin.start` (the boot artifact, `defineStack` configs), + * - the install-local plugin's install route and its `kernel:ready` rehydrate. + * + * ## Re-binding replaces, it never accumulates + * + * Before binding, the owner's previous set is torn down: its action handlers + * (`removeActionsByPackage`) and its hooks (`unregisterHooksByPackage`). On a + * first bind that is a no-op; on a reinstall it is what keeps each action at + * exactly one handler and each hook firing once, and what makes an action or a + * hook the new version dropped stop running. The explicit hook teardown matters + * because `bindHooksToEngine` only unregisters when it is handed a NON-empty + * list — a reinstall whose new version declares no hooks would otherwise keep + * the old ones firing. + * + * Functions are not torn down: they are code (ADR-0088 — never a metadata row), + * so a JSON artifact cannot carry one, and `registerFunction` replaces by name. + * + * ## Failure posture (unchanged from the block it replaces) + * + * Never throws. A hook set or an action set that fails to bind is logged at + * `error` with the app id and the other half still binds; a single action whose + * registration throws is logged at `warn` and the rest still register. + */ + +import type { IObjectQLEngine, Logger } from '@objectstack/spec/contracts'; +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; +import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; +import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; +import { collectBundleActions, collectBundleFunctionEntries, collectBundleHooks } from './app-plugin.js'; + +/** + * The engine owner key every handler bound for `appId` is registered under — + * the `package` of each `listRegisteredActions()` row and the `packageId` of + * each hook. ObjectQLPlugin's runtime-authored re-sync reads the same owner as + * "an installed code package's handler" (`isArtifactShippedAction`). + */ +export function appArtifactHandlerOwner(appId: string): string { + return `app:${appId}`; +} + +export interface AppArtifactHandlerBindingOptions { + /** The app the handlers belong to: the artifact manifest's `id` (falling back to `name`). */ + appId: string; + logger: Logger; + /** Who is binding, for the log lines — `'AppPlugin'`, `'MarketplaceInstallLocal'`. */ + source?: string; +} + +/** What one {@link bindAppArtifactHandlers} call bound. */ +export interface AppArtifactHandlerBinding { + /** The owner key the handlers were registered under. */ + owner: string; + /** Hook definitions handed to `bindHooks` (0 when none were declared or binding failed). */ + hooks: number; + /** Bundle functions handed to `bindHooks` alongside them. */ + functions: number; + /** Action handlers registered — one per bound declaration. */ + actions: number; +} + +/** + * Bind every executable handler an app artifact declares onto `ql`, replacing + * whatever the same app bound before. See the module header for the contract. + * + * @param ql the engine the app's metadata is registered on + * @param bundle the artifact / stack definition — any shape the bundle + * collectors accept (flattened manifest, `{ manifest, … }` + * envelope, multi-package `packages[]`) + */ +export function bindAppArtifactHandlers( + ql: IObjectQLEngine, + bundle: unknown, + options: AppArtifactHandlerBindingOptions, +): AppArtifactHandlerBinding { + const { appId, logger } = options; + const tag = `[${options.source ?? 'AppPlugin'}]`; + const owner = appArtifactHandlerOwner(appId); + const out: AppArtifactHandlerBinding = { owner, hooks: 0, functions: 0, actions: 0 }; + + // ── Tear down the owner's previous set ────────────────────────────── + try { + if (typeof ql.removeActionsByPackage === 'function') ql.removeActionsByPackage(owner); + if (typeof ql.unregisterHooksByPackage === 'function') ql.unregisterHooksByPackage(owner); + } catch (err: any) { + logger.error(`${tag} Failed to tear down the previous handler set`, err as Error, { appId, owner }); + } + + // ── Hooks (and the functions a hook may name) ─────────────────────── + // Inline function handlers are resolved directly; string-named handlers + // are looked up in `bundle.functions` (registered here too) or in any + // function previously registered on the engine. + try { + const hooks = collectBundleHooks(bundle); + // Entries, not bare handlers: each function's declared `effect` + // (#4396) rides along to the registry, where a `script` node reads + // it to report what its run actually did. + const functions = collectBundleFunctionEntries(bundle); + for (const [name, fn] of Object.entries(functions)) { + if (fn.unrecognizedEffect === undefined) continue; + logger.warn(`${tag} unrecognized function effect — counted as an uncountable write`, { + appId, + name, + effect: fn.unrecognizedEffect, + expected: "'pure' | 'writes'", + }); + } + if (hooks.length > 0 || Object.keys(functions).length > 0) { + if (typeof ql.bindHooks === 'function') { + ql.bindHooks(hooks, { + packageId: owner, + functions, + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger, appId }), + }); + out.hooks = hooks.length; + out.functions = Object.keys(functions).length; + logger.info(`${tag} Bound declarative hooks`, { + appId, + hookCount: out.hooks, + functionCount: out.functions, + }); + } else { + logger.warn(`${tag} ql.bindHooks unavailable; declarative hooks ignored`, { + appId, + hookCount: hooks.length, + }); + } + } + } catch (err: any) { + logger.error(`${tag} Failed to bind declarative hooks`, err as Error, { appId }); + } + + // ── Action bodies ─────────────────────────────────────────────────── + // Actions with an inline `body` are wired to the engine so the REST + // `/actions` door, MCP `run_action` and the Console button can invoke + // them. Actions without one are left to imperative + // `engine.registerAction(...)` registration in user code. + try { + const actions = collectBundleActions(bundle); + if (actions.length > 0 && typeof ql.registerAction === 'function') { + const actionBodyRunner = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger, appId }); + for (const action of actions) { + const handler = actionBodyRunner(action); + if (!handler) continue; + // Object-less actions register under the canonical `'global'` + // key (#3913) — the literal every reader probes + // (`actionHandlerObjectKeys`), since `executeAction` is an + // exact-string Map lookup with no wildcard semantics. + const objectKey = + typeof action.object === 'string' && action.object.length > 0 + ? action.object + : GLOBAL_ACTION_OBJECT_KEY; + try { + ql.registerAction(objectKey, action.name, handler, owner); + out.actions++; + } catch (err: any) { + logger.warn(`${tag} Failed to register action body`, { + appId, + action: action.name, + object: objectKey, + error: err?.message ?? String(err), + }); + } + } + } + if (out.actions > 0) { + logger.info(`${tag} Bound declarative actions`, { appId, actionCount: out.actions }); + } + } catch (err: any) { + logger.error(`${tag} Failed to bind declarative actions`, err as Error, { appId }); + } + + return out; +} diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index b114e045dfe..3e5ecf8c854 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -30,7 +30,7 @@ import { readServiceSelfInfo } from '@objectstack/spec/api'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; -import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; +import { bindAppArtifactHandlers } from './app-artifact-handlers.js'; import { toBoundaryJobSchedule } from './job-schedule.js'; import type { JobHandlerContext } from './job-handler-context.js'; import { countServerTiming, SEMCONV } from '@objectstack/observability'; @@ -1087,110 +1087,24 @@ export class AppPlugin implements Plugin { ctx.logger.debug('No runtime.onEnable function found', { appId }); } - // ── Auto-bind declarative Hook metadata ───────────────────────── - // Hooks declared via `defineStack({ hooks })` (or attached to the - // bundle by other tooling) are wired into the ObjectQL execution - // pipeline here, with no boilerplate from user code. Inline - // function handlers are resolved directly; string-named handlers - // are looked up in `bundle.functions` (also auto-registered) or in - // any function previously registered on the engine. + // ── Auto-bind declarative Hook + Action handlers ──────────────── + // Hooks declared via `defineStack({ hooks })` (inline function handlers, + // string-named `bundle.functions`, or a sandboxed `body`) and actions + // carrying an extracted `body` are wired into the ObjectQL engine here, + // with no boilerplate from user code, so `POST /api/v1/actions//`, + // MCP `run_action` and the record pipeline run them. // - // Runs AFTER `runtime.onEnable` so user code may still - // imperatively register additional hooks/functions for advanced - // cases — both will coexist on the engine. - try { - const hooks = collectBundleHooks(this.bundle); - // Entries, not bare handlers: each function's declared `effect` - // (#4396) rides along to the registry, where a `script` node reads - // it to report what its run actually did. - const functions = collectBundleFunctionEntries(this.bundle); - for (const [name, fn] of Object.entries(functions)) { - if (fn.unrecognizedEffect === undefined) continue; - ctx.logger.warn('[AppPlugin] unrecognized function effect — counted as an uncountable write', { - appId, - name, - effect: fn.unrecognizedEffect, - expected: "'pure' | 'writes'", - }); - } - if (hooks.length > 0 || Object.keys(functions).length > 0) { - if (typeof ql.bindHooks === 'function') { - ql.bindHooks(hooks, { - packageId: `app:${appId}`, - functions, - bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { - ql, - logger: ctx.logger, - appId, - }), - }); - ctx.logger.info('[AppPlugin] Bound declarative hooks', { - appId, - hookCount: hooks.length, - functionCount: Object.keys(functions).length, - }); - } else { - ctx.logger.warn('[AppPlugin] ql.bindHooks unavailable; declarative hooks ignored', { - appId, - hookCount: hooks.length, - }); - } - } - } catch (err: any) { - ctx.logger.error('[AppPlugin] Failed to bind declarative hooks', err as Error, { - appId, - }); - } - - // ── Auto-register declarative Action handlers ─────────────────── - // Actions with an inline `handler` (or extracted `body`) are wired - // to the engine here so HTTP `POST /api/v1/actions//` - // can invoke them. Actions without a body are left for legacy - // imperative `engine.registerAction(...)` registration in user code. - try { - const actions = collectBundleActions(this.bundle); - const actionBodyRunner = actionBodyRunnerFactory(new QuickJSScriptRunner(), { - ql, - logger: ctx.logger, - appId, - }); - let registered = 0; - if (actions.length > 0 && typeof ql.registerAction === 'function') { - for (const action of actions) { - const handler = actionBodyRunner(action); - if (!handler) continue; - // Object-less actions register under the canonical - // `'global'` key (#3913) — the literal every reader probes - // (`actionHandlerObjectKeys`), since `executeAction` is an - // exact-string Map lookup with no wildcard semantics. - const objectKey = - typeof action.object === 'string' && action.object.length > 0 - ? action.object - : GLOBAL_ACTION_OBJECT_KEY; - try { - ql.registerAction(objectKey, action.name, handler, `app:${appId}`); - registered++; - } catch (err: any) { - ctx.logger.warn('[AppPlugin] Failed to register action body', { - appId, - action: action.name, - object: objectKey, - error: err?.message ?? String(err), - }); - } - } - } - if (registered > 0) { - ctx.logger.info('[AppPlugin] Bound declarative actions', { - appId, - actionCount: registered, - }); - } - } catch (err: any) { - ctx.logger.error('[AppPlugin] Failed to bind declarative actions', err as Error, { - appId, - }); - } + // [#21321] Through `bindAppArtifactHandlers` — the ONE binder, which the + // install-local plugin also calls for an installed package on install + // and on rehydrate. This block used to BE that loop, which made + // `AppPlugin.start` the only path that ever bound an artifact's + // handlers. See `./app-artifact-handlers.ts` for the contract. + // + // Runs AFTER `runtime.onEnable` so user code may still imperatively + // register additional hooks/functions/actions for advanced cases — both + // coexist on the engine (the binder only replaces what it owns, + // `app:`). + bindAppArtifactHandlers(ql, this.bundle, { appId, logger: ctx.logger, source: 'AppPlugin' }); // [ADR-0110 D5] The action-governance inventory used to hang off a // `kernel:ready` hook HERE. Moved to ObjectQLPlugin: AppPlugin is diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index dae5ade89b6..efb29cdf8ca 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -693,10 +693,27 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon // flow action while promising "its input parameters". const automation: any = await actionExec.resolveAutomationService(deps, context, envId); const hasAutomation = Boolean(automation); + // [#21321] ONE source for advertising and running. `run_action` + // dispatches a script action through the engine's handler registry + // (`executeRegisteredAction`), so the listing asks that registry — + // the same engine, the same key rotation — instead of trusting the + // declaration's `target || body`. A declared action nothing bound + // (measured: an `os package install`ed package) was listed here and + // refused there with "No handler registered". + const hasHandler = actionExec.registeredActionHandlerProbe( + deps, + await deps.getObjectQL(context, envId).catch(() => undefined), + ); const out: any[] = []; for (const { action, objectName, obj } of await actionExec.collectActionDeclarations(deps, meta)) { if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* if (!actionExec.isHeadlessInvokableAction(deps, action, hasAutomation)) continue; + // The script arm — every action `invokeBusinessAction` sends to + // the handler registry, i.e. neither the declarative update nor + // a flow (its branch order, read the same way). The other two + // arms have their own dispatchers and keep their own predicates. + if (!actionExec.isDeclarativeUpdateAction(action) && action?.type !== 'flow' + && !hasHandler(objectName, actionExec.resolveActionHandlerKeys(action))) continue; // [#2849 / ADR-0011] MCP is an AI surface: only actions the // author explicitly opted in via `ai.exposed` are listed. // Fail-closed — bodies run as trusted code (see diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index fd510488f2f..c359cf2b20a 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -4310,6 +4310,13 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () }); const ql: any = { executeAction, + // [#21321] The engine's enumeration of the handlers `executeAction` + // answers above — `list_actions` advertises a script action only when + // one of its handler keys is registered here. + listRegisteredActions: () => [ + { objectName: 'todo_task', actionName: 'completeTask' }, + { objectName: 'todo_task', actionName: 'issueLicense' }, + ], registry: { getObject: (n: string) => (n === 'todo_task' ? todoObject : null) }, insert: vi.fn(), update: vi.fn(), @@ -4585,6 +4592,12 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () }); const ql: any = { executeAction, + // [#21321] The handlers `executeAction` answers above, enumerated. + listRegisteredActions: () => [ + { objectName: 'todo_task', actionName: 'archive_task' }, + { objectName: 'global', actionName: 'nightly_cleanup' }, + { objectName: 'todo_task', actionName: 'completeTask' }, + ], registry: { getObject: (n: string) => (n === 'todo_task' ? todoObject : null) }, insert: vi.fn(), update: vi.fn(), delete: vi.fn(), find: vi.fn(async () => []), diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7b32f459bab..9cdee7f7ce7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -59,6 +59,11 @@ export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-rec export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; +// [#21321] The ONE binder of an app artifact's script-action bodies and body +// hooks, under the owner `app:` — called by `AppPlugin.start` and by the +// install-local plugin (`@objectstack/cloud-connection`) on install and rehydrate. +export { bindAppArtifactHandlers, appArtifactHandlerOwner } from './app-artifact-handlers.js'; +export type { AppArtifactHandlerBinding, AppArtifactHandlerBindingOptions } from './app-artifact-handlers.js'; // #14094 — what a DECLARATIVE job's handler is invoked with. A job has no graph, // so unlike a flow `script` node it is given data reach (`ql`) instead of being a // pure value-returner whose I/O the surrounding graph performs. diff --git a/packages/runtime/src/mcp-list-actions-handler-probe.test.ts b/packages/runtime/src/mcp-list-actions-handler-probe.test.ts new file mode 100644 index 00000000000..24da9b66972 --- /dev/null +++ b/packages/runtime/src/mcp-list-actions-handler-probe.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21321 — MCP `list_actions` advertises a `script` action only when a handler + * is registered for it: ONE source for advertising and for running. + * + * The run doors (MCP `run_action`, REST `/actions`) dispatch a script action + * through `executeRegisteredAction`, which rotates the action's handler-key + * candidates over the engine's handler Map. The listing used to admit a script + * action on DECLARATION presence (`target || body`), so a declaration nothing + * had bound — measured: an `os package install`ed package — was advertised by + * `list_actions` and refused by `run_action` ("No handler registered"), the + * NEG1 failure of the `ai.mcp-run-action-exposure-gate` checklist item. + * + * The listing now asks `registeredActionHandlerProbe`, which reads the engine's + * public `listRegisteredActions()` and walks the SAME rotation + * (`actionHandlerObjectKeys` × `resolveActionHandlerKeys`) the run door walks. + * Each case below pairs the listing with the run door on the same engine, so + * the pin is the agreement, not either half alone. The engine double keeps ONE + * Map and answers both `executeAction` and `listRegisteredActions` from it. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; + +const exposed = (description: string) => ({ exposed: true, description }); + +/** Bound by its declarative name (the body shape `AppPlugin` registers). */ +const BODY_ACTION = { + name: 'complete_task', label: 'Complete', objectName: 'tasks_app_task', type: 'script', + body: { language: 'js', source: 'return { ok: true };' }, ai: exposed('Mark a task complete.'), +}; +/** Bound by its `target` (the shape user code registers imperatively). */ +const TARGET_ACTION = { + name: 'reopen_task', label: 'Reopen', objectName: 'tasks_app_task', type: 'script', + target: 'reopenTask', ai: exposed('Reopen a task.'), +}; +/** A flow action — dispatched by the automation service, never the handler Map. */ +const FLOW_ACTION = { + name: 'escalate_task', label: 'Escalate', objectName: 'tasks_app_task', type: 'flow', + target: 'escalate_flow', ai: exposed('Escalate a task.'), +}; + +function makeBridge(registered: Array<[object: string, key: string]>, opts: { enumerable?: boolean } = {}) { + const object = { + name: 'tasks_app_task', label: 'Task', fields: {}, + actions: [BODY_ACTION, TARGET_ACTION, FLOW_ACTION], + }; + const handlers = new Map unknown>(); + for (const [obj, key] of registered) handlers.set(`${obj}:${key}`, () => ({ ran: `${obj}:${key}` })); + const ql: any = { + registry: { getObject: () => object }, + find: vi.fn(async () => [{ id: 'r1' }]), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + executeAction: vi.fn(async (obj: string, key: string, ctx: any) => { + const h = handlers.get(`${obj}:${key}`); + if (!h) throw new Error(`Action '${key}' on object '${obj}' not found`); + return h(ctx); + }), + ...(opts.enumerable === false ? {} : { + listRegisteredActions: () => [...handlers.keys()].map((k) => ({ + objectName: k.slice(0, k.indexOf(':')), actionName: k.slice(k.indexOf(':') + 1), + })), + }), + }; + const metadata: any = { + listObjects: vi.fn(async () => [object]), + getObject: vi.fn(async () => object), + }; + const automation = { execute: vi.fn(async () => ({ success: true })) }; + const kernel: any = { + context: { + getService: (n: string) => + n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? automation : null, + }, + }; + const ctx: any = { request: {}, environmentId: 'platform', executionContext: { userId: 'u1', systemPermissions: [] } }; + return (new HttpDispatcher(kernel) as any).buildMcpBridge(ctx); +} + +const listedNames = async (bridge: any): Promise => (await bridge.listActions()).map((a: any) => a.name); + +describe('#21321: list_actions advertises a script action only when run_action can run it', () => { + it('THE DEFECT — a declared body action with no registered handler is not advertised, and run_action refuses it', async () => { + const bridge = makeBridge([]); + expect(await listedNames(bridge), 'advertised an action the run door cannot dispatch').not.toContain('complete_task'); + await expect(bridge.runAction('complete_task', { recordId: 'r1' })).rejects.toThrow(/No handler registered/); + }); + + it('CONTROL — the same action, bound by name, is advertised and runs', async () => { + const bridge = makeBridge([['tasks_app_task', 'complete_task']]); + expect(await listedNames(bridge)).toContain('complete_task'); + await expect(bridge.runAction('complete_task', { recordId: 'r1' })).resolves.toMatchObject({ ok: true }); + }); + + it('the probe walks the run door’s key candidates — a target-bound handler counts, its bare name does not', async () => { + expect(await listedNames(makeBridge([['tasks_app_task', 'reopenTask']]))).toContain('reopen_task'); + // Registered under a key no candidate derives: the run door misses it, so the listing must too. + const stray = makeBridge([['tasks_app_task', 'reopenTaskV2']]); + expect(await listedNames(stray)).not.toContain('reopen_task'); + await expect(stray.runAction('reopen_task', { recordId: 'r1' })).rejects.toThrow(/No handler registered/); + }); + + it('the probe walks the run door’s OBJECT rotation — a handler on the object-less key counts', async () => { + const bridge = makeBridge([['global', 'complete_task']]); + expect(await listedNames(bridge)).toContain('complete_task'); + await expect(bridge.runAction('complete_task', { recordId: 'r1' })).resolves.toMatchObject({ ok: true }); + }); + + it('CONTROL — a flow action is not gated on the handler Map (its dispatcher is the automation service)', async () => { + expect(await listedNames(makeBridge([]))).toContain('escalate_task'); + }); + + it('an engine that cannot enumerate its handlers advertises no script action — the listing cannot vouch for one', async () => { + const names = await listedNames(makeBridge([['tasks_app_task', 'complete_task']], { enumerable: false })); + expect(names).not.toContain('complete_task'); + expect(names).toContain('escalate_task'); + }); +}); From f7e9fddbbf7b36ca72ecf4519ec499b0370cb269 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:30:52 +0000 Subject: [PATCH 2/4] test(runtime,cloud-connection): pin the shared binder on a real engine; install-local suites pay the runtime load at module top Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../marketplace-install-local-bundle.test.ts | 5 + ...marketplace-install-local-conflict.test.ts | 5 + .../marketplace-install-local-id-gate.test.ts | 5 + ...etplace-install-local-list-posture.test.ts | 5 + ...-install-local-offline-degradation.test.ts | 5 + ...etplace-install-local-posture-gate.test.ts | 5 + ...ketplace-install-local-storage-dir.test.ts | 5 + .../runtime/src/app-artifact-handlers.test.ts | 116 ++++++++++++++++++ 8 files changed, 151 insertions(+) create mode 100644 packages/runtime/src/app-artifact-handlers.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts b/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts index ef9aeb1855f..1393485a905 100644 --- a/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts @@ -12,6 +12,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts b/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts index 19938e091a1..3d3eede0f9f 100644 --- a/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts @@ -14,6 +14,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-id-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-id-gate.test.ts index 8ac225be33a..1f4c32622d6 100644 --- a/packages/cloud-connection/src/marketplace-install-local-id-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-id-gate.test.ts @@ -38,6 +38,11 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { manifestIdRefusal } from '@objectstack/spec/kernel'; import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource } from './local-manifest-source.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts index cbb95b03884..4a6006b39e7 100644 --- a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -62,6 +62,11 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource } from './local-manifest-source.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts b/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts index 07c147aee0f..9b6dbf7b440 100644 --- a/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts @@ -28,6 +28,11 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts index 9bcb3f656c2..e04011f204c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts @@ -47,6 +47,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts index 0b335425477..67fc751a455 100644 --- a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts @@ -27,6 +27,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +// [#21321] An install, and a rehydrate of a ledger entry, now bind the package's +// handlers through `@objectstack/runtime` (a lazy `import()` inside the plugin). +// Its first load is paid here, at module top — never inside a clocked `it` +// (the clocked-window rule, `scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js'; diff --git a/packages/runtime/src/app-artifact-handlers.test.ts b/packages/runtime/src/app-artifact-handlers.test.ts new file mode 100644 index 00000000000..ec1b5959aad --- /dev/null +++ b/packages/runtime/src/app-artifact-handlers.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21321 — `bindAppArtifactHandlers`, the ONE binder of an app artifact's + * script-action bodies and body hooks, on a REAL `ObjectQL` engine and the + * real QuickJS sandbox. + * + * Its two callers (`AppPlugin.start`, and the install-local plugin's install + * and rehydrate) are pinned where they live; this file pins the contract they + * share: + * + * - a body action becomes an `executeAction` handler under `app:`, + * and running it runs the body; + * - a body hook fires through the engine's own `triggerHooks`; + * - re-binding the same artifact leaves exactly one handler per action and + * one binding per hook (a reinstall); + * - re-binding an artifact that DROPPED its action and its hook unbinds both + * — including the hook, which `bindHooksToEngine` alone would keep firing + * because it unregisters only when handed a non-empty list; + * - another owner's handlers are never touched. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { bindAppArtifactHandlers, appArtifactHandlerOwner } from './app-artifact-handlers.js'; + +const APP_ID = 'com.example.tasksapp'; +const OWNER = appArtifactHandlerOwner(APP_ID); + +const quiet = { debug() {}, info() {}, warn() {}, error() {} }; + +const ACTION = { + name: 'complete_task', + objectName: 'tasks_app_task', + type: 'script', + body: { language: 'js', source: 'return { ran: true, id: ctx.recordId };' }, +}; +const HOOK = { + name: 'tasks_app_stamp_status', + object: 'tasks_app_task', + events: ['beforeInsert'], + // Appends, so a hook bound twice is visible on the input. + body: { language: 'js', source: "ctx.input.status = (typeof ctx.input.status === 'string' ? ctx.input.status : '') + 'stamped';" }, +}; + +/** The compiled-artifact shape: meta under `manifest`, the action both standalone and object-embedded. */ +function artifact(opts: { withHandlers: boolean }) { + return { + manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, + objects: [{ name: 'tasks_app_task', fields: {}, ...(opts.withHandlers ? { actions: [ACTION] } : {}) }], + ...(opts.withHandlers ? { actions: [ACTION], hooks: [HOOK] } : {}), + }; +} + +const owned = (ql: ObjectQL, owner = OWNER) => + ql.listRegisteredActions().filter((r) => r.package === owner).map((r) => `${r.objectName}:${r.actionName}`); + +async function insertStatus(ql: ObjectQL): Promise { + const ctx: any = { object: 'tasks_app_task', event: 'beforeInsert', input: { name: 'probe' }, session: {} }; + await ql.triggerHooks('beforeInsert', ctx); + return ctx.input.status; +} + +describe('#21321: bindAppArtifactHandlers', () => { + it('binds the body action under app: — executeAction runs the body', async () => { + const ql = new ObjectQL({ logger: quiet } as any); + const bound = bindAppArtifactHandlers(ql as any, artifact({ withHandlers: true }), { appId: APP_ID, logger: quiet }); + + expect(bound.owner).toBe(OWNER); + expect(owned(ql)).toEqual(['tasks_app_task:complete_task']); + await expect(ql.executeAction('tasks_app_task', 'complete_task', { recordId: 'r1', params: {} })) + .resolves.toEqual({ ran: true, id: 'r1' }); + }); + + it('binds the body hook — the engine’s own triggerHooks runs it', async () => { + const ql = new ObjectQL({ logger: quiet } as any); + expect(await insertStatus(ql), 'precondition: nothing bound yet').toBeUndefined(); + + bindAppArtifactHandlers(ql as any, artifact({ withHandlers: true }), { appId: APP_ID, logger: quiet }); + + expect(await insertStatus(ql)).toBe('stamped'); + }); + + it('re-binding the same artifact keeps exactly one handler per action and one binding per hook', async () => { + const ql = new ObjectQL({ logger: quiet } as any); + bindAppArtifactHandlers(ql as any, artifact({ withHandlers: true }), { appId: APP_ID, logger: quiet }); + bindAppArtifactHandlers(ql as any, artifact({ withHandlers: true }), { appId: APP_ID, logger: quiet }); + + expect(owned(ql)).toEqual(['tasks_app_task:complete_task']); + expect(await insertStatus(ql), 'stampedstamped = the hook is bound twice').toBe('stamped'); + }); + + it('re-binding an artifact that dropped its action and hook unbinds both', async () => { + const ql = new ObjectQL({ logger: quiet } as any); + bindAppArtifactHandlers(ql as any, artifact({ withHandlers: true }), { appId: APP_ID, logger: quiet }); + expect(owned(ql)).toHaveLength(1); + + const bound = bindAppArtifactHandlers(ql as any, artifact({ withHandlers: false }), { appId: APP_ID, logger: quiet }); + + expect(bound).toMatchObject({ hooks: 0, actions: 0 }); + expect(owned(ql), 'an action the new version dropped must stop running').toEqual([]); + expect(await insertStatus(ql), 'a hook the new version dropped must stop firing').toBeUndefined(); + }); + + it('never touches another owner’s handlers', async () => { + const ql = new ObjectQL({ logger: quiet } as any); + ql.registerAction('tasks_app_task', 'imperative_task', () => ({ mine: true })); + ql.registerAction('tasks_app_task', 'authored_task', () => ({ authored: true }), 'metadata-service'); + + bindAppArtifactHandlers(ql as any, artifact({ withHandlers: true }), { appId: APP_ID, logger: quiet }); + bindAppArtifactHandlers(ql as any, artifact({ withHandlers: false }), { appId: APP_ID, logger: quiet }); + + await expect(ql.executeAction('tasks_app_task', 'imperative_task', {})).resolves.toEqual({ mine: true }); + expect(owned(ql, 'metadata-service')).toEqual(['tasks_app_task:authored_task']); + }); +}); From cf2484a9e6efcd66add7cba974bea1bcac243b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:40:04 +0000 Subject: [PATCH 3/4] chore(changeset): runtime minor (new binder export, list_actions reads the handler registry), cloud-connection patch Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../21321-install-local-binds-artifact-handlers.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/21321-install-local-binds-artifact-handlers.md diff --git a/.changeset/21321-install-local-binds-artifact-handlers.md b/.changeset/21321-install-local-binds-artifact-handlers.md new file mode 100644 index 00000000000..26cac0aa683 --- /dev/null +++ b/.changeset/21321-install-local-binds-artifact-handlers.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': minor +'@objectstack/cloud-connection': patch +--- + +An app installed with `os package install ` now runs its `type: 'script'` action bodies and its body hooks, and MCP `list_actions` lists a script action only when `run_action` can run it (#21321). + +Clause-②: yes (widening) + +- **`@objectstack/runtime`.** New export `bindAppArtifactHandlers(ql, bundle, { appId, logger, source? })`. It binds every action `body` of an artifact through `ql.registerAction`, and every hook `body` and bundle function through `ql.bindHooks`, all under the owner `app:`. `appArtifactHandlerOwner(appId)` returns that owner key. Each call first removes the action handlers and hooks the same owner bound before. A reinstall therefore leaves one handler per action, and an action or hook that the new version dropped stops running. `AppPlugin.start` now binds through this function, with the same log lines and the same results for a boot artifact. +- **`@objectstack/runtime`, MCP `list_actions`.** A `script` action is listed only when the engine has a handler registered for it. The check reads `listRegisteredActions()` and uses the same object and key order as `run_action`. Before, a declared `target` or `body` was enough to be listed, so `list_actions` could list an action that `run_action` refused with "No handler registered". An engine without `listRegisteredActions` gets no script actions listed. Declarative update actions and `flow` actions are listed as before. +- **`@objectstack/cloud-connection`.** The install-local plugin calls `bindAppArtifactHandlers` on `POST /api/v1/marketplace/install-local` and when it rehydrates its ledger at `kernel:ready`. Before, an installed package's script actions answered REST `404 RESOURCE_NOT_FOUND` and MCP "No handler registered", before and after a restart, and its body hooks never ran. The same artifact booted with `os start --artifact` was not affected. From 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:19:04 +0000 Subject: [PATCH 4/4] test(cli): the install-local handler pin spawns the tsx source entry, signs in as the dev admin, and attributes every exchange to the child Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...install-local-handlers.integration.test.ts | 154 +++++++++++------- 1 file changed, 96 insertions(+), 58 deletions(-) diff --git a/packages/cli/test/package-install-local-handlers.integration.test.ts b/packages/cli/test/package-install-local-handlers.integration.test.ts index 975581eb3cf..eae5645676d 100644 --- a/packages/cli/test/package-install-local-handlers.integration.test.ts +++ b/packages/cli/test/package-install-local-handlers.integration.test.ts @@ -38,42 +38,50 @@ * * ## Spawn shape * - * `bin/run.js` with `NODE_ENV` unset (hence `requireBuiltCli`) — the operator's - * entrypoint, and the one whose bind is deterministic (no development - * auto-shift). Every workspace package the child loads, `@objectstack/runtime` - * and `@objectstack/cloud-connection` included, resolves through its `exports` - * to `dist/`: an ablation of either package's source reaches this file only - * after that package is rebuilt. Each `os start` gets its own process group and - * is stopped by signalling the group (`os start` supervises a `serve` - * grandchild). + * The tsx source entry (`bin/run-dev.js`), as every `runServe()` caller spawns + * it, for `os start` and for `os package install` alike. It pins + * `NODE_ENV=development`, which lets `serve` auto-shift off a port taken + * between the probe and the bind, so the ready banner is read back + * (`portDriftError`) before any request is addressed to the port. The CLI runs + * from `src/`, but every workspace package it loads — `@objectstack/runtime` + * and `@objectstack/cloud-connection` included — resolves through its + * `exports` to `dist/`: an ablation of either package's source reaches this + * file only after that package is rebuilt. Each `os start` gets its own + * process group and is stopped by signalling the group (`os start` supervises + * a `serve` grandchild). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import { + CLI, childEnv, E2E_SECRET_KEY, portContentionError, + portDriftError, + probeThroughChild, randomPort, - requireBuiltCli, - RUN_JS_RESOLVES_FROM_DIST, + TSX, } from './helpers/serve-process.js'; -const HERE = dirname(fileURLToPath(import.meta.url)); -const RUN_JS = resolve(HERE, '../bin/run.js'); - /** The banner's tail — every row above it has printed. */ const READY = /Press Ctrl\+C to stop/; const BOOT_TIMEOUT_MS = 180_000; const APP_ID = 'com.example.tasksapp'; const OBJECT = 'tasks_app_task'; -const EMAIL = 'owner@example.com'; -const PASSWORD = 'Passw0rd!Passw0rd'; +/** + * The development dev-admin seed (`objectstack dev`'s documented, loginable + * admin — `admin@objectos.ai` / `admin123`, promoted to platform admin): the + * tsx entry runs `os start` in development, which seeds it on every boot that + * finds no login, so it is the operator on all three boots and holds the + * `manage_metadata` capability the install route demands. + */ +const EMAIL = 'admin@objectos.ai'; +const PASSWORD = 'admin123'; const COMPLETE_TASK = { name: 'complete_task', @@ -136,11 +144,10 @@ interface LiveStart { function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(process.execPath, [RUN_JS, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, - // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV` - // unset: the built entrypoint resolves commands from dist/ (#11464). - env: childEnv({ NODE_ENV: undefined, NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), + // `childEnv`, never a bare `...process.env` — see its header. + env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), stdio: ['ignore', 'pipe', 'pipe'], // Own process group: `os start` supervises a `serve` grandchild. detached: true, @@ -161,7 +168,8 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - if (READY.test(out)) settle(null); + // The child is the authority on the port it bound. + if (READY.test(out)) settle(portDriftError(out, 'os start', port)); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); @@ -186,27 +194,45 @@ async function stopGroup(child: ChildProcess): Promise { interface Answer { status: number; body: any } -async function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise { - const r = await fetch(`${live.base}${path}`, { - method, - headers: { - origin: live.base, - ...(body !== undefined ? { 'content-type': 'application/json' } : {}), - ...(token ? { authorization: `Bearer ${token}` } : {}), +/** + * One exchange against the running `os start`, attributed to the child if the + * transport fails (`probeThroughChild`: a dead child is named with its + * transcript; a socket the live server dropped — the keep-alive connection an + * idle stretch outlives — is absorbed and retried, loudly, a bounded number of + * times). ⛔ No assertion inside it. + */ +function exchange(live: LiveStart, what: string, run: () => Promise): Promise { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'package-install-local-handlers', + what, }, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + run, + ); +} + +function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise { + return exchange(live, `${method} ${path}`, async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; }); - const text = await r.text(); - let parsed: any = text; - try { parsed = JSON.parse(text); } catch { /* keep the text */ } - return { status: r.status, body: parsed }; } -async function authenticate(live: LiveStart, firstUser: boolean): Promise { - const res = await http( - live, 'POST', firstUser ? '/api/v1/auth/sign-up/email' : '/api/v1/auth/sign-in/email', '', - firstUser ? { email: EMAIL, password: PASSWORD, name: 'Owner' } : { email: EMAIL, password: PASSWORD }, - ); +async function authenticate(live: LiveStart): Promise { + const res = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); const token = res.body?.token; if (res.status !== 200 || typeof token !== 'string') { throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`); @@ -214,24 +240,37 @@ async function authenticate(live: LiveStart, firstUser: boolean): Promise { + return new Promise((done) => { + const child = spawn(TSX, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + cwd: appDir, + env: childEnv({ NO_COLOR: '1' }), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.on('data', (d) => { output += String(d); }); + child.stderr?.on('data', (d) => { output += String(d); }); + const timer = setTimeout(() => child.kill('SIGKILL'), 120_000); + child.on('close', (code) => { clearTimeout(timer); done({ exit: code, output }); }); }); - return { exit: r.status, output: `${r.stdout}\n${r.stderr}` }; } /** One MCP JSON-RPC call over Streamable HTTP; the tool's JSON text, parsed. */ async function mcpTool(live: LiveStart, apiKey: string, name: string, args: Record) { - const r = await fetch(`${live.base}/api/v1/mcp`, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': apiKey }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), + const text = await exchange(live, `MCP tools/call ${name}`, async () => { + const r = await fetch(`${live.base}/api/v1/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': apiKey }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), + }); + return r.text(); }); - const text = await r.text(); const data = text.split('\n').find((l) => l.startsWith('data:')); const envelope = JSON.parse(data ? data.slice(5) : text); const content = envelope?.result?.content?.[0]?.text; @@ -279,7 +318,6 @@ const phases: Record<'install' | 'reinstall' | 'restart' | 'control', Phase | un const installs: Array<{ exit: number | null; output: string }> = []; beforeAll(async () => { - requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); const root = mkdtempSync(join(tmpdir(), 'install-local-handlers-')); dirs.push(root); const appDir = join(root, 'app'); @@ -293,23 +331,23 @@ beforeAll(async () => { // ── boot 1: empty `os start`, install, probe, reinstall, probe ───────── const first = await bootStart(runtimeDir, home, port); - const token = await authenticate(first, true); - installs.push(packageInstall(appDir, first)); + const token = await authenticate(first); + installs.push(await packageInstall(appDir, first)); phases.install = await probe(first, token); - installs.push(packageInstall(appDir, first)); + installs.push(await packageInstall(appDir, first)); phases.reinstall = await probe(first, token); await stopGroup(first.child); // ── boot 2: same home and cwd — the ledger rehydrates on kernel:ready ── const second = await bootStart(runtimeDir, home, port); - phases.restart = await probe(second, await authenticate(second, false)); + phases.restart = await probe(second, await authenticate(second)); await stopGroup(second.child); // ── boot 3: the CONTROL — the same file as the boot artifact ─────────── const controlDir = join(root, 'control'); mkdirSync(controlDir, { recursive: true }); const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]); - phases.control = await probe(third, await authenticate(third, true)); + phases.control = await probe(third, await authenticate(third)); await stopGroup(third.child); }, 4 * BOOT_TIMEOUT_MS);