feat(apps): Rowboat Apps M1 — per-app origins, Host API core, apps skill

Implements M1 of the Apps V1 spec, replacing the mini-apps prototype:

- rowboat-app.json manifest + AppSummary/install/publish/registry schemas;
  sourceApp on background tasks
- apps server on 127.0.0.1:3210 (absorbs local-sites): Host-header routing to
  <slug>.apps.localhost origins, 421 rebinding guard, static from dist/ with
  entry/SPA fallback + realpath confinement, control host /health, chokidar
  watcher + per-app SSE with dist/data areas, embed-opt-in autosize bootstrap
  and cancelable rowboat:data-change event
- Host API: D17 anti-CSRF (X-Rowboat-App + strict Origin on non-GET),
  GET /_rowboat/app (manifest + theme), /_rowboat/events, data API under
  data/ with atomic writes and dataContracts enforcement
- indexer: list (ok/invalid surfaced) / create scaffold / delete
- IPC: apps:list/get/create/delete/setTheme; renderer reports theme live
- renderer: Apps home (My apps grid + catalog placeholder, server banner,
  invalid/kind badges) and full-height app view on the app origin
- apps copilot skill (replaces build-mini-app); app-set-data builtin replaces
  mini-app-install/set-data; deleted the app://miniapp protocol, postMessage
  bridge, mini-apps handler/channels, and local-sites
This commit is contained in:
Gagan 2026-07-04 17:12:54 +05:30
parent 2c038fe518
commit 7f15f67273
24 changed files with 1567 additions and 2359 deletions

View file

@ -33,6 +33,9 @@ export type BackgroundTask = {
projectId?: string;
model?: string;
provider?: string;
// Folder slug of the Rowboat App that installed this task (spec §8.2).
// Runtime-managed; tasks with sourceApp are owned by the app lifecycle.
sourceApp?: string;
createdAt: string;
// Runtime-managed — never hand-write. Mirrors live-note's flat-field
// pattern: `lastAttemptAt` is bumped at every run start (backoff anchor),
@ -53,6 +56,7 @@ export type BackgroundTaskSummary = {
active: boolean;
triggers?: Triggers;
projectId?: string;
sourceApp?: string;
createdAt: string;
lastAttemptAt?: string;
lastRunId?: string;
@ -71,6 +75,7 @@ export const BackgroundTaskSchema = z.object({
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
sourceApp: z.string().optional().describe('Folder slug of the app that installed this task. Runtime-managed.'),
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the bg-task:stop handler.'),
@ -86,6 +91,7 @@ export const BackgroundTaskSummarySchema = z.object({
active: z.boolean(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
sourceApp: z.string().optional(),
createdAt: z.string(),
lastAttemptAt: z.string().optional(),
lastRunId: z.string().optional(),

View file

@ -19,5 +19,5 @@ export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as notificationSettings from './notification-settings.js';
export * as codeSessions from './code-sessions.js';
export * as miniApp from './mini-app.js';
export * as rowboatApp from './rowboat-app.js';
export { PrefixLogger };

View file

@ -16,7 +16,7 @@ import {
import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
import { MiniAppManifest } from './mini-app.js';
import { AppSummarySchema } from './rowboat-app.js';
import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
@ -1008,60 +1008,34 @@ const ipcSchemas = {
shouldShow: z.boolean(),
}),
},
// Mini Apps: seed built-in apps to ~/.rowboat/apps/<id>/ (idempotent).
'mini-apps:seed': {
req: z.object({
apps: z.array(z.object({
manifest: MiniAppManifest,
html: z.string(),
data: z.unknown().optional(),
})),
}),
// Rowboat Apps (spec §13) — M1 local channels.
'apps:list': {
req: z.object({}),
res: z.object({
seeded: z.array(z.string()),
serverRunning: z.boolean(),
serverError: z.string().optional(),
apps: z.array(AppSummarySchema),
}),
},
// Mini Apps: list installed app manifests from ~/.rowboat/apps/.
'mini-apps:list': {
req: z.null(),
'apps:get': {
req: z.object({ folder: z.string() }),
res: z.object({
manifests: z.array(MiniAppManifest),
app: AppSummarySchema,
readme: z.string().optional(),
rollbackAvailable: z.boolean(),
}),
},
// Mini Apps: read an app's latest data.json (agent output).
'mini-apps:get-data': {
req: z.object({ id: z.string() }),
res: z.object({ data: z.unknown().nullable() }),
'apps:create': {
req: z.object({ folder: z.string(), name: z.string(), description: z.string() }),
res: z.object({ app: AppSummarySchema }),
},
// Mini Apps: event pushed main→renderer when an app's data.json changes on
// disk (agent refresh), so an open app can reload its data live.
'mini-apps:dataChanged': {
req: z.object({ id: z.string() }),
res: z.null(),
'apps:delete': {
req: z.object({ folder: z.string() }),
res: z.object({ ok: z.literal(true) }),
},
// Mini Apps: event pushed main→renderer when an app is installed/updated
// (manifest or index.html changed), so the gallery re-lists and an open app
// reloads once the install settles.
'mini-apps:appsChanged': {
req: z.object({ id: z.string() }),
res: z.null(),
},
// Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the
// sandboxed app origin). GET/POST to http(s) only.
'mini-apps:fetch': {
req: z.object({
url: z.string(),
method: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
}),
res: z.object({
ok: z.boolean(),
status: z.number(),
statusText: z.string(),
text: z.string(),
error: z.string().optional(),
}),
'apps:setTheme': {
req: z.object({ theme: z.enum(['light', 'dark']) }),
res: z.object({ ok: z.literal(true) }),
},
'composio:didConnect': {
req: z.object({

View file

@ -1,42 +0,0 @@
import { z } from 'zod';
/**
* Manifest for a Mini App stored at ~/.rowboat/apps/<id>/manifest.json.
*
* Static assets are served from `<id>/dist/` via app://miniapp/<id>/. The
* optional `agent` links a background task (bg-tasks engine) that writes
* `<id>/data.json`, which the host reads and pushes to the app.
*/
export const MiniAppManifest = z.object({
/** Stable slug; also the on-disk folder name and the app:// host path. */
id: z.string(),
/** Display name shown on the card and in the open view. */
title: z.string(),
/** One-line description for the card. */
description: z.string().default(''),
/** Primary integration shown in the card footer pill (e.g. 'GitHub'). */
source: z.string().default(''),
/** Composio toolkits this app may use; enforced host-side on bridge calls. */
scope: z.array(z.string()).default([]),
/** Whether the app's agent is active (drives the status badge). */
active: z.boolean().default(true),
/** Human last-run label for the card footer (e.g. '2m ago'). */
lastRun: z.string().default(''),
/** Entry file within the app folder, served via app://miniapp/<id>/. */
entry: z.string().default('dist/index.html'),
/** Optional associated background-task slug that produces data.json. */
agent: z.string().optional(),
/**
* Optional guard on what may be written to data.json (via mini-app-set-data).
* Prevents a stray/legacy task from corrupting the app with the wrong shape or
* wiping good series with empty ones.
*/
dataContract: z.object({
/** Top-level keys that must be present and non-null. */
requiredKeys: z.array(z.string()).default([]),
/** Top-level keys that must be non-empty arrays. */
nonEmptyArrayKeys: z.array(z.string()).default([]),
}).optional(),
});
export type MiniAppManifest = z.infer<typeof MiniAppManifest>;

View file

@ -0,0 +1,113 @@
import { z } from 'zod';
// Rowboat Apps schemas (spec §4.2, §5.1, §9.1, §11.4, §12.2).
export const PACKAGE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
export const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
// ---------------------------------------------------------------------------
// Manifest — rowboat-app.json (§4.2)
// ---------------------------------------------------------------------------
export const RowboatAppManifestSchema = z.object({
schemaVersion: z.literal(1),
name: z.string().min(3).max(64).regex(PACKAGE_NAME_RE)
.describe('Package identity. Globally unique and immutable once published.'),
version: z.string().regex(SEMVER_RE)
.describe('Strict semver, no prerelease/build suffix in V1.'),
description: z.string().max(500).default(''),
icon: z.string().optional()
.describe('Path relative to dist/. Same traversal rules as entry.'),
entry: z.string().default('index.html')
.describe('Path relative to dist/. Serves as app root and SPA fallback.'),
agents: z.array(z.string().regex(/^[a-z0-9][a-z0-9-_]*\.yaml$/)).default([])
.describe('Filenames under agents/. Each must exist in the package.'),
capabilities: z.array(z.string()).default([])
.describe('Capability identifiers this app may use (D7): Composio toolkit slugs for /_rowboat/tools/*, plus the reserved identifiers "llm" (§7.6) and "copilot" (§7.7). Empty = none.'),
dataContracts: z.array(z.object({
file: z.string(), // path relative to data/, e.g. "data.json"
requiredKeys: z.array(z.string()).default([]),
nonEmptyArrayKeys: z.array(z.string()).default([]),
})).default([])
.describe('Write guards for specific data/ files: required top-level keys and keys that must stay non-empty arrays. Enforced on writes (§7.3, §8.6) so a buggy agent run cannot corrupt the app\'s data shape or wipe good series with empties.'),
// RESERVED — validated if present, ignored by V1 runtime:
build: z.object({ command: z.string() }).optional()
.describe('RESERVED. Rowboat MUST NOT execute this in V1.'),
minRowboatVersion: z.string().regex(SEMVER_RE).optional()
.describe('RESERVED. Minimum compatible Rowboat version; not enforced in V1.'),
}).passthrough(); // unknown fields survive round-trips (forward compatibility)
export type RowboatAppManifest = z.infer<typeof RowboatAppManifestSchema>;
// ---------------------------------------------------------------------------
// Install record — .rowboat-install.json (§12.2)
// ---------------------------------------------------------------------------
export const AppInstallRecordSchema = z.object({
name: z.string(),
repo: z.string().optional(), // owner/repo at install time; absent only for non-GitHub URL installs (§12.5)
sourceUrl: z.string().optional(), // present only for §12.5 URL installs
version: z.string(),
sha256: z.string(), // bundle checksum, pinned at install
installedAt: z.string(),
updatedAt: z.string().optional(),
files: z.record(z.string(), z.string()), // relpath → sha256 of release-managed files
previousVersion: z.string().optional(), // set while .previous/ exists
});
export type AppInstallRecord = z.infer<typeof AppInstallRecordSchema>;
// ---------------------------------------------------------------------------
// Publish record — .rowboat-publish.json (§11.4)
// ---------------------------------------------------------------------------
export const AppPublishRecordSchema = z.object({
name: z.string(),
login: z.string(), // publisher GitHub login
repo: z.string(), // owner/repo
lastPublishedVersion: z.string().optional(),
lastSha256: z.string().optional(),
pendingSteps: z.object({ // present only mid-publish (resume state)
version: z.string(),
completed: z.array(z.string()), // step names from §11.2
releaseId: z.number().optional(),
prUrl: z.string().optional(),
}).optional(),
});
export type AppPublishRecord = z.infer<typeof AppPublishRecordSchema>;
// ---------------------------------------------------------------------------
// App summary — apps:list / apps:get (§5.1)
// ---------------------------------------------------------------------------
export const AppSummarySchema = z.object({
folder: z.string(), // folder slug
status: z.enum(['ok', 'invalid']),
manifest: RowboatAppManifestSchema.optional(), // present when ok
manifestError: z.string().optional(), // present when invalid
origin: z.string(), // http://<folder>.apps.localhost:3210
kind: z.enum(['local', 'installed']),
install: AppInstallRecordSchema.optional(), // §12.2
publish: AppPublishRecordSchema.optional(), // §11.4
hasDist: z.boolean(),
agentSlugs: z.array(z.string()), // materialized bg-task slugs (§8.3)
});
export type AppSummary = z.infer<typeof AppSummarySchema>;
// ---------------------------------------------------------------------------
// Registry record — apps/<name>.json in the registry repo (§9.1)
// ---------------------------------------------------------------------------
export const RegistryRecordSchema = z.object({
schemaVersion: z.literal(1),
name: z.string().min(3).max(64).regex(PACKAGE_NAME_RE),
owner: z.string().min(1), // GitHub login of the publisher
repo: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), // "owner/repo"
description: z.string().max(500).default(''),
iconUrl: z.string().url().optional(), // https URL for the catalog listing icon
createdAt: z.string(), // ISO 8601
}).strict();
export type RegistryRecord = z.infer<typeof RegistryRecordSchema>;