Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ app/config/settings.json
app/config/mcp_config.json
app/config/skills_config.json
app/config/scheduler_config.json
# Mini Browser's Chromium profile: cookies and signed-in sessions, plus its
# lock file (its password vault is in .credentials, above).
**/mini_browser_profile
**/mini_browser_profile.lock

# Per-install agent state
app/data/.usage
Expand Down
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ OMNIPARSER_BASE_URL=

# Google (/google login)
GOOGLE_CLIENT_ID=
# Optional Figma OAuth app credentials. PAT connection works without these.
# Public apps need Figma approval; do not commit real client secrets.
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
GOOGLE_CLIENT_SECRET=

# LinkedIn (/linkedin login)
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/windows-shell.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Windows shell regressions

on:
push:
paths:
- 'app/data/action/run_shell.py'
- 'app/data/action/run_powershell.py'
- 'tests/windows_shell/**'
- '.github/workflows/windows-shell.yml'
pull_request:
paths:
- 'app/data/action/run_shell.py'
- 'app/data/action/run_powershell.py'
- 'tests/windows_shell/**'
- '.github/workflows/windows-shell.yml'

jobs:
shell:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Verify real cmd and Windows PowerShell execution
run: python -m unittest discover -s tests/windows_shell -v
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ agent_bundle/
**/.craftbot/
app/data/.file_index/
.playwright-mcp
**/test-results/
**/playwright-report/
# Sidecar Node runtime (install.py downloads it when the system Node is too old for Agent App)
runtime/

Expand All @@ -85,3 +87,8 @@ npm-cache/
playwright-browsers/

hf-cache/

# Mini Browser's persistent Chromium profiles, one per chat (cookies,
# signed-in sessions), and their cross-process lock files. Its password
# vault lives in .credentials/ (ignored above). Never commit.
**/mini_browser_profile*
14 changes: 1 addition & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ python craftbot.py uninstall # Stop, remove auto-start, and uninstall packages
**Agent App is a system/app/dashboard that evolves with your needs.**

<div align="center">
<img src="assets/agent_app_banner.gif" alt="CraftBot Banner" width="1280"/>
<img src="assets/agent-app-showcase.gif" alt="CraftBot Banner" width="1280"/>
</div>

- Need a kanban board with an AI co-pilot built in?
Expand All @@ -105,23 +105,11 @@ Spin it up as a Agent App that runs alongside CraftBot and grows as your needs c
scaffolds the data model, backend API, and React UI, then iterates with
you through a structured design process.

<div align="center">
<img src="assets/agent-app-custom-build.png" alt="Building a Agent App from scratch" width="448"/>
</div>

2. **Install from the marketplace.** Browse community-built Agent Apps from [living-ui-marketplace](https://github.com/CraftOS-dev/living-ui-marketplace).

<div align="center">
<img src="assets/living-ui-marketplace.png" alt="Agent App marketplace" width="448"/>
</div>

3. **Import an existing project.** Point CraftBot at a Go, Node.js, Python,
Rust, or static source code or github repo. It detects the runtime, configures health checks, and wraps it as a Agent App.

<div align="center">
<img src="assets/agent-app-import.png" alt="Importing an existing project as a Agent App" width="448"/>
</div>

### Keeps evolving with CraftBot inside the loop

A Agent App is never "finished." Ask the agent to add features, redesign
Expand Down
14 changes: 12 additions & 2 deletions agent-app/blueprint/pb/pb_hooks/_craftbot_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*
* const bridge = require(`${__hooks}/_craftbot_bridge.js`);
* const text = bridge.callLLM('Summarize: ...', 'You are terse.');
* const obj = JSON.parse(bridge.callLLM(prompt, system, { json: true }) || 'null');
* const res = bridge.callAction('send_gmail', { to, subject, body });
* const raw = bridge.callIntegration('slack', 'POST', '/chat.postMessage', { ... });
*
Expand All @@ -14,15 +15,24 @@
* real paths/payloads there). All no-op gracefully outside CraftBot.
*/

function callLLM(prompt, systemMessage) {
/**
* options.json: the provider's JSON mode constrains the reply to one JSON
* object (the prompt must still describe the object's shape). Use it whenever
* the hook JSON.parse()s the reply, never for prose.
*/
function callLLM(prompt, systemMessage, options) {
try {
const bridge = $os.getenv('CRAFTBOT_BRIDGE_URL');
const token = $os.getenv('CRAFTBOT_BRIDGE_TOKEN');
if (!bridge || !token) return '';
const res = $http.send({
url: bridge + '/api/bridge/llm',
method: 'POST',
body: JSON.stringify({ prompt: prompt, system_message: systemMessage || '' }),
body: JSON.stringify({
prompt: prompt,
system_message: systemMessage || '',
json_mode: !!(options && options.json),
}),
headers: {
'content-type': 'application/json',
authorization: 'Bearer ' + token,
Expand Down
3 changes: 3 additions & 0 deletions agent-app/docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ system module `pb/pb_hooks/_craftbot_bridge.js` — no API keys in the app:
```js
const bridge = require(`${__hooks}/_craftbot_bridge.js`);
const summary = bridge.callLLM('Summarize:\n' + text, 'Reply in one sentence.');
// Parsing the reply? Ask for the provider's JSON mode instead of scraping prose:
const notes = JSON.parse(bridge.callLLM(prompt, 'Reply with one JSON object.', { json: true }) || 'null');
const res = bridge.callIntegration('slack', 'POST', '/chat.postMessage', { ... });
```

Expand Down Expand Up @@ -175,6 +177,7 @@ Use the CLI — it resolves the port, authenticates, and validates params:

```
agent-app ops <project> # what can this app do?
agent-app ops <project> <area> # one area's ops, every param explained
agent-app run <project> <op> --param value # execute a declared op
agent-app data <project> <collection> list --filter '...' --limit 20
agent-app data <project> <collection> create --json '{...}'
Expand Down
8 changes: 6 additions & 2 deletions agent-app/tools/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,23 @@ async function bootstrapSuperuser(projectDir: string): Promise<void> {
const email = 'agent@agent-app.local';
const password = randomBytes(18).toString('base64url');

// Flags first, then `--`, then email and password: a base64url password
// can start with "-", which PocketBase's CLI would otherwise parse as an
// unknown flag (it then saves nothing and still exits 0).
execFileSync(
pbBin,
[
'superuser',
'upsert',
email,
password,
'--dir',
join(pbDir, 'pb_data'),
'--migrationsDir',
join(pbDir, 'pb_migrations'),
'--hooksDir',
join(pbDir, 'pb_hooks'),
'--',
email,
password,
],
{ stdio: 'pipe' },
);
Expand Down
30 changes: 24 additions & 6 deletions agent-app/tools/src/commands/ops.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,49 @@
/**
* agent-app ops <project-dir> — the app's declared verb surface (spec O1/O4).
* The agent-facing capability card: what this app can DO.
* agent-app ops <project-dir> [area]: the app's declared verb surface (spec O1/O4).
* The agent-facing capability card: what this app can DO. With an area (an op
* name or the part before its last dot, e.g. `notes` or `notes.create`) it lists
* only those ops, each param with its description, so an agent can look up
* one area of a large app without reading every op.
*/
import { log } from '../lib/log.ts';
import { loadOps, loadProject } from '../lib/project.ts';

export async function run(args: string[]): Promise<number> {
const dirArg = args.find((a) => !a.startsWith('--'));
const [dirArg, area] = args.filter((a) => !a.startsWith('--'));
if (dirArg === undefined) {
log.error('Usage: agent-app ops <project-dir>');
log.error('Usage: agent-app ops <project-dir> [area]');
return 1;
}
const project = loadProject(dirArg);
const ops = loadOps(project);
const all = loadOps(project);
const ops = area === undefined ? all : all.filter((o) => o.name === area || o.name.startsWith(area + '.'));
if (ops.length === 0) {
const areas = [...new Set(all.map((o) => o.name.slice(0, Math.max(o.name.lastIndexOf('.'), 0)) || o.name))];
log.error(`No op matches "${area}". Areas: ${areas.join(', ')}`);
return 1;
}

log.raw(`${project.name} (${project.id}) — ${project.baseUrl}\n`);
for (const op of ops) {
const params = Object.entries(op.params ?? {})
.map(([k, v]) => `--${k} <${v.type}>${v.required ? '' : '?'}`)
.map(([k, v]) => `--${k} <${v.enum ? v.enum.join('|') : v.type}>${v.required ? '' : '?'}`)
.join(' ');
const flags = [op.system ? 'system' : '', op.destructive ? 'DESTRUCTIVE' : '']
.filter(Boolean)
.join(', ');
log.raw(` ${op.name}${params ? ' ' + params : ''}${flags ? ` [${flags}]` : ''}`);
log.raw(` ${op.description}`);
if (area !== undefined) {
for (const [k, v] of Object.entries(op.params ?? {})) {
if (v.description) log.raw(` --${k}: ${v.description}`);
}
}
}
log.raw(`\nRun one: agent-app run ${dirArg} <op-name> [--param value ...]`);
const example = ops.find((o) => !o.system)?.name;
if (area === undefined && example !== undefined) {
log.raw(`Details: agent-app ops ${dirArg} <area> (each param explained, e.g. ${example.slice(0, example.lastIndexOf('.'))})`);
}
log.raw(`Data: agent-app data ${dirArg} <collection> [list|get <id>|create|update <id>|delete <id>] [--json '{...}'] [--filter '...']`);
return 0;
}
26 changes: 18 additions & 8 deletions agent-app/tools/src/lib/os-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,31 @@ export class OSAdapter {
this.arch = arch === 'arm64' ? 'arm64' : 'amd64';
}

/** Per-user cache root of the agent-app tooling. */
private cacheRoot(): string {
return {
darwin: join(homedir(), 'Library', 'Caches', 'craftos-agent-app'),
linux: join(homedir(), '.cache', 'craftos-agent-app'),
win32: join(process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local'), 'craftos-agent-app'),
}[this.platform];
}

/** Central per-host PocketBase binary cache, versioned (spec B1). */
pbCacheDir(version: string): string {
const override = process.env['AGENT_APP_PB_CACHE'];
const base =
override ??
{
darwin: join(homedir(), 'Library', 'Caches', 'craftos-agent-app', 'pb'),
linux: join(homedir(), '.cache', 'craftos-agent-app', 'pb'),
win32: join(process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local'), 'craftos-agent-app', 'pb'),
}[this.platform];
const base = process.env['AGENT_APP_PB_CACHE'] ?? join(this.cacheRoot(), 'pb');
const dir = join(base, version);
mkdirSync(dir, { recursive: true });
return dir;
}

/** Where the CLI keeps each app's superuser session between commands. Kept
* out of the project folder so it never travels with an exported app. */
cliSessionDir(): string {
const dir = join(this.cacheRoot(), 'cli-sessions');
mkdirSync(dir, { recursive: true });
return dir;
}

/** GitHub release asset name for the pinned version. */
pbAssetName(version: string): string {
const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[this.platform];
Expand Down
75 changes: 69 additions & 6 deletions agent-app/tools/src/lib/project.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/** Operate-command helpers: resolve a project, its port, ops, and auth. */
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';

import { osAdapter } from './os-adapter.ts';

export interface ProjectRef {
dir: string;
name: string;
Expand Down Expand Up @@ -84,7 +86,7 @@ export interface Operation {
description: string;
system?: boolean;
destructive?: boolean;
params?: Record<string, { type: string; description?: string; required?: boolean }>;
params?: Record<string, { type: string; description?: string; required?: boolean; enum?: (string | number)[] }>;
executor: { type: string; method?: string; path?: string; collection?: string; action?: string };
}

Expand All @@ -104,17 +106,72 @@ export function readAgentToken(project: ProjectRef): string | null {
}

/** Superuser token via the project-local .superuser file (absent on imports). */
export async function authToken(project: ProjectRef): Promise<string | null> {
/**
* Superuser session for projects that have a `.superuser` file. A failed
* sign-in is an error, never a silent fallback: carrying on unauthenticated
* turned protected reads into a bare "not found", which agents took to mean
* "already handled by someone else" (IP Manager, 2026-09-29).
*/
export async function authToken(project: ProjectRef, fresh = false): Promise<string | null> {
const credFile = join(project.dir, '.superuser');
if (!existsSync(credFile)) return null;
if (!fresh) {
const cached = cachedSession(project);
if (cached !== null) return cached;
}
const { email, password } = JSON.parse(readFileSync(credFile, 'utf8'));
const res = await fetch(`${project.baseUrl}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) return null;
return ((await res.json()) as { token: string }).token;
if (res.status === 429) {
throw new Error(
`Could not sign in to ${project.name}: PocketBase rate-limits sign-ins (HTTP 429, too many in the ` +
`last minute), so nothing was read or changed. Wait a minute, then retry.`,
);
}
if (!res.ok) {
throw new Error(
`Could not sign in to ${project.name} as its superuser (HTTP ${res.status}): the credentials in ` +
`.superuser do not match the app's database, so nothing was read or changed. ` +
`Relaunch the app from CraftBot to re-apply them, then retry.`,
);
}
const token = ((await res.json()) as { token: string }).token;
saveSession(project, token);
return token;
}

/**
* The superuser session is cached per app instance (id + port, so a shadow
* keeps its own) until shortly before it expires. Every CLI command is a new
* process, so without the cache each one signed in again, and PocketBase
* allows 30 sign-ins a minute: an agent working through a batch (filing 30
* SOPs, say) started failing mid-way.
*/
function sessionFile(project: ProjectRef): string {
return join(osAdapter.cliSessionDir(), `${project.id || 'app'}-${project.port}.json`);
}

function cachedSession(project: ProjectRef): string | null {
try {
const { token } = JSON.parse(readFileSync(sessionFile(project), 'utf8')) as { token: string };
const payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) as {
exp?: number;
};
return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now() + 60_000 ? token : null;
} catch {
return null;
}
}

function saveSession(project: ProjectRef, token: string): void {
try {
writeFileSync(sessionFile(project), JSON.stringify({ token }), { mode: 0o600 });
} catch {
/* no cache: the next command signs in again */
}
}

export async function request(
Expand Down Expand Up @@ -151,6 +208,12 @@ export async function request(
if (extraHeaders !== undefined) Object.assign(headers, extraHeaders);
const init: RequestInit = { method, headers };
if (body !== undefined) init.body = JSON.stringify(body);
const res = await fetch(`${project.baseUrl}${path}`, init);
let res = await fetch(`${project.baseUrl}${path}`, init);
if (res.status === 401 && token !== null) {
// A cached session the app no longer accepts (app data reset, secret
// rotated): sign in once more and repeat. A 401 means nothing was done.
headers['Authorization'] = (await authToken(project, true)) ?? '';
res = await fetch(`${project.baseUrl}${path}`, init);
}
return { status: res.status, body: await res.text() };
}
9 changes: 7 additions & 2 deletions agent_core/core/action_framework/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,13 @@ def list_all_actions_as_json(self) -> List[Dict[str, Any]]:
return json_actions_list

def find_action_by_name(self, action_name: str) -> Optional[Dict[str, Any]]:
"""Find an action by name and return its JSON representation."""
if action_name not in self._registry:
"""Find an action by name and return its JSON representation.

An action with no implementation for this OS does not exist here.
Action sets already leave it out of the prompt; this keeps a name the
model produces anyway from resolving to another OS's code.
"""
if self.get_action_implementation(action_name) is None:
return None

platform_impls = self._registry[action_name]
Expand Down
Loading
Loading