feat(nemoclaw): add hindsight-nemoclaw setup CLI package (#630)

* feat(nemoclaw): add hindsight-nemoclaw setup CLI package

Automates the full NemoClaw sandbox setup:
- Installs @vectorize-io/hindsight-openclaw plugin
- Configures external API mode in ~/.openclaw/openclaw.json
- Reads current openshell sandbox policy, merges Hindsight egress rule, re-applies
- Restarts the OpenClaw gateway

Options: --dry-run, --skip-policy, --skip-plugin-install
36 unit tests passing

* docs: add NEMOCLAW.md setup guide

* feat(nemoclaw): add README, docs page, and release pipeline

* revert: remove release.yml changes from nemoclaw PR
This commit is contained in:
Ben 2026-03-20 10:16:55 -04:00 committed by GitHub
parent 93609f74ab
commit d284de28c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2929 additions and 0 deletions

View file

@ -0,0 +1,246 @@
---
sidebar_position: 5
---
# NemoClaw
Persistent memory for [NemoClaw](https://nemoclaw.ai) sandboxed agents using [Hindsight](https://hindsight.vectorize.io).
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with controlled filesystem, process, and network egress policies. The `hindsight-nemoclaw` package automates adding Hindsight memory to a sandbox in one command — no code changes required.
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
You'll see output like:
```
[0] Preflight checks...
✓ openshell found
✓ openclaw found
[1] Installing @vectorize-io/hindsight-openclaw plugin...
✓ Plugin installed
[2] Configuring plugin in ~/.openclaw/openclaw.json...
✓ Plugin config written (bank: my-sandbox-openclaw)
[3] Applying Hindsight network policy to sandbox "my-assistant"...
✓ Policy version 2 submitted
✓ Policy version 2 loaded (active version: 2)
[4] Restarting OpenClaw gateway...
✓ Gateway restarted
✓ Setup complete!
```
## How It Works
### The sandbox problem
OpenShell enforces strict network egress — every outbound endpoint must be explicitly permitted in the sandbox policy. By default, the Hindsight API (`api.hindsight.vectorize.io`) is not in that list.
The `hindsight-openclaw` plugin supports **external API mode**, where it skips the local daemon entirely and makes direct HTTPS calls to Hindsight Cloud. This is the natural fit for sandboxed environments: the plugin becomes a thin HTTP client, and the only sandbox change needed is one egress rule.
### What the setup command does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads the current sandbox policy, merges the Hindsight egress block, and re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
### Memory flow
Once set up, the `hindsight-openclaw` plugin hooks into the OpenClaw gateway lifecycle:
- **`before_agent_start`** — recalls relevant memories from past sessions and injects them into context
- **`agent_end`** — retains the conversation to the Hindsight memory bank
The sandbox doesn't interfere with either step — it sees the Hindsight calls as normal HTTPS egress to a permitted endpoint.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
Use `--dry-run` to preview all changes before applying anything. Use `--skip-policy` if you manage sandbox policies manually.
## Manual Setup
If you prefer to apply the steps yourself instead of using the CLI:
### 1. Install the plugin
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### 2. Configure `~/.openclaw/openclaw.json`
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
`llmProvider: "claude-code"` uses the Claude Code process already present in the sandbox — no additional API key needed.
### 3. Add the Hindsight network policy
`openshell policy set` replaces the entire policy document. Export your current policy first, add the Hindsight block, then re-apply:
```yaml
network_policies:
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
```bash
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
openclaw gateway restart
```
## Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
| `hindsightApiUrl` | string | — | Hindsight API base URL |
| `hindsightApiToken` | string | — | API token for authentication |
| `llmProvider` | string | auto-detect | LLM provider for memory extraction |
| `dynamicBankId` | boolean | `false` | Isolate memory per user (`true`) or share across sessions (`false`) |
| `bankIdPrefix` | string | `"nemoclaw"` | Prefix for the memory bank name |
### Bank naming
When `dynamicBankId: false`, all sessions write to a single bank named `{bankIdPrefix}-openclaw`. When `dynamicBankId: true`, each user gets an isolated bank — useful for multi-tenant deployments.
## Verifying It Works
After setup, check the gateway logs:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
```
On startup you should see:
```
[Hindsight] Plugin loaded successfully
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
[Hindsight] External API health: {"status":"healthy","database":"connected"}
[Hindsight] Default bank: my-sandbox-openclaw
[Hindsight] ✓ Ready (external API mode)
```
After a conversation:
```
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
```
## Pitfalls
### Policy replacement is full-document
`openshell policy set` replaces the entire policy document. The `hindsight-nemoclaw setup` command handles this automatically. If you're applying manually, export the current policy first so existing rules aren't lost.
### LaunchAgent can't follow symlinks on macOS
On macOS, the OpenClaw gateway runs as a LaunchAgent under a restricted security context. `openclaw plugins install --link` creates a symlink the LaunchAgent can't follow — the setup command installs as a copy instead. If you see `EPERM: operation not permitted, scandir` in gateway logs, this is the cause.
### Memory retention is asynchronous
Fact extraction and entity resolution happen in the background after `retain`. If you open a new session immediately after closing one, the most recent memories may not be indexed yet — typically a few seconds.
### Binary-scoped egress
The `binaries` field in the network policy restricts the egress rule to a specific executable path. If OpenClaw updates and the binary path changes, the rule silently stops working. Check your binary path after upgrades.
## Troubleshooting
### Plugin not loading
```bash
openclaw plugins list | grep hindsight
# Should show: ✓ enabled │ Hindsight Memory │ ...
# Reinstall
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### Egress blocked
If calls to `api.hindsight.vectorize.io` are being blocked, check the active sandbox policy:
```bash
openshell sandbox get my-assistant
```
Verify the `hindsight` block is present and the `binaries` path matches your OpenClaw binary:
```bash
which openclaw
```
### External API not connecting
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
# If you see daemon startup messages instead of "Using external API",
# the plugin config isn't being read — check ~/.openclaw/openclaw.json
```

View file

@ -0,0 +1,196 @@
# Using hindsight-openclaw with NemoClaw
This guide covers running the `hindsight-openclaw` plugin inside a [NemoClaw](https://nemoclaw.ai) sandbox. NemoClaw runs OpenClaw inside an OpenShell sandbox, so the plugin's outbound calls to `api.hindsight.vectorize.io` must be explicitly allowed in the sandbox's network egress policy.
## Prerequisites
- NemoClaw installed and a sandbox created (`nemoclaw onboard`)
- OpenClaw installed (`brew install openclaw` or equivalent)
- A Hindsight API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io)
- The plugin source built (`npm run build` in this directory)
## Step 1: Create a Hindsight memory bank
Create the bank the plugin will write to. The bank ID follows the pattern `{bankIdPrefix}-openclaw` when `dynamicBankId` is false:
```bash
curl -X PUT "https://api.hindsight.vectorize.io/v1/default/banks/my-sandbox-openclaw" \
-H "Authorization: Bearer <your-hindsight-api-key>" \
-H "Content-Type: application/json" \
-d '{"mission": "Memory bank for my NemoClaw sandbox."}'
```
## Step 2: Install the plugin
Install the plugin as a copy (not a symlink) so the OpenClaw LaunchAgent can access it:
```bash
# Build first if you haven't already
npm run build
# Install (copy, not link — required for LaunchAgent access)
openclaw plugins install /path/to/hindsight-integrations/openclaw
```
Alternatively, install from npm:
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
## Step 3: Configure the plugin
Add the plugin config to `~/.openclaw/openclaw.json` under `plugins.entries.hindsight-openclaw`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-hindsight-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
**Config notes:**
| Field | Value | Why |
|-------|-------|-----|
| `hindsightApiUrl` + `hindsightApiToken` | External API URL + key | Skips the local daemon; no `uvx`/`uv` required inside the sandbox |
| `llmProvider: "claude-code"` | `"claude-code"` | Satisfies LLM detection without a separate API key — Claude Code is available in the sandbox via the `claude_code` policy |
| `dynamicBankId: false` | `false` | All conversations write to one bank; easier to verify during testing |
| `bankIdPrefix` | e.g. `"my-sandbox"` | Results in bank ID `my-sandbox-openclaw` |
> **Note:** The gateway log will say `Dynamic bank IDs disabled - using static bank: openclaw` — this is a misleading log message. The actual bank ID used at runtime correctly applies the prefix (e.g. `my-sandbox-openclaw`). You can verify by watching for `[Hindsight] Default bank: my-sandbox-openclaw` in the logs after full initialization.
## Step 4: Add the Hindsight network policy to the sandbox
The sandbox blocks all outbound traffic by default. You need to add `api.hindsight.vectorize.io` to the egress policy.
Get the current full policy by running `openshell sandbox get <name>` and save it to a YAML file, then add the `hindsight` block under `network_policies`:
```yaml
network_policies:
# ... your existing policies ...
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
Apply it:
```bash
openshell policy set <sandbox-name> --policy /path/to/full-policy.yaml --wait
```
> **Important:** `openshell policy set` replaces the entire policy, not just patches it. Make sure your YAML includes all existing network policies or they will be removed.
Verify the policy loaded:
```bash
openshell policy get <sandbox-name>
# Should show: Status: Loaded, and version incremented
```
## Step 5: Restart the OpenClaw gateway
```bash
openclaw gateway restart
```
Watch the logs to confirm the plugin loaded and the API is reachable:
```bash
# Should see:
# [Hindsight] Plugin loaded successfully
# [Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
# [Hindsight] External API health: {"status":"healthy","database":"connected"}
# [Hindsight] Default bank: my-sandbox-openclaw
# [Hindsight] ✓ Ready (external API mode)
grep Hindsight ~/.openclaw/logs/gateway.log | tail -20
```
## Step 6: Test
Send a message to the agent:
```bash
openclaw agent --agent main --session-id test-1 \
-m "My name is Ben and I work on Hindsight. I prefer detailed commit messages."
```
Verify memory was retained (check logs):
```bash
grep "Retained\|agent_end" ~/.openclaw/logs/gateway.log | tail -5
# Should see: [Hindsight] Retained N messages to bank my-sandbox-openclaw for session ...
```
Test recall in a new session:
```bash
openclaw agent --agent main --session-id test-2 \
-m "What do you remember about me?"
# Should recall your name and preferences from the previous session
```
You can also verify directly against the API:
```bash
curl -s -X POST "https://api.hindsight.vectorize.io/v1/default/banks/my-sandbox-openclaw/memories/recall" \
-H "Authorization: Bearer <your-hindsight-api-key>" \
-H "Content-Type: application/json" \
-d '{"query": "what do you know about the user", "max_tokens": 512}'
```
## Troubleshooting
**Plugin fails to load with `EPERM: operation not permitted, scandir`**
You used `--link` when installing. The OpenClaw LaunchAgent runs under a restricted macOS security context and cannot access `~/Documents` or other user directories by symlink. Reinstall without `--link`:
```bash
openclaw plugins uninstall hindsight-openclaw
openclaw plugins install /path/to/hindsight-integrations/openclaw # no --link
```
**`[Hindsight] Failed to retain memory (HTTP 403)`**
The sandbox network policy is blocking the outbound call. Check that:
1. The `hindsight` network policy block is present in your policy YAML
2. The policy was applied and shows `Status: Loaded` (`openshell policy get <name>`)
3. The `binaries` list includes `/usr/local/bin/openclaw`
**Gateway restart times out but then recovers**
This is normal on first restart after installing a plugin — the LaunchAgent takes a moment to reload. The gateway is healthy if `openclaw gateway status` shows `RPC probe: ok`.
**`openclaw agent` fails with `Pass --to, --session-id, or --agent`**
You need to specify a session. Use `--agent main` to use the default agent, or `--session-id <any-string>` to create a named session.

View file

@ -0,0 +1,60 @@
# hindsight-nemoclaw
One-command setup for [Hindsight](https://hindsight.vectorize.io) persistent memory on [NemoClaw](https://nemoclaw.ai) sandboxes.
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with strict network egress policies. This package automates the full setup: installing the `hindsight-openclaw` plugin, configuring external API mode, merging the Hindsight egress rule into your sandbox policy, and restarting the gateway.
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
## Documentation
Full setup guide, pitfalls, and troubleshooting:
**[NemoClaw Integration Documentation](https://vectorize.io/hindsight/sdks/integrations/nemoclaw)**
Or see [NEMOCLAW.md](./NEMOCLAW.md) in this directory for a step-by-step walkthrough.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
## What It Does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads current sandbox policy, merges Hindsight egress rule, re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
## Links
- [Hindsight Documentation](https://vectorize.io/hindsight)
- [NemoClaw](https://nemoclaw.ai)
- [OpenClaw](https://openclaw.ai)
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
## License
MIT

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
{
"name": "@vectorize-io/hindsight-nemoclaw",
"version": "0.1.0",
"description": "Setup CLI for hindsight-openclaw on NemoClaw sandboxes — installs the plugin, configures external API mode, and applies the OpenShell network policy",
"type": "module",
"main": "dist/cli.js",
"bin": {
"hindsight-nemoclaw": "dist/cli.js"
},
"keywords": [
"nemoclaw",
"openclaw",
"memory",
"ai",
"agent",
"hindsight",
"openshell",
"nvidia"
],
"author": "Vectorize <support@vectorize.io>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/nemoclaw"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc && node -e \"const f='dist/cli.js',s=require('fs');s.writeFileSync(f,'#!/usr/bin/env node\\n'+s.readFileSync(f,'utf8'));s.chmodSync(f,0o755)\"",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run src",
"test:watch": "vitest src",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22"
}
}

View file

@ -0,0 +1,84 @@
import { runSetup } from './setup.js';
import type { CliArgs } from './types.js';
function usage(): void {
process.stdout.write(`
hindsight-nemoclaw Setup CLI for Hindsight memory on NemoClaw sandboxes
Usage:
hindsight-nemoclaw setup [options]
Required options:
--sandbox <name> NemoClaw sandbox name (e.g. my-assistant)
--api-url <url> Hindsight Cloud API URL (https://api.hindsight.vectorize.io)
--api-token <token> Hindsight API key from https://ui.hindsight.vectorize.io
--bank-prefix <prefix> Bank ID prefix (memories go to <prefix>-openclaw)
Optional options:
--skip-policy Skip the openshell policy update
--skip-plugin-install Skip openclaw plugins install
--dry-run Print what would be changed without executing
--help Show this help
Example:
hindsight-nemoclaw setup \\
--sandbox my-assistant \\
--api-url https://api.hindsight.vectorize.io \\
--api-token hsk_abc123 \\
--bank-prefix my-sandbox
`);
}
function parseArgs(argv: string[]): CliArgs | null {
const args = argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
usage();
return null;
}
if (args[0] !== 'setup') {
process.stderr.write(`Unknown command: ${args[0]}\nRun with --help for usage.\n`);
process.exit(1);
}
const get = (flag: string): string | undefined => {
const idx = args.indexOf(flag);
if (idx === -1 || idx + 1 >= args.length) return undefined;
return args[idx + 1];
};
const sandbox = get('--sandbox');
const apiUrl = get('--api-url');
const apiToken = get('--api-token');
const bankPrefix = get('--bank-prefix');
const missing: string[] = [];
if (!sandbox) missing.push('--sandbox');
if (!apiUrl) missing.push('--api-url');
if (!apiToken) missing.push('--api-token');
if (!bankPrefix) missing.push('--bank-prefix');
if (missing.length > 0) {
process.stderr.write(`Missing required options: ${missing.join(', ')}\nRun with --help for usage.\n`);
process.exit(1);
}
return {
sandbox: sandbox!,
apiUrl: apiUrl!,
apiToken: apiToken!,
bankPrefix: bankPrefix!,
skipPolicy: args.includes('--skip-policy'),
skipPluginInstall: args.includes('--skip-plugin-install'),
dryRun: args.includes('--dry-run'),
};
}
const args = parseArgs(process.argv);
if (args) {
runSetup(args).catch(err => {
process.stderr.write(`\nError: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
}

View file

@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { mergePluginConfig } from './openclaw-config.js';
import type { OpenClawConfig, HindsightPluginConfig } from './openclaw-config.js';
const PLUGIN_CONFIG: HindsightPluginConfig = {
hindsightApiUrl: 'https://api.hindsight.vectorize.io',
hindsightApiToken: 'hsk_test123',
llmProvider: 'claude-code',
dynamicBankId: false,
bankIdPrefix: 'my-sandbox',
};
const BASE_CONFIG: OpenClawConfig = {
meta: { lastTouchedVersion: '2026.3.2' },
gateway: { port: 18789, mode: 'local' },
agents: {
defaults: { model: { primary: 'openai/gpt-5' } },
},
plugins: {
slots: { memory: 'memory-core' },
entries: {
'memory-core': { enabled: false },
},
},
};
describe('mergePluginConfig', () => {
it('sets hindsight-openclaw as the memory slot', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
});
it('enables the hindsight-openclaw entry', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
});
it('writes the full plugin config', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(config?.hindsightApiToken).toBe('hsk_test123');
expect(config?.llmProvider).toBe('claude-code');
expect(config?.dynamicBankId).toBe(false);
expect(config?.bankIdPrefix).toBe('my-sandbox');
});
it('preserves existing top-level config fields', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.gateway).toEqual({ port: 18789, mode: 'local' });
expect(result.agents).toBeDefined();
});
it('preserves existing plugin entries', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['memory-core']?.enabled).toBe(false);
});
it('merges into existing hindsight-openclaw entry without overwriting other fields', () => {
const configWithExisting: OpenClawConfig = {
...BASE_CONFIG,
plugins: {
...BASE_CONFIG.plugins,
entries: {
...BASE_CONFIG.plugins?.entries,
'hindsight-openclaw': {
enabled: true,
config: { embedPackagePath: '/some/local/path' },
},
},
},
};
const result = mergePluginConfig(configWithExisting, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
// New fields written
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
// Existing custom field preserved
expect(config?.embedPackagePath).toBe('/some/local/path');
});
it('handles missing plugins section gracefully', () => {
const minimal: OpenClawConfig = { gateway: { port: 18789 } };
const result = mergePluginConfig(minimal, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
});
it('does not mutate the original config', () => {
const original = JSON.parse(JSON.stringify(BASE_CONFIG)) as OpenClawConfig;
mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(JSON.stringify(BASE_CONFIG)).toBe(JSON.stringify(original));
});
it('records install metadata', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const install = result.plugins?.installs?.['hindsight-openclaw'] as Record<string, unknown>;
expect(install?.source).toBe('npm');
expect(install?.version).toBe('latest');
expect(typeof install?.installedAt).toBe('string');
});
});

View file

@ -0,0 +1,106 @@
import { readFile, writeFile, rename } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
const CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
export interface HindsightPluginConfig {
hindsightApiUrl: string;
hindsightApiToken: string;
llmProvider: string;
dynamicBankId: boolean;
bankIdPrefix: string;
}
export interface OpenClawConfig {
plugins?: {
slots?: Record<string, string>;
entries?: Record<string, { enabled: boolean; config?: Record<string, unknown> }>;
installs?: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
export async function readOpenClawConfig(configPath = CONFIG_PATH): Promise<OpenClawConfig> {
let raw: string;
try {
raw = await readFile(configPath, 'utf8');
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
throw new Error(
`OpenClaw config not found at ${configPath}.\n` +
`Run \`openclaw\` once to initialize it, then re-run setup.`
);
}
throw err;
}
return JSON.parse(raw) as OpenClawConfig;
}
export function mergePluginConfig(
config: OpenClawConfig,
pluginConfig: HindsightPluginConfig
): OpenClawConfig {
const plugins = config.plugins ?? {};
const entries = plugins.entries ?? {};
const existing = entries['hindsight-openclaw'] ?? { enabled: true };
return {
...config,
plugins: {
...plugins,
slots: {
...(plugins.slots ?? {}),
memory: 'hindsight-openclaw',
},
entries: {
...entries,
'hindsight-openclaw': {
...existing,
enabled: true,
config: {
...(existing.config ?? {}),
hindsightApiUrl: pluginConfig.hindsightApiUrl,
hindsightApiToken: pluginConfig.hindsightApiToken,
llmProvider: pluginConfig.llmProvider,
dynamicBankId: pluginConfig.dynamicBankId,
bankIdPrefix: pluginConfig.bankIdPrefix,
},
},
},
installs: {
...(plugins.installs ?? {}),
'hindsight-openclaw': {
source: 'npm',
version: 'latest',
installedAt: new Date().toISOString(),
},
},
},
};
}
export async function writeOpenClawConfig(
config: OpenClawConfig,
configPath = CONFIG_PATH
): Promise<void> {
const contents = JSON.stringify(config, null, 2) + '\n';
const tmp = `${configPath}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, contents, 'utf8');
await rename(tmp, configPath);
}
export async function applyPluginConfig(
pluginConfig: HindsightPluginConfig,
configPath = CONFIG_PATH
): Promise<void> {
const current = await readOpenClawConfig(configPath);
const updated = mergePluginConfig(current, pluginConfig);
await writeOpenClawConfig(updated, configPath);
}
export { CONFIG_PATH };
export { dirname };

View file

@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { stripAnsi, extractPolicyYaml, parseSandboxPolicy } from './policy-reader.js';
import { serializePolicy } from './policy-writer.js';
// Fixture: actual output of `openshell sandbox get my-assistant`
// (ANSI codes represented as escape sequences)
const FIXTURE_RAW = `\x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m
\x1b[2mId:\x1b[0m 61c993f1-010f-4eca-a1ac-d6ddec9d604a
\x1b[2mName:\x1b[0m my-assistant
\x1b[2mNamespace:\x1b[0m openshell
\x1b[2mPhase:\x1b[0m Ready
\x1b[1m\x1b[36mPolicy:\x1b[39m\x1b[0m
\x1b[2mversion\x1b[0m\x1b[2m:\x1b[0m 1
\x1b[2mfilesystem_policy\x1b[0m\x1b[2m:\x1b[0m
\x1b[2minclude_workdir\x1b[0m\x1b[2m:\x1b[0m true
\x1b[2mread_only\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0m/usr
\x1b[2m- \x1b[0m/lib
\x1b[2mread_write\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0m/sandbox
\x1b[2m- \x1b[0m/tmp
\x1b[2mnetwork_policies\x1b[0m\x1b[2m:\x1b[0m
\x1b[2mclaude_code\x1b[0m\x1b[2m:\x1b[0m
\x1b[2mname\x1b[0m\x1b[2m:\x1b[0m claude_code
\x1b[2mendpoints\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mhost: api.anthropic.com
\x1b[2mport\x1b[0m\x1b[2m:\x1b[0m 443
\x1b[2mrules\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mallow:
\x1b[2mmethod\x1b[0m\x1b[2m:\x1b[0m '*'
\x1b[2mpath\x1b[0m\x1b[2m:\x1b[0m /**
\x1b[2mbinaries\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mpath: /usr/local/bin/claude
`;
describe('stripAnsi', () => {
it('removes ANSI escape codes', () => {
expect(stripAnsi('\x1b[1m\x1b[36mHello\x1b[39m\x1b[0m')).toBe('Hello');
});
it('leaves plain strings unchanged', () => {
expect(stripAnsi('version: 1')).toBe('version: 1');
});
it('handles strings with no ANSI codes', () => {
expect(stripAnsi(' - /usr')).toBe(' - /usr');
});
});
describe('extractPolicyYaml', () => {
it('extracts the Policy: section and dedents by 2 spaces', () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).toContain('version: 1');
expect(result).toContain('filesystem_policy:');
expect(result).toContain('network_policies:');
});
it('does not include the Sandbox: section', () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).not.toContain('Sandbox:');
expect(result).not.toContain('my-assistant');
});
it('throws if Policy: section is missing', () => {
expect(() => extractPolicyYaml('no policy here')).toThrow('Could not find "Policy:"');
});
});
describe('parseSandboxPolicy', () => {
it('parses version field', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.version).toBe(1);
});
it('parses filesystem_policy', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.filesystem_policy?.include_workdir).toBe(true);
expect(policy.filesystem_policy?.read_only).toContain('/usr');
});
it('parses network_policies', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.network_policies).toBeDefined();
expect(policy.network_policies?.claude_code).toBeDefined();
expect(policy.network_policies?.claude_code?.name).toBe('claude_code');
});
it('is idempotent — parse → serialize → parse yields same structure', () => {
const policy1 = parseSandboxPolicy(FIXTURE_RAW);
const yamlStr = serializePolicy(policy1);
// Re-wrap in a Policy: header to match the expected format
const wrapped = 'Policy:\n' + yamlStr.split('\n').map((l: string) => ` ${l}`).join('\n');
const policy2 = parseSandboxPolicy(wrapped);
expect(policy2.version).toBe(policy1.version);
expect(Object.keys(policy2.network_policies ?? {})).toEqual(
Object.keys(policy1.network_policies ?? {})
);
});
});

View file

@ -0,0 +1,88 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
const execFileAsync = promisify(execFile);
/** Strip ANSI escape codes from a string */
export function stripAnsi(str: string): string {
return str.replace(/\x1B\[[0-9;]*m/g, '');
}
/**
* Extract and dedent the policy section from `openshell sandbox get` output.
* The output looks like:
*
* Sandbox:
* Id: ...
* Name: ...
*
* Policy:
* version: 1
* filesystem_policy:
* ...
*
* We need to extract everything after `Policy:` and dedent by 2 spaces.
*/
export function extractPolicyYaml(raw: string): string {
const stripped = stripAnsi(raw);
const lines = stripped.split('\n');
const policyHeaderIdx = lines.findIndex(l => l.trimEnd() === 'Policy:');
if (policyHeaderIdx === -1) {
throw new Error('Could not find "Policy:" section in `openshell sandbox get` output');
}
const policyLines = lines.slice(policyHeaderIdx + 1);
// Dedent by 2 spaces (the policy block is indented under `Policy:`)
const dedented = policyLines.map(l => {
if (l.startsWith(' ')) return l.slice(2);
return l;
});
// Drop trailing empty lines
while (dedented.length > 0 && dedented[dedented.length - 1].trim() === '') {
dedented.pop();
}
return dedented.join('\n');
}
/**
* Parse `openshell sandbox get <name>` output into a SandboxPolicy object.
* Throws a descriptive error if parsing fails.
*/
export function parseSandboxPolicy(rawOutput: string): SandboxPolicy {
const policyYaml = extractPolicyYaml(rawOutput);
try {
const parsed = yaml.load(policyYaml);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Parsed policy is not an object');
}
return parsed as SandboxPolicy;
} catch (err) {
throw new Error(
`Failed to parse sandbox policy YAML.\n` +
`This may mean the openshell output format has changed.\n` +
`Apply the Hindsight policy manually using the instructions in NEMOCLAW.md.\n` +
`Parse error: ${err}`
);
}
}
/** Run `openshell sandbox get <sandbox>` and return parsed policy */
export async function readSandboxPolicy(sandboxName: string): Promise<SandboxPolicy> {
let stdout: string;
try {
const result = await execFileAsync('openshell', ['sandbox', 'get', sandboxName]);
stdout = result.stdout;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to run \`openshell sandbox get ${sandboxName}\`: ${msg}`);
}
return parseSandboxPolicy(stdout);
}

View file

@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { parseSandboxPolicy } from './policy-reader.js';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
const BASE_POLICY: SandboxPolicy = {
version: 1,
filesystem_policy: {
include_workdir: true,
read_only: ['/usr', '/lib'],
read_write: ['/sandbox', '/tmp'],
},
network_policies: {
claude_code: {
name: 'claude_code',
endpoints: [
{
host: 'api.anthropic.com',
port: 443,
rules: [{ allow: { method: '*', path: '/**' } }],
},
],
binaries: [{ path: '/usr/local/bin/claude' }],
},
},
};
describe('hasHindsightPolicy', () => {
it('returns false when no hindsight policy exists', () => {
expect(hasHindsightPolicy(BASE_POLICY)).toBe(false);
});
it('returns false when network_policies is undefined', () => {
expect(hasHindsightPolicy({ version: 1 })).toBe(false);
});
it('returns true when hindsight policy is present', () => {
const withHindsight = mergeHindsightPolicy(BASE_POLICY);
expect(hasHindsightPolicy(withHindsight)).toBe(true);
});
});
describe('mergeHindsightPolicy', () => {
it('adds the hindsight network policy block', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.hindsight).toBeDefined();
expect(result.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
});
it('preserves all existing network policies', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.claude_code).toBeDefined();
expect(result.network_policies?.claude_code?.name).toBe('claude_code');
});
it('sets the correct binary path', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const binaries = result.network_policies?.hindsight?.binaries ?? [];
expect(binaries.some(b => b.path === OPENCLAW_BINARY)).toBe(true);
});
it('includes GET, POST, and PUT rules', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const rules = result.network_policies?.hindsight?.endpoints[0].rules ?? [];
const methods = rules.map(r => r.allow.method);
expect(methods).toContain('GET');
expect(methods).toContain('POST');
expect(methods).toContain('PUT');
});
it('is idempotent — merging twice yields the same result', () => {
const once = mergeHindsightPolicy(BASE_POLICY);
const twice = mergeHindsightPolicy(once);
expect(JSON.stringify(twice.network_policies?.hindsight)).toBe(
JSON.stringify(once.network_policies?.hindsight)
);
});
it('does not mutate the original policy', () => {
const original = JSON.parse(JSON.stringify(BASE_POLICY)) as SandboxPolicy;
mergeHindsightPolicy(BASE_POLICY);
expect(BASE_POLICY.network_policies?.hindsight).toBeUndefined();
expect(JSON.stringify(BASE_POLICY)).toBe(JSON.stringify(original));
});
});
describe('serializePolicy', () => {
it('produces valid YAML that round-trips through parseSandboxPolicy', () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yamlStr = serializePolicy(merged);
// Wrap in Policy: header as parseSandboxPolicy expects
const wrapped = 'Policy:\n' + yamlStr.split('\n').map(l => ` ${l}`).join('\n');
const reparsed = parseSandboxPolicy(wrapped);
expect(reparsed.version).toBe(merged.version);
expect(reparsed.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
expect(reparsed.network_policies?.claude_code).toBeDefined();
});
it('includes all network policies in output', () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yaml = serializePolicy(merged);
expect(yaml).toContain('claude_code:');
expect(yaml).toContain('hindsight:');
expect(yaml).toContain(HINDSIGHT_HOST);
});
});

View file

@ -0,0 +1,59 @@
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_POLICY_NAME, HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
const HINDSIGHT_NETWORK_POLICY = {
name: HINDSIGHT_POLICY_NAME,
endpoints: [
{
host: HINDSIGHT_HOST,
port: 443,
protocol: 'rest',
tls: 'terminate',
enforcement: 'enforce',
rules: [
{ allow: { method: 'GET', path: '/**' } },
{ allow: { method: 'POST', path: '/**' } },
{ allow: { method: 'PUT', path: '/**' } },
],
},
],
binaries: [{ path: OPENCLAW_BINARY }],
};
/**
* Returns true if the policy already has a correct Hindsight network policy entry.
*/
export function hasHindsightPolicy(policy: SandboxPolicy): boolean {
const np = policy.network_policies?.[HINDSIGHT_POLICY_NAME];
if (!np) return false;
return np.endpoints?.some(e => e.host === HINDSIGHT_HOST) ?? false;
}
/**
* Merge the Hindsight network policy block into a SandboxPolicy.
* Idempotent if the block already exists and is correct, returns policy unchanged.
*/
export function mergeHindsightPolicy(policy: SandboxPolicy): SandboxPolicy {
const updated: SandboxPolicy = {
...policy,
network_policies: {
...(policy.network_policies ?? {}),
[HINDSIGHT_POLICY_NAME]: HINDSIGHT_NETWORK_POLICY,
},
};
return updated;
}
/**
* Serialize a SandboxPolicy to a YAML string suitable for `openshell policy set`.
*/
export function serializePolicy(policy: SandboxPolicy): string {
return yaml.dump(policy, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
});
}

View file

@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CliArgs } from './types.js';
// Mock all external I/O before importing setup
vi.mock('child_process', () => ({
execFile: vi.fn(),
}));
vi.mock('./policy-reader.js', () => ({
readSandboxPolicy: vi.fn(),
}));
vi.mock('./policy-writer.js', () => ({
hasHindsightPolicy: vi.fn(),
mergeHindsightPolicy: vi.fn(),
serializePolicy: vi.fn(),
}));
vi.mock('./openclaw-config.js', () => ({
applyPluginConfig: vi.fn(),
}));
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
rm: vi.fn(),
}));
const BASE_ARGS: CliArgs = {
sandbox: 'my-assistant',
apiUrl: 'https://api.hindsight.vectorize.io',
apiToken: 'hsk_test123',
bankPrefix: 'my-sandbox',
skipPolicy: false,
skipPluginInstall: false,
dryRun: false,
};
describe('runSetup', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { execFile } = await import('child_process');
const execFileMock = vi.mocked(execFile);
// Default: all shell commands succeed
execFileMock.mockImplementation((_cmd, _args, callback?: unknown) => {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { readSandboxPolicy } = await import('./policy-reader.js');
vi.mocked(readSandboxPolicy).mockResolvedValue({
version: 1,
network_policies: { claude_code: { name: 'claude_code', endpoints: [] } },
});
const { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } = await import('./policy-writer.js');
vi.mocked(hasHindsightPolicy).mockReturnValue(false);
vi.mocked(mergeHindsightPolicy).mockImplementation(p => ({ ...p, network_policies: { ...p.network_policies, hindsight: { name: 'hindsight', endpoints: [] } } }));
vi.mocked(serializePolicy).mockReturnValue('version: 1\n');
const { applyPluginConfig } = await import('./openclaw-config.js');
vi.mocked(applyPluginConfig).mockResolvedValue(undefined);
});
it('runs all steps in order for a clean install', async () => {
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('which openshell'))).toBe(true);
expect(calls.some(c => c.includes('which openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openclaw plugins install @vectorize-io/hindsight-openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openshell policy set my-assistant'))).toBe(true);
expect(calls.some(c => c.includes('openclaw gateway restart'))).toBe(true);
});
it('skips plugin install when --skip-plugin-install is set', async () => {
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup({ ...BASE_ARGS, skipPluginInstall: true });
expect(calls.some(c => c.includes('plugins install'))).toBe(false);
});
it('skips policy update when --skip-policy is set', async () => {
const { runSetup } = await import('./setup.js');
const { readSandboxPolicy } = await import('./policy-reader.js');
await runSetup({ ...BASE_ARGS, skipPolicy: true });
expect(vi.mocked(readSandboxPolicy)).not.toHaveBeenCalled();
});
it('skips policy set when Hindsight policy already exists', async () => {
const { hasHindsightPolicy } = await import('./policy-writer.js');
vi.mocked(hasHindsightPolicy).mockReturnValue(true);
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('openshell policy set'))).toBe(false);
});
it('does not execute any shell commands in dry-run mode', async () => {
const { execFile } = await import('child_process');
const { applyPluginConfig } = await import('./openclaw-config.js');
const { writeFile } = await import('fs/promises');
const { runSetup } = await import('./setup.js');
await runSetup({ ...BASE_ARGS, dryRun: true });
// which checks still run (preflight), but no actual commands
const execCalls = vi.mocked(execFile).mock.calls.map(c => `${c[0]} ${(c[1] as string[]).join(' ')}`);
expect(execCalls.some(c => c.includes('plugins install'))).toBe(false);
expect(execCalls.some(c => c.includes('policy set'))).toBe(false);
expect(execCalls.some(c => c.includes('gateway restart'))).toBe(false);
expect(vi.mocked(applyPluginConfig)).not.toHaveBeenCalled();
expect(vi.mocked(writeFile)).not.toHaveBeenCalled();
});
it('fails early if openshell is not on PATH', async () => {
const { execFile } = await import('child_process');
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
if (cmd === 'which' && (args as string[])[0] === 'openshell') {
if (typeof callback === 'function') {
(callback as (err: Error) => void)(new Error('not found'));
}
} else {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await expect(runSetup(BASE_ARGS)).rejects.toThrow('openshell');
});
});

View file

@ -0,0 +1,148 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomBytes } from 'crypto';
import type { CliArgs } from './types.js';
import { readSandboxPolicy } from './policy-reader.js';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { applyPluginConfig } from './openclaw-config.js';
const execFileAsync = promisify(execFile);
function log(msg: string) {
process.stdout.write(`${msg}\n`);
}
function step(n: number, msg: string) {
log(`\n[${n}] ${msg}`);
}
async function which(bin: string): Promise<boolean> {
try {
await execFileAsync('which', [bin]);
return true;
} catch {
return false;
}
}
export async function runSetup(args: CliArgs): Promise<void> {
log('\nhindsight-nemoclaw setup');
log('─'.repeat(40));
// Step 0 — Preflight
step(0, 'Preflight checks...');
const [hasOpenshell, hasOpenclaw] = await Promise.all([which('openshell'), which('openclaw')]);
if (!hasOpenshell) {
throw new Error('`openshell` not found on PATH. Install it from https://openshell.ai');
}
if (!hasOpenclaw) {
throw new Error('`openclaw` not found on PATH. Install it from https://openclaw.ai');
}
log(' ✓ openshell found');
log(' ✓ openclaw found');
// Step 1 — Install hindsight-openclaw plugin
if (!args.skipPluginInstall) {
step(1, 'Installing @vectorize-io/hindsight-openclaw plugin...');
if (args.dryRun) {
log(' [dry-run] would run: openclaw plugins install @vectorize-io/hindsight-openclaw');
} else {
const { stdout } = await execFileAsync('openclaw', [
'plugins', 'install', '@vectorize-io/hindsight-openclaw',
]);
log(stdout.trim() || ' ✓ Plugin installed');
}
} else {
step(1, 'Skipping plugin install (--skip-plugin-install)');
}
// Step 2 — Configure ~/.openclaw/openclaw.json
step(2, 'Configuring plugin in ~/.openclaw/openclaw.json...');
const pluginConfig = {
hindsightApiUrl: args.apiUrl,
hindsightApiToken: args.apiToken,
llmProvider: 'claude-code',
dynamicBankId: false,
bankIdPrefix: args.bankPrefix,
};
if (args.dryRun) {
log(` [dry-run] would write plugin config to ~/.openclaw/openclaw.json`);
log(` config: ${JSON.stringify(pluginConfig, null, 4).split('\n').join('\n ')}`);
} else {
await applyPluginConfig(pluginConfig);
log(` ✓ Plugin config written (bank: ${args.bankPrefix}-openclaw)`);
}
// Step 3 — Apply OpenShell network policy
if (!args.skipPolicy) {
step(3, `Applying Hindsight network policy to sandbox "${args.sandbox}"...`);
const currentPolicy = await readSandboxPolicy(args.sandbox);
if (hasHindsightPolicy(currentPolicy)) {
log(' ✓ Hindsight policy already present — skipping');
} else {
const updatedPolicy = mergeHindsightPolicy(currentPolicy);
const policyYaml = serializePolicy(updatedPolicy);
if (args.dryRun) {
log(' [dry-run] would apply policy:');
log(policyYaml.split('\n').map(l => ` ${l}`).join('\n'));
} else {
const tmpFile = join(tmpdir(), `hindsight-policy-${randomBytes(6).toString('hex')}.yaml`);
try {
await writeFile(tmpFile, policyYaml, 'utf8');
const { stdout } = await execFileAsync('openshell', [
'policy', 'set', args.sandbox, '--policy', tmpFile, '--wait',
]);
log(stdout.trim() || ` ✓ Policy applied to sandbox "${args.sandbox}"`);
} finally {
await rm(tmpFile, { force: true });
}
}
}
} else {
step(3, 'Skipping policy update (--skip-policy)');
log(' Add the following block to your sandbox network_policies manually:');
log('');
log(' hindsight:');
log(' name: hindsight');
log(' endpoints:');
log(' - host: api.hindsight.vectorize.io');
log(' port: 443');
log(' protocol: rest');
log(' tls: terminate');
log(' enforcement: enforce');
log(' rules:');
log(' - allow: { method: GET, path: /** }');
log(' - allow: { method: POST, path: /** }');
log(' - allow: { method: PUT, path: /** }');
log(' binaries:');
log(' - path: /usr/local/bin/openclaw');
}
// Step 4 — Restart gateway
step(4, 'Restarting OpenClaw gateway...');
if (args.dryRun) {
log(' [dry-run] would run: openclaw gateway restart');
} else {
await execFileAsync('openclaw', ['gateway', 'restart']);
log(' ✓ Gateway restarted');
}
log('\n' + '─'.repeat(40));
log('✓ Setup complete!\n');
log(` Bank ID: ${args.bankPrefix}-openclaw`);
log(` API URL: ${args.apiUrl}`);
log('');
log(' Watch gateway logs to confirm:');
log(' grep Hindsight ~/.openclaw/logs/gateway.log | tail -5');
log(' Expected: [Hindsight] ✓ Ready (external API mode)');
log('');
log(' Test memory retention:');
log(` openclaw agent --agent main --session-id test-1 -m "My name is Ben."`);
log(` openclaw agent --agent main --session-id test-2 -m "What do you remember about me?"`);
}

View file

@ -0,0 +1,63 @@
export interface CliArgs {
sandbox: string;
apiUrl: string;
apiToken: string;
bankPrefix: string;
skipPolicy: boolean;
skipPluginInstall: boolean;
dryRun: boolean;
}
export interface PolicyEndpointRule {
allow: {
method: string;
path: string;
};
}
export interface PolicyEndpoint {
host: string;
port: number;
protocol?: string;
tls?: string;
enforcement?: string;
access?: string;
rules?: PolicyEndpointRule[];
}
export interface PolicyBinary {
path: string;
}
export interface NetworkPolicy {
name: string;
endpoints: PolicyEndpoint[];
binaries?: PolicyBinary[];
}
export interface FilesystemPolicy {
include_workdir?: boolean;
read_only?: string[];
read_write?: string[];
}
export interface Landlock {
compatibility?: string;
}
export interface ProcessPolicy {
run_as_user?: string;
run_as_group?: string;
}
export interface SandboxPolicy {
version?: number;
filesystem_policy?: FilesystemPolicy;
landlock?: Landlock;
process?: ProcessPolicy;
network_policies?: Record<string, NetworkPolicy>;
}
export const HINDSIGHT_POLICY_NAME = 'hindsight';
export const HINDSIGHT_HOST = 'api.hindsight.vectorize.io';
export const OPENCLAW_BINARY = '/usr/local/bin/openclaw';

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});