@josephyoung/pi-openviking 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/dist/bootstrap.d.ts +20 -0
- package/dist/bootstrap.js +117 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +28 -0
- package/dist/delivery.d.ts +44 -0
- package/dist/delivery.js +167 -0
- package/dist/host.d.ts +33 -0
- package/dist/host.js +130 -0
- package/dist/launcher.d.ts +17 -0
- package/dist/launcher.js +61 -0
- package/dist/openviking-client.d.ts +35 -0
- package/dist/openviking-client.js +216 -0
- package/dist/resource-profile.d.ts +7 -0
- package/dist/resource-profile.js +14 -0
- package/dist/scheduler.d.ts +26 -0
- package/dist/scheduler.js +116 -0
- package/dist/standard.d.ts +6 -0
- package/dist/standard.js +87 -0
- package/dist/state-store.d.ts +13 -0
- package/dist/state-store.js +146 -0
- package/dist/tool-worker-entry.d.ts +1 -0
- package/dist/tool-worker-entry.js +83 -0
- package/dist/tool-worker.d.ts +27 -0
- package/dist/tool-worker.js +160 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +9 -0
- package/dist/worker-tools.d.ts +8 -0
- package/dist/worker-tools.js +64 -0
- package/licenses/Apache-2.0.txt +202 -0
- package/package.json +81 -0
package/dist/standard.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createOpenVikingExtension, MemoryDelivery } from './host.js';
|
|
2
|
+
import { createIsolatedToolsExtension } from './worker-tools.js';
|
|
3
|
+
const statusLabels = {
|
|
4
|
+
queued: '等待处理', session_unknown: '正在核对', session_created: '处理中',
|
|
5
|
+
message_unknown: '正在核对', message_delivered: '处理中', commit_unknown: '正在核对',
|
|
6
|
+
processing: '处理中', ready: '已保存', failed: '保存失败', blocked: '需要处理', blocked_by_pause: '已被暂停阻止',
|
|
7
|
+
};
|
|
8
|
+
let launcherBinding;
|
|
9
|
+
/** Only the trusted CLI bootstrap installs this binding, before pi loads extensions. */
|
|
10
|
+
export function bindStandardHost(options, worker) {
|
|
11
|
+
if (launcherBinding)
|
|
12
|
+
throw new Error('DUPLICATE_STANDARD_MEMORY_HOST');
|
|
13
|
+
if (!worker)
|
|
14
|
+
throw new Error('MISSING_MEMORY_WORKER');
|
|
15
|
+
launcherBinding = { options, worker };
|
|
16
|
+
}
|
|
17
|
+
export default async function openViking(pi) {
|
|
18
|
+
if (!launcherBinding) {
|
|
19
|
+
pi.on('session_start', (_event, ctx) => {
|
|
20
|
+
ctx.ui.notify('长期记忆未启用:请通过受保护的 pi-openviking 启动入口运行。', 'info');
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const { options, worker } = launcherBinding;
|
|
25
|
+
// The memory gate verifies the very same worker that receives native tools.
|
|
26
|
+
await createIsolatedToolsExtension(worker)(pi);
|
|
27
|
+
await createOpenVikingExtension({ ...options, assertToolIsolation: () => worker.assertIsolated() })(pi);
|
|
28
|
+
const delivery = new MemoryDelivery({ store: options.stateStore, transport: options.client, maxPayloadBytes: options.policy.maxPayloadBytes });
|
|
29
|
+
pi.registerCommand('memory', {
|
|
30
|
+
description: '长期记忆:enable、pause、status、show。启用和自动采集分别授权。',
|
|
31
|
+
async handler(args, ctx) {
|
|
32
|
+
try {
|
|
33
|
+
const action = args.trim();
|
|
34
|
+
if (action === 'enable') {
|
|
35
|
+
await worker.assertIsolated();
|
|
36
|
+
if (!ctx.hasUI) {
|
|
37
|
+
ctx.ui.notify('请在交互模式中确认启用长期记忆。', 'warning');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const accepted = await ctx.ui.confirm('启用长期记忆', '明确保存的事实将发送到已配置的记忆服务,并可在新聊天中召回。自动采集仍保持关闭;启用前的保存请求需重新确认。');
|
|
41
|
+
if (!accepted)
|
|
42
|
+
return;
|
|
43
|
+
const state = await options.stateStore.read();
|
|
44
|
+
if (!state.authorization.enabled)
|
|
45
|
+
await delivery.enable(state.authorization.policyVersion);
|
|
46
|
+
ctx.ui.notify('长期记忆已启用;自动采集未授权。请重新发起需要保存的事实。', 'info');
|
|
47
|
+
}
|
|
48
|
+
else if (action === 'pause') {
|
|
49
|
+
await delivery.pause();
|
|
50
|
+
ctx.ui.notify('长期记忆已暂停。未发送的保存不会在恢复后自动重放。', 'info');
|
|
51
|
+
}
|
|
52
|
+
else if (action === 'status') {
|
|
53
|
+
const state = await options.stateStore.read();
|
|
54
|
+
const recent = Object.values(state.operations).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20);
|
|
55
|
+
const summary = [`长期记忆:${state.authorization.enabled ? '已启用' : '已暂停'};自动采集:${state.authorization.automaticCollection ? '已授权' : '未授权'}`,
|
|
56
|
+
...recent.map(operation => `${statusLabels[operation.phase]} · ${operation.createdAt} · ${operation.id}`)];
|
|
57
|
+
ctx.ui.notify(summary.join('\n'), 'info');
|
|
58
|
+
}
|
|
59
|
+
else if (action.startsWith('show ')) {
|
|
60
|
+
const id = action.slice(5).trim();
|
|
61
|
+
if (!/^[a-f0-9]{64}$/.test(id)) {
|
|
62
|
+
ctx.ui.notify('保存记录不存在。', 'warning');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const operation = (await options.stateStore.read()).operations[id];
|
|
66
|
+
if (!operation) {
|
|
67
|
+
ctx.ui.notify('保存记录不存在。', 'warning');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const lines = [`状态:${statusLabels[operation.phase]}`, `时间:${operation.createdAt}`,
|
|
71
|
+
`来源会话:${operation.source.sessionId}`];
|
|
72
|
+
if (operation.phase === 'ready' && options.client.readMemory) {
|
|
73
|
+
for (const uri of operation.memoryUris ?? [])
|
|
74
|
+
lines.push(await options.client.readMemory(uri));
|
|
75
|
+
}
|
|
76
|
+
ctx.ui.notify(lines.join('\n'), 'info');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
ctx.ui.notify('使用 /memory enable、/memory pause 、/memory status 或 /memory show <记录编号>。', 'info');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
ctx.ui.notify('记忆操作暂时不可用;普通聊天可继续。', 'warning');
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Owner, type OwnerState, type StateStore } from './types.js';
|
|
2
|
+
/** The directory must be outside tool access; permissions alone are not a sandbox. */
|
|
3
|
+
export declare class FileStateStore implements StateStore {
|
|
4
|
+
#private;
|
|
5
|
+
readonly owner: Owner;
|
|
6
|
+
constructor(options: {
|
|
7
|
+
owner: Owner;
|
|
8
|
+
directory: string;
|
|
9
|
+
policyVersion: string;
|
|
10
|
+
});
|
|
11
|
+
read(): Promise<OwnerState>;
|
|
12
|
+
transact<T>(mutation: (state: OwnerState) => T): Promise<T>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { flock } from 'fs-ext';
|
|
6
|
+
import { checkedOwner, sameOwner } from './types.js';
|
|
7
|
+
async function lock(fd, operation) {
|
|
8
|
+
// Blocking flock consumes a libuv worker: enough waiting writers can starve
|
|
9
|
+
// the current holder's fsync. Nonblocking acquisition keeps that pool free.
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
await new Promise((accept, reject) => flock(fd, operation === 'ex' ? 'exnb' : 'un', error => error ? reject(error) : accept()));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (operation !== 'ex' || !['EAGAIN', 'EWOULDBLOCK'].includes(error.code ?? ''))
|
|
17
|
+
throw error;
|
|
18
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function verify(state, owner) {
|
|
23
|
+
if (state?.version !== 1 || !state.owner || !sameOwner(state.owner, owner)
|
|
24
|
+
|| !Number.isSafeInteger(state.revision) || state.revision < 0
|
|
25
|
+
|| !state.authorization || typeof state.authorization.enabled !== 'boolean'
|
|
26
|
+
|| typeof state.authorization.automaticCollection !== 'boolean'
|
|
27
|
+
|| !Number.isSafeInteger(state.authorization.epoch)
|
|
28
|
+
|| !state.operations || Array.isArray(state.operations)) {
|
|
29
|
+
throw new Error('INVALID_MEMORY_STATE');
|
|
30
|
+
}
|
|
31
|
+
for (const [id, operation] of Object.entries(state.operations)) {
|
|
32
|
+
if (id !== operation.id || !operation.owner || !sameOwner(operation.owner, owner)) {
|
|
33
|
+
throw new Error('MEMORY_OWNER_MISMATCH');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The directory must be outside tool access; permissions alone are not a sandbox. */
|
|
38
|
+
export class FileStateStore {
|
|
39
|
+
owner;
|
|
40
|
+
#directory;
|
|
41
|
+
#policyVersion;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.owner = checkedOwner(options.owner);
|
|
44
|
+
this.#directory = resolve(options.directory);
|
|
45
|
+
if (!options.policyVersion)
|
|
46
|
+
throw new Error('MISSING_POLICY_VERSION');
|
|
47
|
+
this.#policyVersion = options.policyVersion;
|
|
48
|
+
}
|
|
49
|
+
async #prepare() {
|
|
50
|
+
// Do not follow a final-component symlink or relax an existing directory.
|
|
51
|
+
await mkdir(this.#directory, { mode: 0o700, recursive: true });
|
|
52
|
+
const metadata = await lstat(this.#directory);
|
|
53
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()
|
|
54
|
+
|| (metadata.mode & 0o077) !== 0
|
|
55
|
+
|| metadata.uid !== process.getuid?.())
|
|
56
|
+
throw new Error('UNPROTECTED_MEMORY_STATE');
|
|
57
|
+
}
|
|
58
|
+
async #load() {
|
|
59
|
+
let file;
|
|
60
|
+
try {
|
|
61
|
+
file = await open(join(this.#directory, 'state.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 'ENOENT')
|
|
65
|
+
throw new Error('MEMORY_STATE_UNREADABLE');
|
|
66
|
+
return {
|
|
67
|
+
version: 1, owner: this.owner, revision: 0,
|
|
68
|
+
authorization: { enabled: false, automaticCollection: false, epoch: 0,
|
|
69
|
+
effectiveAt: new Date().toISOString(), policyVersion: this.#policyVersion },
|
|
70
|
+
operations: {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const metadata = await file.stat();
|
|
75
|
+
if (!metadata.isFile() || (metadata.mode & 0o077) !== 0 || metadata.uid !== process.getuid?.()) {
|
|
76
|
+
throw new Error('UNPROTECTED_MEMORY_STATE');
|
|
77
|
+
}
|
|
78
|
+
const state = JSON.parse(await file.readFile('utf8'));
|
|
79
|
+
verify(state, this.owner);
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await file.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async #write(state) {
|
|
87
|
+
verify(state, this.owner);
|
|
88
|
+
const temporary = join(this.#directory, `.state-${randomUUID()}`);
|
|
89
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
90
|
+
try {
|
|
91
|
+
await file.writeFile(JSON.stringify(state));
|
|
92
|
+
await file.sync();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await file.close();
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await rename(temporary, join(this.#directory, 'state.json'));
|
|
99
|
+
const directory = await open(this.#directory, constants.O_RDONLY);
|
|
100
|
+
try {
|
|
101
|
+
await directory.sync();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await directory.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await rm(temporary, { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async #locked(action) {
|
|
112
|
+
await this.#prepare();
|
|
113
|
+
const file = await open(join(this.#directory, 'state.lock'), constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
114
|
+
try {
|
|
115
|
+
const metadata = await file.stat();
|
|
116
|
+
if (!metadata.isFile() || (metadata.mode & 0o077) !== 0 || metadata.uid !== process.getuid?.()) {
|
|
117
|
+
throw new Error('UNPROTECTED_MEMORY_LOCK');
|
|
118
|
+
}
|
|
119
|
+
await lock(file.fd, 'ex');
|
|
120
|
+
try {
|
|
121
|
+
return await action();
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await lock(file.fd, 'un');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await file.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
read() {
|
|
132
|
+
return this.#locked(() => this.#load());
|
|
133
|
+
}
|
|
134
|
+
transact(mutation) {
|
|
135
|
+
return this.#locked(async () => {
|
|
136
|
+
const state = await this.#load();
|
|
137
|
+
const result = mutation(state);
|
|
138
|
+
if (result && typeof result.then === 'function') {
|
|
139
|
+
throw new Error('ASYNC_MEMORY_TRANSACTION');
|
|
140
|
+
}
|
|
141
|
+
state.revision++;
|
|
142
|
+
await this.#write(state);
|
|
143
|
+
return result;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Internal executable. Its arguments come only from the trusted bootstrap.
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
const [workspace, piEntry, limitText] = process.argv.slice(2);
|
|
4
|
+
const maxResultBytes = Number(limitText);
|
|
5
|
+
if (!process.send || process.getuid?.() === 0 || !workspace || !piEntry || !Number.isSafeInteger(maxResultBytes)) {
|
|
6
|
+
throw new Error('INVALID_TOOL_WORKER_START');
|
|
7
|
+
}
|
|
8
|
+
const pi = await import(pathToFileURL(piEntry).href);
|
|
9
|
+
const definitions = [pi.createReadToolDefinition(workspace), pi.createWriteToolDefinition(workspace),
|
|
10
|
+
pi.createEditToolDefinition(workspace), pi.createBashToolDefinition(workspace), pi.createGrepToolDefinition(workspace),
|
|
11
|
+
pi.createFindToolDefinition(workspace), pi.createLsToolDefinition(workspace)];
|
|
12
|
+
const tools = new Map(definitions.map(tool => [tool.name, tool]));
|
|
13
|
+
const running = new Map();
|
|
14
|
+
const send = (value) => { if (process.connected)
|
|
15
|
+
process.send(value, () => { }); };
|
|
16
|
+
process.on('disconnect', () => { for (const controller of running.values())
|
|
17
|
+
controller.abort(); process.exit(0); });
|
|
18
|
+
process.on('message', async (message) => {
|
|
19
|
+
if (!message || typeof message !== 'object')
|
|
20
|
+
return;
|
|
21
|
+
const request = message;
|
|
22
|
+
if (request.type === 'shutdown') {
|
|
23
|
+
for (const controller of running.values())
|
|
24
|
+
controller.abort();
|
|
25
|
+
process.disconnect?.();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (typeof request.id !== 'string')
|
|
29
|
+
return;
|
|
30
|
+
const id = request.id;
|
|
31
|
+
if (request.type === 'cancel') {
|
|
32
|
+
running.get(id)?.abort();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (request.type !== 'execute' || typeof request.name !== 'string' || !request.parameters
|
|
36
|
+
|| typeof request.parameters !== 'object' || Array.isArray(request.parameters) || running.has(id))
|
|
37
|
+
return;
|
|
38
|
+
const tool = tools.get(request.name);
|
|
39
|
+
if (!tool && request.name !== 'user_bash') {
|
|
40
|
+
send({ type: 'error', id });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
running.set(id, controller);
|
|
45
|
+
try {
|
|
46
|
+
// Native definitions do not consume extension context. No host callback,
|
|
47
|
+
// credentials, mutable environment or arbitrary function crosses this IPC.
|
|
48
|
+
if (request.name === 'user_bash') {
|
|
49
|
+
const parameters = request.parameters;
|
|
50
|
+
if (typeof parameters.command !== 'string')
|
|
51
|
+
throw new Error('INVALID_SHELL_COMMAND');
|
|
52
|
+
const value = await pi.createLocalBashOperations().exec(parameters.command, workspace, {
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
timeout: typeof parameters.timeout === 'number' ? parameters.timeout : undefined,
|
|
55
|
+
onData(data) {
|
|
56
|
+
const value = { data: data.toString('base64') };
|
|
57
|
+
const update = { type: 'update', id, value };
|
|
58
|
+
if (Buffer.byteLength(JSON.stringify(update)) <= maxResultBytes)
|
|
59
|
+
send(update);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
send({ type: 'result', id, value });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const value = await tool.execute(id, request.parameters, controller.signal, update => {
|
|
66
|
+
const result = { type: 'update', id, value: update };
|
|
67
|
+
if (Buffer.byteLength(JSON.stringify(result)) <= maxResultBytes)
|
|
68
|
+
send(result);
|
|
69
|
+
}, undefined);
|
|
70
|
+
const result = { type: 'result', id, value };
|
|
71
|
+
if (Buffer.byteLength(JSON.stringify(result)) > maxResultBytes)
|
|
72
|
+
send({ type: 'error', id });
|
|
73
|
+
else
|
|
74
|
+
send(result);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
send({ type: 'error', id });
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
running.delete(id);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
send({ type: 'ready', uid: process.getuid?.(), gid: process.getgid?.() });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const nativeToolNames: readonly ["read", "write", "edit", "bash", "grep", "find", "ls"];
|
|
2
|
+
export type NativeToolName = typeof nativeToolNames[number];
|
|
3
|
+
export type WorkerOperationName = NativeToolName | 'user_bash';
|
|
4
|
+
export interface WorkerOptions {
|
|
5
|
+
workspace: string;
|
|
6
|
+
/** Protected installation's package.json; never supplied by the model. */
|
|
7
|
+
piPackageContext: string;
|
|
8
|
+
/** Trusted util-linux setpriv executable, used to make privilege gain irreversible. */
|
|
9
|
+
privilegeGuard: string;
|
|
10
|
+
workerUid: number;
|
|
11
|
+
workerGid: number;
|
|
12
|
+
hostUid: number;
|
|
13
|
+
path: string;
|
|
14
|
+
startupTimeoutMs: number;
|
|
15
|
+
operationTimeoutMs: number;
|
|
16
|
+
maxConcurrentOperations: number;
|
|
17
|
+
maxResultBytes: number;
|
|
18
|
+
}
|
|
19
|
+
/** Bootstrap while privileged; drop host privileges before enabling memory. */
|
|
20
|
+
export declare class NativeToolWorker {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(options: WorkerOptions);
|
|
23
|
+
get workspace(): string;
|
|
24
|
+
assertIsolated(): Promise<void>;
|
|
25
|
+
execute(name: WorkerOperationName, parameters: Record<string, unknown>, signal?: AbortSignal, onUpdate?: (value: unknown) => void): Promise<unknown>;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
export const nativeToolNames = ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'];
|
|
8
|
+
/** Bootstrap while privileged; drop host privileges before enabling memory. */
|
|
9
|
+
export class NativeToolWorker {
|
|
10
|
+
#child;
|
|
11
|
+
#options;
|
|
12
|
+
#pending = new Map();
|
|
13
|
+
#ready;
|
|
14
|
+
#closed = false;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
if (process.platform !== 'linux' || process.getuid?.() !== 0 || !isAbsolute(options.privilegeGuard)
|
|
17
|
+
|| ![options.workerUid, options.workerGid, options.hostUid].every(id => Number.isSafeInteger(id) && id > 0)
|
|
18
|
+
|| options.workerUid === options.hostUid
|
|
19
|
+
|| ![options.startupTimeoutMs, options.operationTimeoutMs, options.maxConcurrentOperations, options.maxResultBytes]
|
|
20
|
+
.every(value => Number.isSafeInteger(value) && value > 0)) {
|
|
21
|
+
throw new Error('INVALID_MEMORY_WORKER_PROFILE');
|
|
22
|
+
}
|
|
23
|
+
this.#options = { ...options };
|
|
24
|
+
// pi publishes an import-only export; CommonJS require.resolve cannot
|
|
25
|
+
// select it. Resolve the fixed peer's declared public ESM entry explicitly.
|
|
26
|
+
const piRoot = join(dirname(options.piPackageContext), 'node_modules/@earendil-works/pi-coding-agent');
|
|
27
|
+
const manifest = JSON.parse(readFileSync(join(piRoot, 'package.json'), 'utf8'));
|
|
28
|
+
const entry = manifest.exports?.['.']?.import;
|
|
29
|
+
const extensionManifest = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
30
|
+
const expectedPeer = extensionManifest.peerDependencies?.['@earendil-works/pi-coding-agent'];
|
|
31
|
+
if (!/^\d+\.\d+\.\d+$/.test(expectedPeer ?? '') || manifest.version !== expectedPeer
|
|
32
|
+
|| typeof entry !== 'string' || !entry.startsWith('./')) {
|
|
33
|
+
throw new Error('UNSUPPORTED_MEMORY_WORKER_PI');
|
|
34
|
+
}
|
|
35
|
+
const piEntry = resolve(piRoot, entry);
|
|
36
|
+
// Clear supplementary bootstrap groups before creating the worker. The
|
|
37
|
+
// eventual host also retains no supplementary groups from root startup.
|
|
38
|
+
process.setgroups([]);
|
|
39
|
+
this.#child = spawn(options.privilegeGuard, ['--no-new-privs', '--', process.execPath, fileURLToPath(new URL('./tool-worker-entry.js', import.meta.url)),
|
|
40
|
+
options.workspace, piEntry, String(options.maxResultBytes)], {
|
|
41
|
+
cwd: options.workspace, uid: options.workerUid, gid: options.workerGid,
|
|
42
|
+
env: { PATH: options.path, HOME: options.workspace, LANG: 'C.UTF-8' },
|
|
43
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
44
|
+
});
|
|
45
|
+
this.#ready = new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => { this.close(); reject(new Error('MEMORY_WORKER_START_TIMEOUT')); }, options.startupTimeoutMs);
|
|
47
|
+
const failed = () => { clearTimeout(timer); reject(new Error('MEMORY_WORKER_UNAVAILABLE')); };
|
|
48
|
+
this.#child.once('error', failed);
|
|
49
|
+
this.#child.once('exit', failed);
|
|
50
|
+
this.#child.on('message', message => {
|
|
51
|
+
if (!message || typeof message !== 'object')
|
|
52
|
+
return;
|
|
53
|
+
const result = message;
|
|
54
|
+
if (result.type === 'ready') {
|
|
55
|
+
if (result.uid !== options.workerUid || result.gid !== options.workerGid) {
|
|
56
|
+
this.close();
|
|
57
|
+
failed();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolve();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (typeof result.id !== 'string')
|
|
65
|
+
return;
|
|
66
|
+
const pending = this.#pending.get(result.id);
|
|
67
|
+
if (!pending)
|
|
68
|
+
return;
|
|
69
|
+
if (result.type === 'update') {
|
|
70
|
+
try {
|
|
71
|
+
if (Buffer.byteLength(JSON.stringify(result)) > options.maxResultBytes)
|
|
72
|
+
throw new Error('WORKER_UPDATE_LIMIT');
|
|
73
|
+
pending.onUpdate?.(result.value);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
this.#pending.delete(result.id);
|
|
77
|
+
pending.cleanup();
|
|
78
|
+
if (this.#child.connected)
|
|
79
|
+
this.#child.send({ type: 'cancel', id: result.id }, () => { });
|
|
80
|
+
pending.reject(new Error('MEMORY_WORKER_UPDATE_FAILED'));
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.#pending.delete(result.id);
|
|
85
|
+
pending.cleanup();
|
|
86
|
+
if (Buffer.byteLength(JSON.stringify(result)) > options.maxResultBytes)
|
|
87
|
+
pending.reject(new Error('MEMORY_WORKER_RESULT_LIMIT'));
|
|
88
|
+
else if (result.type === 'result')
|
|
89
|
+
pending.resolve(result.value);
|
|
90
|
+
else
|
|
91
|
+
pending.reject(new Error('MEMORY_WORKER_TOOL_FAILED'));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// A bootstrap caller may still be preparing the host before awaiting ready.
|
|
95
|
+
void this.#ready.catch(() => { });
|
|
96
|
+
const disconnected = () => {
|
|
97
|
+
this.#closed = true;
|
|
98
|
+
for (const pending of this.#pending.values()) {
|
|
99
|
+
pending.cleanup();
|
|
100
|
+
pending.reject(new Error('MEMORY_WORKER_UNAVAILABLE'));
|
|
101
|
+
}
|
|
102
|
+
this.#pending.clear();
|
|
103
|
+
};
|
|
104
|
+
this.#child.once('exit', disconnected);
|
|
105
|
+
this.#child.once('error', disconnected);
|
|
106
|
+
this.#child.once('disconnect', disconnected);
|
|
107
|
+
}
|
|
108
|
+
get workspace() { return this.#options.workspace; }
|
|
109
|
+
async assertIsolated() {
|
|
110
|
+
await this.#ready;
|
|
111
|
+
if (this.#closed || !this.#child.connected || process.getuid?.() !== this.#options.hostUid) {
|
|
112
|
+
throw new Error('MEMORY_WORKER_UNAVAILABLE');
|
|
113
|
+
}
|
|
114
|
+
// Independently inspect kernel identity, not a self-reported config flag.
|
|
115
|
+
const status = await readFile(`/proc/${this.#child.pid}/status`, 'utf8');
|
|
116
|
+
if (!/^NoNewPrivs:\s+1$/m.test(status))
|
|
117
|
+
throw new Error('MEMORY_WORKER_PRIVILEGE_GAIN_ALLOWED');
|
|
118
|
+
const uids = /^Uid:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$/m.exec(status);
|
|
119
|
+
if (!uids || uids.slice(1).some(uid => Number(uid) !== this.#options.workerUid))
|
|
120
|
+
throw new Error('MEMORY_WORKER_IDENTITY_MISMATCH');
|
|
121
|
+
}
|
|
122
|
+
async execute(name, parameters, signal, onUpdate) {
|
|
123
|
+
await this.assertIsolated();
|
|
124
|
+
if (!(name === 'user_bash' || nativeToolNames.includes(name)) || this.#pending.size >= this.#options.maxConcurrentOperations) {
|
|
125
|
+
throw new Error('MEMORY_WORKER_REQUEST_LIMIT');
|
|
126
|
+
}
|
|
127
|
+
signal?.throwIfAborted();
|
|
128
|
+
const id = randomUUID();
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const cancel = () => {
|
|
131
|
+
if (!this.#pending.delete(id))
|
|
132
|
+
return;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
signal?.removeEventListener('abort', cancel);
|
|
135
|
+
if (this.#child.connected)
|
|
136
|
+
this.#child.send({ type: 'cancel', id }, () => { });
|
|
137
|
+
reject(new Error('MEMORY_WORKER_CANCELLED'));
|
|
138
|
+
};
|
|
139
|
+
const timer = setTimeout(cancel, this.#options.operationTimeoutMs);
|
|
140
|
+
this.#pending.set(id, { resolve, reject, onUpdate, cleanup: () => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
signal?.removeEventListener('abort', cancel);
|
|
143
|
+
} });
|
|
144
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
145
|
+
this.#child.send({ type: 'execute', id, name, parameters }, error => {
|
|
146
|
+
if (!error)
|
|
147
|
+
return;
|
|
148
|
+
const pending = this.#pending.get(id);
|
|
149
|
+
this.#pending.delete(id);
|
|
150
|
+
pending?.cleanup();
|
|
151
|
+
pending?.reject(new Error('MEMORY_WORKER_UNAVAILABLE'));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
close() {
|
|
156
|
+
if (this.#child.connected)
|
|
157
|
+
this.#child.send({ type: 'shutdown' }, () => { });
|
|
158
|
+
this.#closed = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface Owner {
|
|
2
|
+
readonly accountId: string;
|
|
3
|
+
readonly userId: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Authorization {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
automaticCollection: boolean;
|
|
8
|
+
epoch: number;
|
|
9
|
+
effectiveAt: string;
|
|
10
|
+
policyVersion: string;
|
|
11
|
+
}
|
|
12
|
+
export type DeliveryPhase = 'queued' | 'session_unknown' | 'session_created' | 'message_unknown' | 'message_delivered' | 'commit_unknown' | 'processing' | 'ready' | 'failed' | 'blocked_by_pause' | 'blocked';
|
|
13
|
+
export interface Source {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
entryId: string;
|
|
16
|
+
branchId: string;
|
|
17
|
+
contentVersion: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Operation {
|
|
20
|
+
id: string;
|
|
21
|
+
owner: Owner;
|
|
22
|
+
scope: string | null;
|
|
23
|
+
source: Source;
|
|
24
|
+
kind: 'explicit';
|
|
25
|
+
authorizationEpoch: number;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
phase: DeliveryPhase;
|
|
29
|
+
remoteSessionId: string;
|
|
30
|
+
payload?: string;
|
|
31
|
+
taskId?: string;
|
|
32
|
+
archiveId?: string;
|
|
33
|
+
memoryUris?: string[];
|
|
34
|
+
errorCode?: string;
|
|
35
|
+
deliveryAttempts?: number;
|
|
36
|
+
nextAttemptAt?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface OwnerState {
|
|
39
|
+
version: 1;
|
|
40
|
+
owner: Owner;
|
|
41
|
+
revision: number;
|
|
42
|
+
authorization: Authorization;
|
|
43
|
+
operations: Record<string, Operation>;
|
|
44
|
+
}
|
|
45
|
+
export interface StateStore {
|
|
46
|
+
readonly owner: Owner;
|
|
47
|
+
read(): Promise<OwnerState>;
|
|
48
|
+
/** Synchronous mutation, serialized across processes and committed before return. */
|
|
49
|
+
transact<T>(mutation: (state: OwnerState) => T): Promise<T>;
|
|
50
|
+
}
|
|
51
|
+
export declare function checkedOwner(owner: Owner): Owner;
|
|
52
|
+
export declare function sameOwner(a: Owner, b: Owner): boolean;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function checkedOwner(owner) {
|
|
2
|
+
if (!owner || ![owner.accountId, owner.userId].every(id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id))) {
|
|
3
|
+
throw new Error('INVALID_MEMORY_OWNER');
|
|
4
|
+
}
|
|
5
|
+
return Object.freeze({ accountId: owner.accountId, userId: owner.userId });
|
|
6
|
+
}
|
|
7
|
+
export function sameOwner(a, b) {
|
|
8
|
+
return a.accountId === b.accountId && a.userId === b.userId;
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ToolDefinition, type ExtensionFactory, type BashOperations } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { NativeToolWorker } from './tool-worker.js';
|
|
3
|
+
export type IsolatedToolExecutor = Pick<NativeToolWorker, 'workspace' | 'execute' | 'assertIsolated'>;
|
|
4
|
+
/** Preserve pi schemas, normalization and rendering; replace every execution function. */
|
|
5
|
+
export declare function createIsolatedToolDefinitions(worker: IsolatedToolExecutor): ToolDefinition[];
|
|
6
|
+
/** Interactive ! / !! commands must use the same worker as model Bash calls. */
|
|
7
|
+
export declare function createIsolatedBashOperations(worker: IsolatedToolExecutor): BashOperations;
|
|
8
|
+
export declare function createIsolatedToolsExtension(worker: IsolatedToolExecutor): ExtensionFactory;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createReadToolDefinition, createWriteToolDefinition, createEditToolDefinition, createBashToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
function toolResult(value) {
|
|
4
|
+
if (!value || typeof value !== 'object')
|
|
5
|
+
throw new Error('INVALID_WORKER_TOOL_RESULT');
|
|
6
|
+
const content = value.content;
|
|
7
|
+
if (!Array.isArray(content) || !content.every(item => item && typeof item === 'object'
|
|
8
|
+
&& ((item.type === 'text' && typeof item.text === 'string')
|
|
9
|
+
|| (item.type === 'image' && typeof item.data === 'string' && typeof item.mimeType === 'string')))) {
|
|
10
|
+
throw new Error('INVALID_WORKER_TOOL_RESULT');
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function checkWorkspace(worker, cwd) {
|
|
15
|
+
if (resolve(cwd) !== resolve(worker.workspace))
|
|
16
|
+
throw new Error('MEMORY_WORKER_WORKSPACE_MISMATCH');
|
|
17
|
+
}
|
|
18
|
+
/** Preserve pi schemas, normalization and rendering; replace every execution function. */
|
|
19
|
+
export function createIsolatedToolDefinitions(worker) {
|
|
20
|
+
const cwd = worker.workspace;
|
|
21
|
+
const definitions = [createReadToolDefinition(cwd), createWriteToolDefinition(cwd), createEditToolDefinition(cwd),
|
|
22
|
+
createBashToolDefinition(cwd), createGrepToolDefinition(cwd), createFindToolDefinition(cwd), createLsToolDefinition(cwd)];
|
|
23
|
+
return definitions.map(definition => ({ ...definition,
|
|
24
|
+
async execute(_callId, parameters, signal, onUpdate, context) {
|
|
25
|
+
checkWorkspace(worker, context.cwd);
|
|
26
|
+
if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters))
|
|
27
|
+
throw new Error('INVALID_WORKER_TOOL_ARGUMENTS');
|
|
28
|
+
await worker.assertIsolated();
|
|
29
|
+
return toolResult(await worker.execute(definition.name, parameters, signal, update => onUpdate?.(toolResult(update))));
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** Interactive ! / !! commands must use the same worker as model Bash calls. */
|
|
34
|
+
export function createIsolatedBashOperations(worker) {
|
|
35
|
+
return {
|
|
36
|
+
async exec(command, cwd, options) {
|
|
37
|
+
checkWorkspace(worker, cwd);
|
|
38
|
+
await worker.assertIsolated();
|
|
39
|
+
// Never copy options.env from the privileged host into the worker.
|
|
40
|
+
const result = await worker.execute('user_bash', { command, timeout: options.timeout }, options.signal, update => {
|
|
41
|
+
if (!update || typeof update !== 'object' || typeof update.data !== 'string') {
|
|
42
|
+
throw new Error('INVALID_WORKER_SHELL_UPDATE');
|
|
43
|
+
}
|
|
44
|
+
options.onData(Buffer.from(update.data, 'base64'));
|
|
45
|
+
});
|
|
46
|
+
const exitCode = result && typeof result === 'object' ? result.exitCode : undefined;
|
|
47
|
+
if (exitCode !== null && !Number.isInteger(exitCode))
|
|
48
|
+
throw new Error('INVALID_WORKER_SHELL_RESULT');
|
|
49
|
+
return { exitCode: exitCode };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function createIsolatedToolsExtension(worker) {
|
|
54
|
+
const definitions = createIsolatedToolDefinitions(worker);
|
|
55
|
+
const operations = createIsolatedBashOperations(worker);
|
|
56
|
+
return pi => {
|
|
57
|
+
for (const definition of definitions)
|
|
58
|
+
pi.registerTool(definition);
|
|
59
|
+
pi.on('user_bash', event => {
|
|
60
|
+
checkWorkspace(worker, event.cwd);
|
|
61
|
+
return { operations };
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
}
|