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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/grokbot-official-sideload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Sideload the plugin into Grok Bot's synced marketplace on `install grokbot` when Grok Bot agent-data is found: add the plugin folder and a `marketplace.json` entry to the one active `scriptedalchemy/plugins` clone, and write a `.cache-complete` copy under `plugins/cache/scriptedalchemy-plugins`. Folders and entries the installer did not write are never touched, and every write is atomic. Every written path is recorded in the install receipt, and `uninstall grokbot` removes exactly those paths. Change or turn off the target with `--sideload-repo`, `--sideload-slug`, or `--no-sideload` (or `GROK_BOT_SIDELOAD_REPO`, `GROK_BOT_SIDELOAD_SLUG`, `GROK_BOT_SIDELOAD=0`). `GROK_BOT_SIDELOAD_MARKETPLACE` is still read but must name one repository. The next install or uninstall retires the first sideload's `agent-bundle/sideload/<plugin>.json` record under `~/.grokbot` (or `GROK_BOT_HOME`). `doctor` reports the sideload state as AB7335. (#876)
7 changes: 5 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ even when no error diagnostic was reported.
| `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). |
| `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). |
| `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7101` is also the one-shot `build` / `build()` refusal when source changes during compilation; `AB7103`: the development package build; see below). |
| `AB7300`–`AB7334` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), the retired `AB7332` (see below), dangling receipt-owned marketplaces (`AB7333`; see below), and the opt-in Grok Bot report (`AB7334`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). |
| `AB7300`–`AB7335` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), the retired `AB7332` (see below), dangling receipt-owned marketplaces (`AB7333`; see below), and the opt-in Grok Bot report (`AB7334`–`AB7335`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). |
| `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. |
| `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). |
| `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). |
Expand Down Expand Up @@ -1271,11 +1271,14 @@ together with the server-assigned plugin id from `<agent-data>/plugin-skills/cac
(`GROK_BOT_AGENT_DATA_DIR`, then `/home/box/agent-data`, `~/.grokbot/agent-data`,
and the macOS application-support root). It never touches the account-level
install. A plugin that contributes no skill has no skill-index row, so its id reads
as not assigned even when the cache copy exists.
as not assigned even when the cache copy exists. A cache copy `install grokbot`
sideloaded is not reported as an install until Grok Bot's index names it; the
sideload has its own report (`AB7335`).

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB7334` | info / warning | With `--from`: the bundle's Grok Bot state, not installed (and whether a staged marketplace awaits hosting), or installed with each copy's plugin id, manifest version, installed commit, and marketplace; warning when every installed copy has a different version than the bundle. Host the staged marketplace and install the plugin from Grok Bot's Marketplace; for a version warning push the rebuilt marketplace and update the plugin in Grok Bot. |
| `AB7335` | info / warning | A grokbot receipt records a sideload (`install grokbot` wrote the plugin into Grok Bot's marketplace clone, its manifest, and its plugin cache). Info when Grok Bot's plugin index names the sideloaded cache copy (loaded), or when every part is present but not loaded: Grok Bot loads only plugins its account plugin listing returns, and its next plugin sync removes the cache copy otherwise. Warning when a part is gone: the cache copy (removed by that sync), or the clone folder and manifest entry (removed when Grok Bot moves the marketplace to a new commit). Rerun `agent-bundle install grokbot` to restore it; publish the plugin to the marketplace and install it from Grok Bot for a durable install. |

## Read-only runtime identity introspection (`AB7317`–`AB7318`)

Expand Down
19 changes: 19 additions & 0 deletions packages/agent-bundle/src/install/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ interface InstallCommandOptions {
readonly replace?: boolean;
readonly mode?: InstallMode;
readonly scope: string;
/** Commander sets `true` unless `--no-sideload` is given. */
readonly sideload?: boolean;
readonly sideloadRepo?: string;
readonly sideloadSlug?: string;
}

interface UninstallCommandOptions {
Expand Down Expand Up @@ -110,6 +114,18 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo
'same-version content drift is replaced automatically and foreign installs are always refused',
)
.option('--mode <mode>', 'Cursor delivery mode: local (default) or marketplace', installMode)
.option(
'--no-sideload',
'grokbot: skip sideloading into Grok Bot\'s marketplace clone and plugin cache (also GROK_BOT_SIDELOAD=0)',
)
.option(
'--sideload-repo <owner/repo>',
'grokbot: GitHub marketplace whose Grok Bot clone receives the sideload (default scriptedalchemy/plugins; GROK_BOT_SIDELOAD_REPO)',
)
.option(
'--sideload-slug <slug>',
'grokbot: Grok Bot plugin-cache partition for that marketplace (default <owner>-<repo>; GROK_BOT_SIDELOAD_SLUG)',
)
.option('--json', 'Write one machine-readable JSON document');
installCommand.action(async (host: InstallHost, commandOptions: InstallCommandOptions) => {
const { installBundle: install } = await lifecycle();
Expand All @@ -119,6 +135,9 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo
replace: commandOptions.replace === true,
...(commandOptions.mode === undefined ? {} : { mode: commandOptions.mode }),
scope: installScope(commandOptions.scope),
...(commandOptions.sideload === false ? { sideload: false } : {}),
...(commandOptions.sideloadRepo === undefined ? {} : { sideloadRepo: commandOptions.sideloadRepo }),
...(commandOptions.sideloadSlug === undefined ? {} : { sideloadSlug: commandOptions.sideloadSlug }),
});
await (commandOptions.json === true ? machine(result) : show(formatInstallResult(result)));
});
Expand Down
83 changes: 80 additions & 3 deletions packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type InstalledTreeComparison,
type InstalledTreeOwnership,
type InstallReceipt,
type InstallReceiptGrokBotSideload,
type InstallReceiptMode,
type InstallReceiptScope,
type InstallRegistration,
Expand All @@ -75,6 +76,7 @@ import {
} from './cursor-hooks-registration.ts';
import { cursorMarketplaceName, cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts';
import { grokBotMarketplaceRoot, grokBotRoot, readGrokBotInventory, type GrokBotInventory } from './grokbot.ts';
import { type GrokBotSideloadInspection, inspectGrokBotSideload } from './grokbot-sideload.ts';
import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts';
import { inspectInstalledStateOwnership, resolveInstalledStateRoots } from './state-root.ts';

Expand Down Expand Up @@ -202,6 +204,16 @@ export interface DoctorLifecycle {
* host no longer lists the plugin or the staged repository is gone), or
* `unknown` (the host inventory was unusable).
*/
/** grokbot receipts: what `install grokbot` sideloaded and which parts Grok Bot still has (`AB7335`). */
export interface DoctorGrokBotSideload extends GrokBotSideloadInspection {
readonly cachePath: string;
readonly commit: string;
readonly manifest: string;
readonly pluginPath: string;
readonly repo: string;
readonly slug: string;
}

export interface DoctorReceiptFinding {
readonly contentHash: string;
readonly format: string;
Expand All @@ -211,6 +223,7 @@ export interface DoctorReceiptFinding {
readonly plugin: string;
readonly registrations: readonly InstallRegistration[];
readonly scope: InstallReceiptScope;
readonly sideload?: DoctorGrokBotSideload;
readonly state: 'consistent' | 'orphaned' | 'unknown';
readonly updatedAt: string;
readonly version: string;
Expand Down Expand Up @@ -2769,6 +2782,48 @@ const doctorHost = async (
});
};

/** `AB7335`: which parts of a receipted Grok Bot sideload remain, and whether Grok Bot loaded it. */
const grokBotSideloadDiagnostic = (plugin: string, sideload: DoctorGrokBotSideload): Diagnostic => {
const where = `${sideload.repo} @ ${sideload.commit}, cache ${sideload.slug}`;
const missing = [
...(sideload.cacheCopy ? [] : [`the cache copy ${sideload.cachePath} (removed by Grok Bot's plugin sync)`]),
...(sideload.cloneActive
? [
...(sideload.pluginFolder ? [] : [`the clone folder ${sideload.pluginPath}`]),
...(sideload.listed ? [] : [`the entry in ${sideload.manifest}`]),
]
: [`the clone @ ${sideload.commit} (Grok Bot moved the marketplace to a new commit and deleted it)`]),
];
if (missing.length > 0) {
return diagnostic(
'AB7335',
`${plugin} sideload on grokbot: incomplete (${where}); missing ${missing.join(', ')}` +
`${sideload.loaded ? ", though Grok Bot's plugin index still names the cache copy" : ''}.`,
'Rerun `agent-bundle install grokbot` to restore the sideload in the clone Grok Bot is using now.',
'warning',
'grokbot',
);
}
return sideload.loaded
? diagnostic(
'AB7335',
`${plugin} sideload on grokbot: loaded — Grok Bot's plugin index names the cache copy ${sideload.cachePath} (${where}).`,
'No action needed.',
'info',
'grokbot',
)
: diagnostic(
'AB7335',
`${plugin} sideload on grokbot: written (${where}), not loaded. Grok Bot loads only plugins its account plugin listing ` +
`returns; its next plugin sync (startup, sign-in change, or every 24 hours) removes the cache copy unless the account ` +
`lists ${plugin} from ${sideload.slug}.`,
`For a durable install, publish ${plugin} to ${sideload.repo} and install it from Grok Bot's Marketplace; rerun ` +
'`agent-bundle install grokbot` to restore a pruned sideload.',
'info',
'grokbot',
);
};

/**
* Read-only Grok Bot report: the staged-marketplace receipts Agent Bundle wrote under `~/.grokbot`, and every
* completed copy of the plugin in the Grok Bot computer's plugin cache with the server-assigned plugin id and
Expand All @@ -2778,9 +2833,31 @@ const doctorGrokBot = async (options: DoctorOptions, home: string): Promise<Doct
const environment = options.environment ?? process.env;
const root = grokBotRoot({ environment, home });
const diagnostics: Diagnostic[] = [];
const receipts = await inspectStoreReceipts('grokbot', root, async (receipt) =>
receipt.mode === 'marketplace' && await exists(join(grokBotMarketplaceRoot(root), receipt.plugin)) ? 'consistent' : 'orphaned');
const sideloads = new Map<string, InstallReceiptGrokBotSideload>();
const receipts = await inspectStoreReceipts('grokbot', root, async (receipt) => {
if (receipt.grokBotSideload !== undefined) sideloads.set(receipt.plugin, receipt.grokBotSideload);
return receipt.mode === 'marketplace' && await exists(join(grokBotMarketplaceRoot(root), receipt.plugin)) ? 'consistent' : 'orphaned';
});
diagnostics.push(...receipts.diagnostics);
const receiptFindings: DoctorReceiptFinding[] = [];
for (const finding of receipts.receipts) {
const record = sideloads.get(finding.plugin);
if (record === undefined) {
receiptFindings.push(finding);
continue;
}
const sideload: DoctorGrokBotSideload = Object.freeze({
...await inspectGrokBotSideload(record, finding.plugin),
cachePath: record.cachePath,
commit: record.commit,
manifest: record.manifest,
pluginPath: record.pluginPath,
repo: record.repo,
slug: record.slug,
});
receiptFindings.push(Object.freeze({ ...finding, sideload }));
diagnostics.push(grokBotSideloadDiagnostic(finding.plugin, sideload));
}
let identity: PluginIdentity | undefined;
let bundleError: unknown;
if (options.from !== undefined) {
Expand Down Expand Up @@ -2853,7 +2930,7 @@ const doctorGrokBot = async (options: DoctorOptions, home: string): Promise<Doct
host: 'grokbot',
inventory: freezeInventory(available ? 'known' : 'unknown', findings),
probe: Object.freeze(available ? { evidence: 'directory' as const, status: 'available' as const } : { status: 'unavailable' as const }),
receipts: receipts.receipts,
receipts: Object.freeze(receiptFindings),
...(bundle === undefined ? {} : { bundle }),
});
};
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bundle/src/install/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export const formatInstallResult = (result: InstallResult): string => {
if (result.marketplace !== undefined && (result.host === 'cursor' || result.host === 'grokbot')) {
lines.push(`Marketplace: ${result.marketplace}${result.commit === undefined ? '' : ` @ ${result.commit}`}`);
}
if (result.sideload !== undefined) {
lines.push(result.sideload.state === 'skipped'
? `Grok Bot sideload: skipped — ${result.sideload.reason}`
: `Grok Bot sideload: ${result.sideload.state} (${result.sideload.repo} @ ${result.sideload.commit}, cache ${result.sideload.slug})`);
}
if (result.nextSteps !== undefined && result.nextSteps.length > 0) {
lines.push('Next steps:');
lines.push(...result.nextSteps.map((step, index) => ` ${index + 1}. ${step}`));
Expand Down
74 changes: 74 additions & 0 deletions packages/agent-bundle/src/install/grokbot-agent-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';

import { isErrno } from '../core/errors.ts';

/**
* Read-only access to a Grok Bot computer's `agent-data` directory, shared by the grokbot installer, its
* sideload into Grok Bot's marketplace clone, Doctor, and uninstall.
*/

/** Where a Grok Bot computer keeps its data, most specific first (`GROK_BOT_AGENT_DATA_DIR` overrides). */
export const grokBotAgentDataCandidates = (
environment: Readonly<NodeJS.ProcessEnv>,
home: string,
): readonly string[] => Object.freeze([
...(environment['GROK_BOT_AGENT_DATA_DIR'] === undefined || environment['GROK_BOT_AGENT_DATA_DIR'] === ''
? []
: [environment['GROK_BOT_AGENT_DATA_DIR']]),
'/home/box/agent-data',
join(home, '.grokbot', 'agent-data'),
join(home, 'Library', 'Application Support', 'Grok Bot', 'agent-data'),
// Absolute and normalized (no `..`, no trailing separator): receipts record paths derived from it verbatim.
].map((candidate) => resolve(candidate)));

export const directoryExists = async (path: string): Promise<boolean> => {
try {
return (await stat(path)).isDirectory();
} catch (error) {
if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES')) return false;
throw error;
}
};

/** The first candidate that holds a `plugins` directory, or undefined when this computer has no Grok Bot data. */
export const findGrokBotAgentData = async (
environment: Readonly<NodeJS.ProcessEnv>,
home: string,
): Promise<string | undefined> => {
for (const candidate of grokBotAgentDataCandidates(environment, home)) {
if (await directoryExists(join(candidate, 'plugins'))) return candidate;
}
return undefined;
};

export const readJsonFile = async (path: string): Promise<unknown> => {
try {
return JSON.parse(await readFile(path, 'utf8')) as unknown;
} catch (error) {
if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES') || error instanceof SyntaxError) {
return undefined;
}
throw error;
}
};

export const listDirectory = async (path: string): Promise<readonly string[]> => {
try {
return (await readdir(path)).sort();
} catch (error) {
if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES')) return [];
throw error;
}
};

/**
* `<marketplace>/<plugin>/<version>` of a path inside `plugins/cache`. Grok Bot's indexes may record the legacy
* `sand-data` root, so only the cache-relative tail identifies a copy.
*/
export const pluginCacheKey = (installPath: string): string | undefined => {
const segments = installPath.split(/[\\/]/u).filter((segment) => segment !== '');
const cache = segments.lastIndexOf('cache');
if (cache < 1 || segments[cache - 1] !== 'plugins' || segments.length !== cache + 4) return undefined;
return segments.slice(cache + 1).join('/');
};
Loading
Loading