@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joseph Young
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# pi-openviking
|
|
2
|
+
|
|
3
|
+
An independent OpenViking memory extension for pi, tracked by
|
|
4
|
+
[Dano #465](https://github.com/zhengchengqiaobusiness-arch/Dano/issues/465).
|
|
5
|
+
|
|
6
|
+
**Implementation in progress. No package release or production activation yet.**
|
|
7
|
+
The package name is `@josephyoung/pi-openviking`. Both entry modules compile
|
|
8
|
+
against pi 0.85.1. The real pi loader loads both entries and keeps a single
|
|
9
|
+
registration after reload; the standard entry fails closed without its launcher binding.
|
|
10
|
+
The Linux CLI now runs through the public pi entry; memory-enabled CLI acceptance and product integration continue under [#474](https://github.com/zhengchengqiaobusiness-arch/Dano/issues/474).
|
|
11
|
+
|
|
12
|
+
## Implemented
|
|
13
|
+
|
|
14
|
+
- A Linux native-tool IPC worker with distinct UID, irreversible `no_new_privs`, explicit environment
|
|
15
|
+
allowlist, kernel identity checks, bounded requests/results, streamed updates
|
|
16
|
+
and cancellation. All seven native definitions and interactive `!`/`!!` shell
|
|
17
|
+
operations have worker proxies; the standard entry registers these proxies
|
|
18
|
+
with the memory extension. The protected CLI bootstraps and binds the worker.
|
|
19
|
+
- Immutable account/user binding and owner-checked private state files. Before
|
|
20
|
+
first data access, the authenticated health response must confirm the expected
|
|
21
|
+
account, user and USER role; an HTTP 200 with missing identity is insufficient.
|
|
22
|
+
- OS advisory locks, atomic replacement, file and directory fsync. The state
|
|
23
|
+
contains delivery/consent metadata and pending payloads, not a second memory
|
|
24
|
+
database. Kernel locks are released when a writer dies; no lease timeout can
|
|
25
|
+
grant a second process permission to repeat a remote mutation.
|
|
26
|
+
- Standard and host factory entry modules; the host never reads global credentials.
|
|
27
|
+
- Bounded, quoted recall in a non-persisted custom context message, with a host
|
|
28
|
+
tokenizer, per-request cache and pause/lifecycle invalidation.
|
|
29
|
+
- Default-off consent, explicit durable enqueue and stable source deduplication.
|
|
30
|
+
- A dedicated remote Session per save operation, with persisted causal phases.
|
|
31
|
+
An unknown message/commit outcome is reconciled through public APIs and never
|
|
32
|
+
blindly retransmitted. Missing or expired receipts require reconciliation;
|
|
33
|
+
they do not authorize repeating a non-idempotent call.
|
|
34
|
+
- Owner-level background scheduling with durable backoff, startup recovery,
|
|
35
|
+
bounded processing and shutdown. Exhausted reconciliation stays visibly blocked;
|
|
36
|
+
it never turns an unknown remote outcome into an automatic resend.
|
|
37
|
+
- Protected resource-loader configuration rejects workspace packages/extensions
|
|
38
|
+
while preserving explicitly supplied trusted Skills. Apply before package
|
|
39
|
+
resolution; `noExtensions` alone is insufficient.
|
|
40
|
+
- `ready` requires a completed matching task, an archive containing the source,
|
|
41
|
+
a matching memory diff, current content and a successful retrieval probe.
|
|
42
|
+
- Pause suppresses unsent operations and removes their pending bodies. Enabling
|
|
43
|
+
again does not replay those operations or authorize automatic collection.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
Requires Node.js >=22.19, a POSIX system, Python and a C++ compiler for the
|
|
48
|
+
`fs-ext` native advisory-lock binding. Install scripts for that audited native
|
|
49
|
+
module must run when installing. This first local run used Node 26.8.2; the
|
|
50
|
+
release gate must also test the selected Linux/Node deployment combination.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm ci
|
|
54
|
+
npm test
|
|
55
|
+
npm run check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`npm test` covers independent processes, killed writers, concurrent processors,
|
|
59
|
+
response loss, source conflicts, owner mismatch, consent, recall budgets and
|
|
60
|
+
lifecycle behavior (38 tests in the current development run). It does not prove
|
|
61
|
+
end-to-end host isolation or UI acceptance.
|
|
62
|
+
|
|
63
|
+
For a separately provisioned disposable `extension-test-*` account, place an
|
|
64
|
+
owner-only JSON file outside the repository with `owner: {accountId, userId}`,
|
|
65
|
+
`baseUrl` (server origin) and a USER-level `apiKey`, then run:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm run build
|
|
69
|
+
node scripts/real-service.mjs /absolute/private/test-run/connection.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This invokes the configured server's extraction models and may incur charges.
|
|
73
|
+
The script rejects non-test account names, leaves synthetic data for inspection
|
|
74
|
+
and writes a credential-free result alongside the protected connection file.
|
|
75
|
+
Do not commit connection files, state, credentials or real user data.
|
|
76
|
+
|
|
77
|
+
## Security and release boundaries
|
|
78
|
+
|
|
79
|
+
State permissions alone do not protect credentials from same-UID Agent tools.
|
|
80
|
+
The selected memory-enabled profile requires a trusted Linux host and a
|
|
81
|
+
separate tool UID, protected installation/state and no executable discovery
|
|
82
|
+
from tool-writable paths. That launcher must be integrated and verified before
|
|
83
|
+
activating memory in either pi or Dano. Current modules are not a substitute
|
|
84
|
+
for that boundary.
|
|
85
|
+
|
|
86
|
+
Further #474 gates: memory-enabled CLI acceptance and Dano worker lifecycle integration,
|
|
87
|
+
credential isolation, Dano exact-version integration, authenticated settings
|
|
88
|
+
and management, ordinary pi and real in-app Browser acceptance. Subsequent
|
|
89
|
+
#475–477 work covers full collection/lifecycle/governance and release gates.
|
|
90
|
+
|
|
91
|
+
## Verified service combination
|
|
92
|
+
|
|
93
|
+
- OpenViking server: unmodified 0.4.20.
|
|
94
|
+
- OpenViking TypeScript SDK: 0.1.0, exact dependency.
|
|
95
|
+
- pi: 0.85.1, exact peer dependency (not bundled).
|
|
96
|
+
- Local actual-adapter save: 2026-09-18, synthetic fact reached `ready` and was
|
|
97
|
+
recalled after ~22.8 seconds. Every delivery step recreated the adapter from
|
|
98
|
+
persisted state. This is one functional run, not the PRD performance sample.
|
|
99
|
+
|
|
100
|
+
See `THIRD_PARTY_NOTICES.md` for distribution responsibilities.
|
|
101
|
+
|
|
102
|
+
## Executed worker boundary
|
|
103
|
+
|
|
104
|
+
`scripts/linux-worker.mjs` exercises the actual worker in a disposable root
|
|
105
|
+
Linux container, with the three numeric identities supplied as arguments. The
|
|
106
|
+
2026-09-18 run used pi 0.82.1 and Node 22.23.2: workspace read/write succeeded;
|
|
107
|
+
absolute and symlink read/write/edit against the host-private credential failed;
|
|
108
|
+
Bash inherited no synthetic memory key; updates and cancellation worked. The
|
|
109
|
+
container used no network and was removed after the run. This verifies the
|
|
110
|
+
worker primitive, not the final CLI/Dano launch and resource-discovery profile.
|
|
111
|
+
|
|
112
|
+
The actual background scheduler also completed a fresh real-service save on
|
|
113
|
+
2026-09-18: it reached `ready` after 30.3 seconds and the subsequent query
|
|
114
|
+
retrieved the synthetic preference. No viewer or foreground delivery calls
|
|
115
|
+
advanced the operation. Reproduce with `scripts/real-scheduler.mjs` and a fresh
|
|
116
|
+
disposable account config, using the same private-config rules above.
|
|
117
|
+
|
|
118
|
+
The standard entry exposes `/memory enable` (interactive confirmation),
|
|
119
|
+
`/memory pause`, `/memory status`, and `/memory show <operation-id>`. Its enable
|
|
120
|
+
gate checks the exact worker used by its native tools. Automatic collection
|
|
121
|
+
remains unapproved. Read-only saved-content and credential-owner checks against
|
|
122
|
+
the actual service are reproducible with `scripts/real-read.mjs`.
|
|
123
|
+
|
|
124
|
+
The Linux worker integration now exercises the registered tool proxies and
|
|
125
|
+
interactive shell, preserving streaming and exit codes. Both cancellation
|
|
126
|
+
paths are checked for absence of a delayed file write, rather than only testing
|
|
127
|
+
that the caller receives a cancellation error.
|
|
128
|
+
|
|
129
|
+
## Protected bootstrap primitive
|
|
130
|
+
|
|
131
|
+
`bootstrapProtectedWorker` validates canonical workspace, private agent/state
|
|
132
|
+
roots and a protected installation tree before starting the worker and dropping
|
|
133
|
+
bootstrap UID/GID. It rejects workspace overlap, replaceable ancestors,
|
|
134
|
+
worker-writable code, and installation symlinks escaping the installation root.
|
|
135
|
+
Private directories must already belong to the configured host UID with no
|
|
136
|
+
group/other permissions. Provisioning is explicit; this function never widens
|
|
137
|
+
permissions or repairs arbitrary paths.
|
|
138
|
+
|
|
139
|
+
The worker uses a configured absolute util-linux `setpriv` path to set
|
|
140
|
+
`no_new_privs` before Node executes. Kernel `NoNewPrivs: 1` is checked alongside
|
|
141
|
+
UID identity. The complete bootstrap primitive passed the real Linux worker
|
|
142
|
+
fixture, including its tool/interactive-shell and cancellation checks. This
|
|
143
|
+
does not by itself establish the multi-user Dano worker lifecycle.
|
|
144
|
+
|
|
145
|
+
## Protected pi CLI
|
|
146
|
+
|
|
147
|
+
`pi-openviking /etc/pi-openviking/profile.json [pi chat arguments]` starts the
|
|
148
|
+
Linux worker, drops host privileges, loads a trusted host module and calls pi's
|
|
149
|
+
public `main` entry with the standard extension factory. The profile and its
|
|
150
|
+
ancestors must be root-owned and not group/other-writable. It contains bootstrap
|
|
151
|
+
paths/IDs/limits, `hostModule`, `shutdownTimeoutMs` and optional
|
|
152
|
+
`trustedSkillPaths`; it must contain no provider credentials. See the exported
|
|
153
|
+
`LauncherProfile` type for required fields.
|
|
154
|
+
|
|
155
|
+
The installed host module exports `createHost({ paths, assertToolIsolation })`
|
|
156
|
+
and returns `{ memory, scheduler }`. It reads keys from the host-private root
|
|
157
|
+
and supplies the selected model's exact tokenizer. It runs after privilege
|
|
158
|
+
drop. Its source and approved Skill paths must be inside the protected
|
|
159
|
+
installation. The CLI fixes private session storage and denies executable
|
|
160
|
+
resource/trust overrides and package/config administration commands.
|
|
161
|
+
|
|
162
|
+
Print mode closes its scheduler/worker on return. Interactive pi emits its own
|
|
163
|
+
shutdown hooks and exits; worker IPC disconnect terminates outstanding tool
|
|
164
|
+
work. Delivery does not depend on an exit flush: the durable queue recovers on
|
|
165
|
+
the next launch. A container supervisor must terminate the entire process tree
|
|
166
|
+
on abrupt host termination.
|
|
167
|
+
|
|
168
|
+
The 2026-09-18 Linux run used the real configured model through pi 0.82.1:
|
|
169
|
+
Bash wrote `cli-proof.txt`, read returned its content, and the file belonged to
|
|
170
|
+
the separate worker UID. Workspace extension discovery was denied and the CLI
|
|
171
|
+
exited normally. `scripts/linux-cli.mjs` reproduces this in a disposable root
|
|
172
|
+
container; its `cli-test-host.mjs` deliberately leaves memory disabled and does
|
|
173
|
+
not substitute for memory-enabled acceptance. Model credentials are copied to
|
|
174
|
+
a private agent directory. Extra CA certificates must remain readable after
|
|
175
|
+
host privilege drop; TLS verification stays enabled.
|
|
176
|
+
|
|
177
|
+
## Release candidate validation
|
|
178
|
+
|
|
179
|
+
The standard CLI RPC path has now passed explicit enable confirmation, real
|
|
180
|
+
OpenViking save to `ready`, content/source inspection, new-session recall and
|
|
181
|
+
pause. Automatic collection remains separately unapproved. This passed first
|
|
182
|
+
on pi 0.82.1 and again with a fresh account on pi 0.85.1. See
|
|
183
|
+
[the acceptance record](docs/acceptance-2026-09-18.md). Interactive TUI screenshots
|
|
184
|
+
and Dano's real in-app Browser gate remain outstanding.
|
|
185
|
+
|
|
186
|
+
The exact peer moved to pi 0.85.1 because pi 0.82.1's bundled shrinkwrap kept
|
|
187
|
+
vulnerable transitive dependencies despite root overrides. The 0.85.1 install
|
|
188
|
+
resolves undici 8.9.0 and brace-expansion 5.0.9; `npm audit` currently reports
|
|
189
|
+
zero vulnerabilities. The package includes the Apache-2.0 license text needed
|
|
190
|
+
for the unmodified OpenViking SDK.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Third-party distribution review
|
|
2
|
+
|
|
3
|
+
This repository's adapter code is original MIT-licensed code. It does not copy
|
|
4
|
+
OpenViking server or example implementation code.
|
|
5
|
+
|
|
6
|
+
- `@openviking/sdk@0.1.0` declares Apache-2.0. Its upstream npm tarball omits a
|
|
7
|
+
standalone LICENSE. The complete Apache-2.0 text is shipped in `licenses/Apache-2.0.txt`.
|
|
8
|
+
Upstream: https://github.com/volcengine/OpenViking/tree/b54001e2e5c974ffd7a09ba543813fa104a99561/sdk/typescript.
|
|
9
|
+
The dependency is unmodified; its published tarball contains no separate
|
|
10
|
+
NOTICE or copyright attribution file. Its package metadata and original
|
|
11
|
+
source notices remain in the dependency. Review the final image dependency
|
|
12
|
+
tree separately; this notice does not relabel the server license.
|
|
13
|
+
- `fs-ext@2.1.1` declares MIT. Preserve its license and the notices of native
|
|
14
|
+
build dependencies when distributing them.
|
|
15
|
+
- pi 0.85.1 is an MIT peer dependency. The extension must not bundle a second
|
|
16
|
+
kernel. Images distributing pi must retain its applicable license/notices.
|
|
17
|
+
- OpenViking server 0.4.20 is a separate AGPL-3.0 service, not part of this
|
|
18
|
+
extension artifact. Server-image delivery must include applicable license,
|
|
19
|
+
notices and exact corresponding source/build/install materials. Reassess
|
|
20
|
+
obligations if the server is modified. HTTP separation is not a blanket
|
|
21
|
+
exemption from license obligations.
|
|
22
|
+
|
|
23
|
+
The release candidate uses pi 0.85.1 after the initial pi 0.82.1 combination
|
|
24
|
+
reported undici and brace-expansion vulnerabilities. Root overrides did not
|
|
25
|
+
replace the old package's bundled shrinkwrap. The current exact peer resolves
|
|
26
|
+
undici 8.9.0 and brace-expansion 5.0.9 and passes `npm audit` with zero findings
|
|
27
|
+
on 2026-09-18. Re-audit the final host image and later releases separately.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NativeToolWorker, type WorkerOptions } from './tool-worker.js';
|
|
2
|
+
export interface ProtectedPaths {
|
|
3
|
+
workspace: string;
|
|
4
|
+
agentDir: string;
|
|
5
|
+
stateDir: string;
|
|
6
|
+
installationDir: string;
|
|
7
|
+
hostUid: number;
|
|
8
|
+
workerUid: number;
|
|
9
|
+
workerGid: number;
|
|
10
|
+
}
|
|
11
|
+
/** Canonical paths returned here must be used throughout the subsequent bootstrap. */
|
|
12
|
+
export declare function validateProtectedPaths(options: ProtectedPaths): Promise<ProtectedPaths>;
|
|
13
|
+
/** Single-host CLI bootstrap. Multi-user hosts must provision each worker explicitly. */
|
|
14
|
+
export declare function bootstrapProtectedWorker(options: ProtectedPaths & Omit<WorkerOptions, 'workspace' | 'hostUid' | 'workerUid' | 'workerGid'> & {
|
|
15
|
+
hostGid: number;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
worker: NativeToolWorker;
|
|
18
|
+
paths: ProtectedPaths;
|
|
19
|
+
piPackageContext: string;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { lstat, realpath, readdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, relative, isAbsolute } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { NativeToolWorker } from './tool-worker.js';
|
|
5
|
+
function contains(parent, child) {
|
|
6
|
+
const path = relative(parent, child);
|
|
7
|
+
return path === '' || (!path.startsWith('../') && path !== '..' && !isAbsolute(path));
|
|
8
|
+
}
|
|
9
|
+
function permissions(stat, options) {
|
|
10
|
+
if (stat.uid === options.workerUid)
|
|
11
|
+
return (stat.mode >> 6) & 7;
|
|
12
|
+
if (stat.gid === options.workerGid)
|
|
13
|
+
return (stat.mode >> 3) & 7;
|
|
14
|
+
return stat.mode & 7;
|
|
15
|
+
}
|
|
16
|
+
async function protectedAncestors(path, options, readable) {
|
|
17
|
+
let current = path;
|
|
18
|
+
let child;
|
|
19
|
+
for (;;) {
|
|
20
|
+
const stat = await lstat(current);
|
|
21
|
+
const access = permissions(stat, options);
|
|
22
|
+
// A sticky shared ancestor may be writable, but the worker must own neither
|
|
23
|
+
// it nor the protected child entry. Without sticky semantics, replacement
|
|
24
|
+
// remains possible even when the protected child itself is read-only.
|
|
25
|
+
const stickyProtection = child && (stat.mode & 0o1000) && stat.uid !== options.workerUid && child.uid !== options.workerUid;
|
|
26
|
+
if (stat.uid === options.workerUid || ((access & 2) && !stickyProtection))
|
|
27
|
+
throw new Error('WORKER_CAN_REPLACE_PROTECTED_PATH');
|
|
28
|
+
if (readable && (!(access & 1) || (current === path && !(access & 4))))
|
|
29
|
+
throw new Error('WORKER_CANNOT_LOAD_INSTALLATION');
|
|
30
|
+
const parent = dirname(current);
|
|
31
|
+
if (parent === current)
|
|
32
|
+
return;
|
|
33
|
+
child = stat;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function verifyInstallationTree(root, options) {
|
|
38
|
+
const visited = new Set();
|
|
39
|
+
const pending = [root];
|
|
40
|
+
while (pending.length) {
|
|
41
|
+
const path = await realpath(pending.pop());
|
|
42
|
+
if (!contains(root, path))
|
|
43
|
+
throw new Error('INSTALLATION_LINK_ESCAPES_ROOT');
|
|
44
|
+
if (visited.has(path))
|
|
45
|
+
continue;
|
|
46
|
+
visited.add(path);
|
|
47
|
+
const stat = await lstat(path);
|
|
48
|
+
const access = permissions(stat, options);
|
|
49
|
+
if (stat.uid === options.workerUid || (access & 2))
|
|
50
|
+
throw new Error('WORKER_CAN_REPLACE_PROTECTED_PATH');
|
|
51
|
+
if (stat.isDirectory()) {
|
|
52
|
+
for (const entry of await readdir(path))
|
|
53
|
+
pending.push(`${path}/${entry}`);
|
|
54
|
+
}
|
|
55
|
+
else if (!stat.isFile())
|
|
56
|
+
throw new Error('UNSUPPORTED_INSTALLATION_ENTRY');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Canonical paths returned here must be used throughout the subsequent bootstrap. */
|
|
60
|
+
export async function validateProtectedPaths(options) {
|
|
61
|
+
if (![options.hostUid, options.workerUid, options.workerGid].every(id => Number.isSafeInteger(id) && id > 0)
|
|
62
|
+
|| options.hostUid === options.workerUid)
|
|
63
|
+
throw new Error('INVALID_MEMORY_IDENTITIES');
|
|
64
|
+
const keys = ['workspace', 'agentDir', 'stateDir', 'installationDir'];
|
|
65
|
+
const paths = { ...options };
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
if (!isAbsolute(paths[key]))
|
|
68
|
+
throw new Error('MEMORY_PATH_MUST_BE_ABSOLUTE');
|
|
69
|
+
paths[key] = await realpath(paths[key]);
|
|
70
|
+
if (!(await lstat(paths[key])).isDirectory())
|
|
71
|
+
throw new Error('MEMORY_PATH_NOT_DIRECTORY');
|
|
72
|
+
}
|
|
73
|
+
for (const key of ['agentDir', 'stateDir', 'installationDir']) {
|
|
74
|
+
if (contains(paths.workspace, paths[key]) || contains(paths[key], paths.workspace))
|
|
75
|
+
throw new Error('MEMORY_WORKSPACE_OVERLAP');
|
|
76
|
+
await protectedAncestors(paths[key], paths, key === 'installationDir');
|
|
77
|
+
}
|
|
78
|
+
for (const key of ['agentDir', 'stateDir']) {
|
|
79
|
+
if (contains(paths.installationDir, paths[key]) || contains(paths[key], paths.installationDir))
|
|
80
|
+
throw new Error('MEMORY_INSTALLATION_STATE_OVERLAP');
|
|
81
|
+
const stat = await lstat(paths[key]);
|
|
82
|
+
if (stat.uid !== paths.hostUid || (stat.mode & 0o077) !== 0)
|
|
83
|
+
throw new Error('MEMORY_PRIVATE_DIRECTORY_REQUIRED');
|
|
84
|
+
}
|
|
85
|
+
if ((permissions(await lstat(paths.workspace), paths) & 7) !== 7)
|
|
86
|
+
throw new Error('WORKER_CANNOT_USE_WORKSPACE');
|
|
87
|
+
await verifyInstallationTree(paths.installationDir, paths);
|
|
88
|
+
return paths;
|
|
89
|
+
}
|
|
90
|
+
/** Single-host CLI bootstrap. Multi-user hosts must provision each worker explicitly. */
|
|
91
|
+
export async function bootstrapProtectedWorker(options) {
|
|
92
|
+
if (process.platform !== 'linux' || process.getuid?.() !== 0
|
|
93
|
+
|| !Number.isSafeInteger(options.hostGid) || options.hostGid <= 0)
|
|
94
|
+
throw new Error('PRIVILEGED_LINUX_BOOTSTRAP_REQUIRED');
|
|
95
|
+
const paths = await validateProtectedPaths(options);
|
|
96
|
+
const extensionEntry = await realpath(fileURLToPath(import.meta.url));
|
|
97
|
+
if (!contains(paths.installationDir, extensionEntry))
|
|
98
|
+
throw new Error('EXTENSION_OUTSIDE_PROTECTED_INSTALLATION');
|
|
99
|
+
const piPackageContext = await realpath(options.piPackageContext);
|
|
100
|
+
if (!contains(paths.installationDir, piPackageContext) || !(await lstat(piPackageContext)).isFile()) {
|
|
101
|
+
throw new Error('PI_CONTEXT_OUTSIDE_PROTECTED_INSTALLATION');
|
|
102
|
+
}
|
|
103
|
+
const privilegeGuard = await realpath(options.privilegeGuard);
|
|
104
|
+
await protectedAncestors(privilegeGuard, paths, true);
|
|
105
|
+
await protectedAncestors(await realpath(process.execPath), paths, true);
|
|
106
|
+
const worker = new NativeToolWorker({ ...options, ...paths, piPackageContext, privilegeGuard });
|
|
107
|
+
try {
|
|
108
|
+
process.setgid(options.hostGid);
|
|
109
|
+
process.setuid(paths.hostUid);
|
|
110
|
+
await worker.assertIsolated();
|
|
111
|
+
return { worker, paths, piPackageContext };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
worker.close();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
4
|
+
import { runProtectedPi } from './launcher.js';
|
|
5
|
+
try {
|
|
6
|
+
const [profilePath, ...args] = process.argv.slice(2);
|
|
7
|
+
if (!profilePath || !isAbsolute(profilePath))
|
|
8
|
+
throw new Error('ABSOLUTE_LAUNCH_PROFILE_REQUIRED');
|
|
9
|
+
const canonical = await realpath(profilePath);
|
|
10
|
+
// The bootstrap profile is administrator-owned data. Validate the whole path
|
|
11
|
+
// before parsing it; it must not be replaceable from an untrusted workspace.
|
|
12
|
+
for (let current = canonical;; current = dirname(current)) {
|
|
13
|
+
const stat = await lstat(current);
|
|
14
|
+
if (stat.uid !== 0 || (stat.mode & 0o022) !== 0)
|
|
15
|
+
throw new Error('ADMIN_OWNED_LAUNCH_PROFILE_REQUIRED');
|
|
16
|
+
if (current === canonical && !stat.isFile())
|
|
17
|
+
throw new Error('INVALID_LAUNCH_PROFILE');
|
|
18
|
+
if (dirname(current) === current)
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
const profile = JSON.parse(await readFile(canonical, 'utf8'));
|
|
22
|
+
await runProtectedPi(profile, args);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Configuration and provider failures can contain keys or endpoints.
|
|
26
|
+
console.error('Protected pi startup failed. Check the administrator-owned launch profile and private host configuration.');
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Operation, type Owner, type Source, type StateStore } from './types.js';
|
|
2
|
+
/** Each method is owner-bound. Reconciliation never mutates the service. */
|
|
3
|
+
export interface DeliveryTransport {
|
|
4
|
+
readonly owner: Owner;
|
|
5
|
+
createSession(id: string): Promise<void>;
|
|
6
|
+
sessionExists(id: string): Promise<boolean>;
|
|
7
|
+
append(operation: Readonly<Operation>): Promise<void>;
|
|
8
|
+
hasSource(operation: Readonly<Operation>): Promise<boolean>;
|
|
9
|
+
commit(id: string): Promise<{
|
|
10
|
+
taskId: string;
|
|
11
|
+
archiveId?: string;
|
|
12
|
+
}>;
|
|
13
|
+
findCommit(id: string): Promise<{
|
|
14
|
+
taskId: string;
|
|
15
|
+
archiveId?: string;
|
|
16
|
+
} | null>;
|
|
17
|
+
inspect(operation: Readonly<Operation>): Promise<{
|
|
18
|
+
status: 'processing';
|
|
19
|
+
} | {
|
|
20
|
+
status: 'failed';
|
|
21
|
+
code: string;
|
|
22
|
+
} | {
|
|
23
|
+
status: 'ready';
|
|
24
|
+
archiveId: string;
|
|
25
|
+
memoryUris: string[];
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export declare class MemoryDelivery {
|
|
29
|
+
#private;
|
|
30
|
+
constructor(options: {
|
|
31
|
+
store: StateStore;
|
|
32
|
+
transport: DeliveryTransport;
|
|
33
|
+
maxPayloadBytes: number;
|
|
34
|
+
});
|
|
35
|
+
get owner(): Owner;
|
|
36
|
+
enable(policyVersion: string): Promise<void>;
|
|
37
|
+
pause(): Promise<void>;
|
|
38
|
+
save(source: Source, content: string, scope?: string | null): Promise<Operation | {
|
|
39
|
+
phase: 'blocked';
|
|
40
|
+
errorCode: string;
|
|
41
|
+
}>;
|
|
42
|
+
/** Advances at most one remote mutation. The caller owns scheduling/lifetime. */
|
|
43
|
+
advance(id: string): Promise<void>;
|
|
44
|
+
}
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { checkedOwner, sameOwner } from './types.js';
|
|
3
|
+
const terminal = new Set(['ready', 'failed', 'blocked_by_pause', 'blocked']);
|
|
4
|
+
const unsent = new Set(['queued', 'session_created', 'message_delivered']);
|
|
5
|
+
export class MemoryDelivery {
|
|
6
|
+
#store;
|
|
7
|
+
#transport;
|
|
8
|
+
#maxPayloadBytes;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
checkedOwner(options.store.owner);
|
|
11
|
+
if (!sameOwner(options.store.owner, options.transport.owner))
|
|
12
|
+
throw new Error('MEMORY_OWNER_MISMATCH');
|
|
13
|
+
if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes <= 0)
|
|
14
|
+
throw new Error('INVALID_MEMORY_LIMIT');
|
|
15
|
+
this.#store = options.store;
|
|
16
|
+
this.#transport = options.transport;
|
|
17
|
+
this.#maxPayloadBytes = options.maxPayloadBytes;
|
|
18
|
+
}
|
|
19
|
+
get owner() { return this.#store.owner; }
|
|
20
|
+
async enable(policyVersion) {
|
|
21
|
+
if (!policyVersion)
|
|
22
|
+
throw new Error('MISSING_POLICY_VERSION');
|
|
23
|
+
await this.#store.transact(state => {
|
|
24
|
+
state.authorization = { enabled: true, automaticCollection: false,
|
|
25
|
+
epoch: state.authorization.epoch + 1, effectiveAt: new Date().toISOString(), policyVersion };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async pause() {
|
|
29
|
+
await this.#store.transact(state => {
|
|
30
|
+
state.authorization.enabled = false;
|
|
31
|
+
state.authorization.epoch++;
|
|
32
|
+
state.authorization.effectiveAt = new Date().toISOString();
|
|
33
|
+
for (const operation of Object.values(state.operations)) {
|
|
34
|
+
if (unsent.has(operation.phase)) {
|
|
35
|
+
operation.phase = 'blocked_by_pause';
|
|
36
|
+
delete operation.payload;
|
|
37
|
+
operation.updatedAt = new Date().toISOString();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async save(source, content, scope = null) {
|
|
43
|
+
if (typeof content !== 'string' || !content.trim() || Buffer.byteLength(content) > this.#maxPayloadBytes
|
|
44
|
+
|| !source || ![source.sessionId, source.entryId, source.branchId, source.contentVersion].every(x => typeof x === 'string' && x.length > 0)) {
|
|
45
|
+
throw new Error('INVALID_MEMORY_SOURCE');
|
|
46
|
+
}
|
|
47
|
+
// Scope is supplied by the host, never copied from model input.
|
|
48
|
+
if (scope !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(scope))
|
|
49
|
+
throw new Error('INVALID_MEMORY_SCOPE');
|
|
50
|
+
return this.#store.transact(state => {
|
|
51
|
+
if (!state.authorization.enabled)
|
|
52
|
+
return { phase: 'blocked', errorCode: 'MEMORY_DISABLED' };
|
|
53
|
+
const id = createHash('sha256').update(JSON.stringify([
|
|
54
|
+
state.owner, scope, source, state.authorization.epoch,
|
|
55
|
+
])).digest('hex');
|
|
56
|
+
const previous = state.operations[id];
|
|
57
|
+
if (previous) {
|
|
58
|
+
if (previous.payload !== undefined && previous.payload !== content)
|
|
59
|
+
throw new Error('MEMORY_SOURCE_CONFLICT');
|
|
60
|
+
return structuredClone(previous);
|
|
61
|
+
}
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
const operation = {
|
|
64
|
+
id, owner: state.owner, source: { ...source }, scope, kind: 'explicit',
|
|
65
|
+
authorizationEpoch: state.authorization.epoch, createdAt: now, updatedAt: now,
|
|
66
|
+
phase: 'queued', remoteSessionId: randomUUID(), payload: content,
|
|
67
|
+
};
|
|
68
|
+
state.operations[id] = operation;
|
|
69
|
+
return structuredClone(operation);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Advances at most one remote mutation. The caller owns scheduling/lifetime. */
|
|
73
|
+
async advance(id) {
|
|
74
|
+
if (!/^[a-f0-9]{64}$/.test(id))
|
|
75
|
+
throw new Error('INVALID_MEMORY_OPERATION');
|
|
76
|
+
const operation = await this.#store.transact(state => {
|
|
77
|
+
const current = state.operations[id];
|
|
78
|
+
if (!current || terminal.has(current.phase))
|
|
79
|
+
return null;
|
|
80
|
+
if (unsent.has(current.phase) && (!state.authorization.enabled
|
|
81
|
+
|| current.authorizationEpoch !== state.authorization.epoch)) {
|
|
82
|
+
current.phase = 'blocked_by_pause';
|
|
83
|
+
delete current.payload;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const snapshot = structuredClone(current);
|
|
87
|
+
if (current.phase === 'queued')
|
|
88
|
+
current.phase = 'session_unknown';
|
|
89
|
+
if (current.phase === 'session_created')
|
|
90
|
+
current.phase = 'message_unknown';
|
|
91
|
+
if (current.phase === 'message_delivered')
|
|
92
|
+
current.phase = 'commit_unknown';
|
|
93
|
+
current.updatedAt = new Date().toISOString();
|
|
94
|
+
return snapshot;
|
|
95
|
+
});
|
|
96
|
+
if (!operation)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
switch (operation.phase) {
|
|
100
|
+
case 'queued':
|
|
101
|
+
await this.#transport.createSession(operation.remoteSessionId);
|
|
102
|
+
await this.#transition(id, 'session_unknown', { phase: 'session_created' });
|
|
103
|
+
break;
|
|
104
|
+
case 'session_unknown':
|
|
105
|
+
if (await this.#transport.sessionExists(operation.remoteSessionId)) {
|
|
106
|
+
await this.#transition(id, 'session_unknown', { phase: 'session_created' });
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case 'session_created':
|
|
110
|
+
await this.#transport.append(operation);
|
|
111
|
+
await this.#transition(id, 'message_unknown', { phase: 'message_delivered' });
|
|
112
|
+
break;
|
|
113
|
+
case 'message_unknown':
|
|
114
|
+
if (await this.#transport.hasSource(operation)) {
|
|
115
|
+
await this.#transition(id, 'message_unknown', { phase: 'message_delivered' });
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
case 'message_delivered': {
|
|
119
|
+
const receipt = await this.#transport.commit(operation.remoteSessionId);
|
|
120
|
+
await this.#transition(id, 'commit_unknown', { phase: 'processing', ...receipt });
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case 'commit_unknown': {
|
|
124
|
+
const receipt = await this.#transport.findCommit(operation.remoteSessionId);
|
|
125
|
+
if (receipt)
|
|
126
|
+
await this.#transition(id, 'commit_unknown', { phase: 'processing', ...receipt });
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'processing': {
|
|
130
|
+
const result = await this.#transport.inspect(operation);
|
|
131
|
+
if (result.status === 'failed') {
|
|
132
|
+
await this.#transition(id, 'processing', { phase: 'failed', errorCode: result.code });
|
|
133
|
+
}
|
|
134
|
+
else if (result.status === 'ready') {
|
|
135
|
+
if (!result.memoryUris.length)
|
|
136
|
+
throw new Error('MEMORY_NOT_RETRIEVABLE');
|
|
137
|
+
await this.#transition(id, 'processing', { phase: 'ready', archiveId: result.archiveId, memoryUris: result.memoryUris });
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// The persisted unknown phase survives transport errors. Neither an
|
|
145
|
+
// exception nor an absent receipt authorizes replay of a mutation.
|
|
146
|
+
await this.#store.transact(state => {
|
|
147
|
+
const current = state.operations[id];
|
|
148
|
+
if (current && !terminal.has(current.phase))
|
|
149
|
+
current.errorCode = 'MEMORY_RECONCILIATION_REQUIRED';
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async #transition(id, expected, patch) {
|
|
154
|
+
await this.#store.transact(state => {
|
|
155
|
+
const current = state.operations[id];
|
|
156
|
+
if (current?.phase !== expected)
|
|
157
|
+
return;
|
|
158
|
+
Object.assign(current, patch, { updatedAt: new Date().toISOString() });
|
|
159
|
+
if (!patch.errorCode)
|
|
160
|
+
delete current.errorCode;
|
|
161
|
+
current.deliveryAttempts = 0;
|
|
162
|
+
current.nextAttemptAt = 0;
|
|
163
|
+
if (terminal.has(current.phase))
|
|
164
|
+
delete current.payload;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { type DeliveryTransport } from './delivery.js';
|
|
3
|
+
import type { RecalledMemory } from './openviking-client.js';
|
|
4
|
+
import { type Owner, type StateStore } from './types.js';
|
|
5
|
+
export { protectedMemoryResources } from './resource-profile.js';
|
|
6
|
+
export { FileStateStore } from './state-store.js';
|
|
7
|
+
export { DeliveryScheduler } from './scheduler.js';
|
|
8
|
+
export { MemoryDelivery } from './delivery.js';
|
|
9
|
+
export { OwnerMemoryClient } from './openviking-client.js';
|
|
10
|
+
export type { Owner, Source, Operation, StateStore } from './types.js';
|
|
11
|
+
export interface MemoryExtensionOptions {
|
|
12
|
+
owner: Owner;
|
|
13
|
+
client: DeliveryTransport & {
|
|
14
|
+
readMemory?(uri: string): Promise<string>;
|
|
15
|
+
recall(query: string, limit: number, signal?: AbortSignal): Promise<RecalledMemory[]>;
|
|
16
|
+
};
|
|
17
|
+
stateStore: StateStore;
|
|
18
|
+
scope?: string | null;
|
|
19
|
+
/** Host implementation must verify the live isolated worker, not a config flag. */
|
|
20
|
+
assertToolIsolation(): Promise<void>;
|
|
21
|
+
policy: {
|
|
22
|
+
maxPayloadBytes: number;
|
|
23
|
+
recallTimeoutMs: number;
|
|
24
|
+
recallTokenBudget: number;
|
|
25
|
+
recallLimit: number;
|
|
26
|
+
minimumScore: number;
|
|
27
|
+
/** Exact tokenizer for the active model, supplied by the trusted host. */
|
|
28
|
+
countTokens(text: string): number;
|
|
29
|
+
};
|
|
30
|
+
/** Wake the owner-level bounded scheduler; does not perform a foreground flush. */
|
|
31
|
+
wakeDelivery(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createOpenVikingExtension(options: MemoryExtensionOptions): ExtensionFactory;
|