open-managed-agents 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 +93 -0
- package/README.md +248 -0
- package/bin/oma.mjs +499 -0
- package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
- package/dist/scripts/alpha-smoke-models.mjs +92 -0
- package/dist/scripts/alpha-smoke.mjs +581 -0
- package/dist/scripts/oma-doctor.js +230 -0
- package/dist/scripts/oma-models.js +299 -0
- package/dist/scripts/oma-workspaces.js +133 -0
- package/dist/src/control-plane/admin/auth.js +43 -0
- package/dist/src/control-plane/admin/routes.js +76 -0
- package/dist/src/control-plane/admin/service.js +102 -0
- package/dist/src/control-plane/admission.js +90 -0
- package/dist/src/control-plane/agents/routes.js +63 -0
- package/dist/src/control-plane/agents/service.js +687 -0
- package/dist/src/control-plane/agents/store.js +391 -0
- package/dist/src/control-plane/agents/types.js +1 -0
- package/dist/src/control-plane/api-constants.js +3 -0
- package/dist/src/control-plane/app.js +907 -0
- package/dist/src/control-plane/console/auth.js +68 -0
- package/dist/src/control-plane/console/static.js +112 -0
- package/dist/src/control-plane/deployment-runtime-config.js +328 -0
- package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
- package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
- package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
- package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
- package/dist/src/control-plane/deployment-storage.js +354 -0
- package/dist/src/control-plane/egress/image.js +48 -0
- package/dist/src/control-plane/egress/policy.js +571 -0
- package/dist/src/control-plane/egress/presets.js +77 -0
- package/dist/src/control-plane/egress/proxy.js +72 -0
- package/dist/src/control-plane/egress/ssrf.js +106 -0
- package/dist/src/control-plane/egress/utils/debug.js +14 -0
- package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
- package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
- package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
- package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
- package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
- package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
- package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
- package/dist/src/control-plane/environments/routes.js +54 -0
- package/dist/src/control-plane/environments/service.js +112 -0
- package/dist/src/control-plane/environments/store.js +132 -0
- package/dist/src/control-plane/environments/types.js +1 -0
- package/dist/src/control-plane/errors.js +81 -0
- package/dist/src/control-plane/events/broadcaster.js +217 -0
- package/dist/src/control-plane/events/constants.js +2 -0
- package/dist/src/control-plane/events/persist.js +39 -0
- package/dist/src/control-plane/events/request.js +190 -0
- package/dist/src/control-plane/events/routes.js +159 -0
- package/dist/src/control-plane/events/runtime-helpers.js +134 -0
- package/dist/src/control-plane/events/service.js +1985 -0
- package/dist/src/control-plane/events/session-guards.js +29 -0
- package/dist/src/control-plane/events/sse.js +6 -0
- package/dist/src/control-plane/events/store.js +772 -0
- package/dist/src/control-plane/events/tool-persistence.js +102 -0
- package/dist/src/control-plane/events/types.js +21 -0
- package/dist/src/control-plane/files/routes.js +121 -0
- package/dist/src/control-plane/files/service.js +65 -0
- package/dist/src/control-plane/files/store-common.js +87 -0
- package/dist/src/control-plane/files/store-local.js +488 -0
- package/dist/src/control-plane/files/store-memory.js +270 -0
- package/dist/src/control-plane/files/store.js +2 -0
- package/dist/src/control-plane/files/types.js +7 -0
- package/dist/src/control-plane/http.js +25 -0
- package/dist/src/control-plane/ids.js +43 -0
- package/dist/src/control-plane/logging.js +263 -0
- package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
- package/dist/src/control-plane/models/catalog.js +78 -0
- package/dist/src/control-plane/models/config-security.js +376 -0
- package/dist/src/control-plane/models/deployment-config.js +93 -0
- package/dist/src/control-plane/models/routes.js +32 -0
- package/dist/src/control-plane/models/service.js +138 -0
- package/dist/src/control-plane/observability/instruments.js +65 -0
- package/dist/src/control-plane/observability/metrics.js +125 -0
- package/dist/src/control-plane/observability/routes.js +95 -0
- package/dist/src/control-plane/openapi/document.js +282 -0
- package/dist/src/control-plane/openapi/routes.js +78 -0
- package/dist/src/control-plane/request-idempotency.js +85 -0
- package/dist/src/control-plane/secrets/envelope.js +132 -0
- package/dist/src/control-plane/secrets/master-key.js +44 -0
- package/dist/src/control-plane/secrets/routes.js +20 -0
- package/dist/src/control-plane/secrets/service.js +71 -0
- package/dist/src/control-plane/secrets/store.js +212 -0
- package/dist/src/control-plane/secrets/types.js +3 -0
- package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
- package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
- package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
- package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
- package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
- package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
- package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
- package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
- package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
- package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
- package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
- package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
- package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
- package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
- package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
- package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
- package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
- package/dist/src/control-plane/sessions/pi/translator.js +100 -0
- package/dist/src/control-plane/sessions/request.js +164 -0
- package/dist/src/control-plane/sessions/resources.js +63 -0
- package/dist/src/control-plane/sessions/routes.js +88 -0
- package/dist/src/control-plane/sessions/serialize.js +24 -0
- package/dist/src/control-plane/sessions/service.js +733 -0
- package/dist/src/control-plane/sessions/store.js +538 -0
- package/dist/src/control-plane/sessions/types.js +1 -0
- package/dist/src/control-plane/skills/archive.js +169 -0
- package/dist/src/control-plane/skills/routes.js +41 -0
- package/dist/src/control-plane/skills/service.js +29 -0
- package/dist/src/control-plane/skills/store.js +233 -0
- package/dist/src/control-plane/skills/types.js +5 -0
- package/dist/src/control-plane/sqlite-transaction.js +15 -0
- package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
- package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
- package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
- package/dist/src/control-plane/vaults/routes.js +66 -0
- package/dist/src/control-plane/vaults/service.js +553 -0
- package/dist/src/control-plane/vaults/store.js +691 -0
- package/dist/src/control-plane/vaults/types.js +1 -0
- package/dist/src/control-plane/wake-loop.js +65 -0
- package/dist/src/control-plane/wiring.js +92 -0
- package/dist/src/control-plane/workspace.js +4 -0
- package/dist/src/control-plane/workspaces/store.js +158 -0
- package/dist/src/egress-proxy-main.js +103 -0
- package/dist/src/main.js +124 -0
- package/dist/src/types/agents.js +1 -0
- package/dist/src/types/common.js +1 -0
- package/dist/src/types/environments.js +1 -0
- package/dist/src/types/events.js +121 -0
- package/dist/src/types/files.js +1 -0
- package/dist/src/types/json.js +19 -0
- package/dist/src/types/sessions.js +1 -0
- package/dist/ui/managed-agents-console/README.md +52 -0
- package/dist/ui/managed-agents-console/docs/agents.md +23 -0
- package/dist/ui/managed-agents-console/docs/console.md +23 -0
- package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
- package/dist/ui/managed-agents-console/docs/environments.md +27 -0
- package/dist/ui/managed-agents-console/docs/events.md +19 -0
- package/dist/ui/managed-agents-console/docs/files.md +17 -0
- package/dist/ui/managed-agents-console/docs/github.md +11 -0
- package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
- package/dist/ui/managed-agents-console/docs/memory.md +11 -0
- package/dist/ui/managed-agents-console/docs/migration.md +17 -0
- package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
- package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
- package/dist/ui/managed-agents-console/docs/overview.md +41 -0
- package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
- package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
- package/dist/ui/managed-agents-console/docs/reference.md +27 -0
- package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
- package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
- package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
- package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
- package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
- package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
- package/dist/ui/managed-agents-console/docs/skills.md +15 -0
- package/dist/ui/managed-agents-console/docs/tools.md +23 -0
- package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
- package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
- package/dist/ui/managed-agents-console/index.html +47 -0
- package/dist/ui/managed-agents-console/serve.mjs +116 -0
- package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
- package/dist/ui/managed-agents-console/src/api.js +1043 -0
- package/dist/ui/managed-agents-console/src/app.jsx +633 -0
- package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
- package/dist/ui/managed-agents-console/src/console.css +733 -0
- package/dist/ui/managed-agents-console/src/data.js +188 -0
- package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
- package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
- package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
- package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
- package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
- package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
- package/dist/ui/managed-agents-console/src/sse.js +185 -0
- package/dist/ui/managed-agents-console/src/states.jsx +90 -0
- package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
- package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
- package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
- package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
- package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
- package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
- package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
- package/dist/ui/openapi-docs/VENDOR.md +27 -0
- package/dist/ui/openapi-docs/index.html +16 -0
- package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
- package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
- package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
- package/package.json +53 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Envelope-encryption primitives for the SecretsStore (ADR 0016 §4; shape
|
|
2
|
+
// validated by scratch/45-envelope-encryption-probe.ts, 11/11).
|
|
3
|
+
//
|
|
4
|
+
// Each secret is encrypted under a random per-secret DEK (AES-256-GCM); the
|
|
5
|
+
// DEK is wrapped by a KEK derived HKDF-SHA256(masterKey, info="oma-kek:<kekId>").
|
|
6
|
+
// Both layers bind an AAD (`<version>:<recordId>` on the ciphertext,
|
|
7
|
+
// `<version>:<kekId>:<recordId>` on the wrap) so a ciphertext copied onto
|
|
8
|
+
// another record fails to open. KEK rotation rewraps the DEK only — the
|
|
9
|
+
// ciphertext bytes are never touched.
|
|
10
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, } from "node:crypto";
|
|
11
|
+
export const ENVELOPE_FORMAT_VERSION = "v1";
|
|
12
|
+
export const MASTER_KEY_BYTES = 32;
|
|
13
|
+
// kekId is a fingerprint of the master key (first 12 hex of SHA-256), not a
|
|
14
|
+
// counter: `open` can tell "sealed under a different master key" apart from
|
|
15
|
+
// corruption, and rotation selects rows to rewrap by the old fingerprint.
|
|
16
|
+
export function kekIdFor(masterKey) {
|
|
17
|
+
assertMasterKey(masterKey);
|
|
18
|
+
return createHash("sha256").update(masterKey).digest("hex").slice(0, 12);
|
|
19
|
+
}
|
|
20
|
+
export function assertMasterKey(masterKey) {
|
|
21
|
+
if (!Buffer.isBuffer(masterKey) || masterKey.length !== MASTER_KEY_BYTES) {
|
|
22
|
+
throw new Error(`master key must be exactly ${MASTER_KEY_BYTES} random bytes ` +
|
|
23
|
+
"(HKDF is not a password KDF — see ADR 0016 §4)");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// KEK = HKDF-SHA256(masterKey, info=`oma-kek:<kekId>`): the raw master bytes
|
|
27
|
+
// are never used directly as a cipher key.
|
|
28
|
+
function deriveKek(masterKey, kekId) {
|
|
29
|
+
return Buffer.from(hkdfSync("sha256", masterKey, Buffer.alloc(0), `oma-kek:${kekId}`, 32));
|
|
30
|
+
}
|
|
31
|
+
function gcmSeal(key, plaintext, aad) {
|
|
32
|
+
const iv = randomBytes(12);
|
|
33
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
34
|
+
cipher.setAAD(aad);
|
|
35
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
36
|
+
return { iv, tag: cipher.getAuthTag(), ct };
|
|
37
|
+
}
|
|
38
|
+
function gcmOpen(key, iv, tag, ct, aad) {
|
|
39
|
+
// Pin authTagLength = 16 AND reject short tags explicitly. Without the pin
|
|
40
|
+
// node:crypto ACCEPTS a truncated tag (probe 45 (6b): a 4-byte tag decrypts
|
|
41
|
+
// with only a DEP0182 warning), degrading forgery resistance from 2^128 to
|
|
42
|
+
// 2^32 — and the tag is an attacker-writable DB column.
|
|
43
|
+
if (tag.length !== 16) {
|
|
44
|
+
throw new Error(`bad auth tag length: ${tag.length} (expected 16)`);
|
|
45
|
+
}
|
|
46
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv, {
|
|
47
|
+
authTagLength: 16,
|
|
48
|
+
});
|
|
49
|
+
decipher.setAuthTag(tag);
|
|
50
|
+
decipher.setAAD(aad);
|
|
51
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
52
|
+
}
|
|
53
|
+
export function seal(masterKey, recordId, plaintext) {
|
|
54
|
+
const kekId = kekIdFor(masterKey);
|
|
55
|
+
const kek = deriveKek(masterKey, kekId);
|
|
56
|
+
const dek = randomBytes(32);
|
|
57
|
+
try {
|
|
58
|
+
const aad = Buffer.from(`${ENVELOPE_FORMAT_VERSION}:${recordId}`);
|
|
59
|
+
const body = gcmSeal(dek, plaintext, aad);
|
|
60
|
+
// The wrap layer binds the same record with the kekId added — defense in
|
|
61
|
+
// depth, and the wrap is exactly the seam a KMS backend plugs into later.
|
|
62
|
+
const wrapAad = Buffer.from(`${ENVELOPE_FORMAT_VERSION}:${kekId}:${recordId}`);
|
|
63
|
+
const wrapped = gcmSeal(kek, dek, wrapAad);
|
|
64
|
+
return {
|
|
65
|
+
version: ENVELOPE_FORMAT_VERSION,
|
|
66
|
+
kekId,
|
|
67
|
+
wrapIv: wrapped.iv,
|
|
68
|
+
wrapTag: wrapped.tag,
|
|
69
|
+
wrappedDek: wrapped.ct,
|
|
70
|
+
ctIv: body.iv,
|
|
71
|
+
ctTag: body.tag,
|
|
72
|
+
ct: body.ct,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
dek.fill(0); // best-effort scrub; Node makes no hard guarantee
|
|
77
|
+
kek.fill(0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function open(masterKey, recordId, sealed) {
|
|
81
|
+
const currentKekId = kekIdFor(masterKey);
|
|
82
|
+
if (sealed.kekId !== currentKekId) {
|
|
83
|
+
throw new Error(`secret is sealed under a different master key (kekId ${sealed.kekId}, ` +
|
|
84
|
+
`current key is ${currentKekId}) — rotate or supply the original key`);
|
|
85
|
+
}
|
|
86
|
+
const kek = deriveKek(masterKey, sealed.kekId);
|
|
87
|
+
let dek;
|
|
88
|
+
try {
|
|
89
|
+
const wrapAad = Buffer.from(`${sealed.version}:${sealed.kekId}:${recordId}`);
|
|
90
|
+
dek = gcmOpen(kek, sealed.wrapIv, sealed.wrapTag, sealed.wrappedDek, wrapAad);
|
|
91
|
+
const aad = Buffer.from(`${sealed.version}:${recordId}`);
|
|
92
|
+
return gcmOpen(dek, sealed.ctIv, sealed.ctTag, sealed.ct, aad);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
dek?.fill(0);
|
|
96
|
+
kek.fill(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Rotation: unwrap the DEK with the old master key, rewrap with the new one.
|
|
100
|
+
// ct / ctIv / ctTag are deliberately untouched — the point of the envelope.
|
|
101
|
+
// Note (ADR 0016 §4): this limits blast radius going forward; it does not
|
|
102
|
+
// revoke a key that leaked alongside a pre-rotation DB copy.
|
|
103
|
+
export function rewrap(oldMasterKey, newMasterKey, recordId, sealed) {
|
|
104
|
+
const oldKekId = kekIdFor(oldMasterKey);
|
|
105
|
+
if (sealed.kekId !== oldKekId) {
|
|
106
|
+
throw new Error(`cannot rewrap: secret is sealed under kekId ${sealed.kekId}, ` +
|
|
107
|
+
`but the supplied old key is ${oldKekId}`);
|
|
108
|
+
}
|
|
109
|
+
const newKekId = kekIdFor(newMasterKey);
|
|
110
|
+
const oldKek = deriveKek(oldMasterKey, sealed.kekId);
|
|
111
|
+
const newKek = deriveKek(newMasterKey, newKekId);
|
|
112
|
+
let dek;
|
|
113
|
+
try {
|
|
114
|
+
const oldWrapAad = Buffer.from(`${sealed.version}:${sealed.kekId}:${recordId}`);
|
|
115
|
+
dek = gcmOpen(oldKek, sealed.wrapIv, sealed.wrapTag, sealed.wrappedDek, oldWrapAad);
|
|
116
|
+
const newWrapAad = Buffer.from(`${sealed.version}:${newKekId}:${recordId}`);
|
|
117
|
+
const rewrapped = gcmSeal(newKek, dek, newWrapAad);
|
|
118
|
+
return {
|
|
119
|
+
...sealed,
|
|
120
|
+
kekId: newKekId,
|
|
121
|
+
wrapIv: rewrapped.iv,
|
|
122
|
+
wrapTag: rewrapped.tag,
|
|
123
|
+
wrappedDek: rewrapped.ct,
|
|
124
|
+
// ct / ctIv / ctTag deliberately unchanged
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
dek?.fill(0);
|
|
129
|
+
oldKek.fill(0);
|
|
130
|
+
newKek.fill(0);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Master-key loading contract (ADR 0016 §4). The key is exactly 32 random
|
|
2
|
+
// bytes, base64-encoded, supplied via OMA_MASTER_KEY (the value) or
|
|
3
|
+
// OMA_MASTER_KEY_FILE (path to a file holding the value — the docker-compose
|
|
4
|
+
// secrets idiom). Anything else is refused: HKDF is not a password KDF, and a
|
|
5
|
+
// low-entropy passphrase here would make the DB offline-brute-forceable.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { MASTER_KEY_BYTES } from "./envelope.js";
|
|
9
|
+
export const MASTER_KEY_ENV = "OMA_MASTER_KEY";
|
|
10
|
+
export const MASTER_KEY_FILE_ENV = "OMA_MASTER_KEY_FILE";
|
|
11
|
+
// Fresh base64 master key, for first-run instructions and docs.
|
|
12
|
+
export function generateMasterKey() {
|
|
13
|
+
return randomBytes(MASTER_KEY_BYTES).toString("base64");
|
|
14
|
+
}
|
|
15
|
+
// Strict parse: canonical base64 of exactly 32 bytes. Buffer.from(_, "base64")
|
|
16
|
+
// is lenient (ignores junk, tolerates truncation), so we require the decoded
|
|
17
|
+
// bytes to re-encode to the exact input — this rejects passphrases, hex, and
|
|
18
|
+
// mangled keys instead of silently deriving from garbage.
|
|
19
|
+
export function parseMasterKey(value, source) {
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
const decoded = Buffer.from(trimmed, "base64");
|
|
22
|
+
if (decoded.length !== MASTER_KEY_BYTES ||
|
|
23
|
+
decoded.toString("base64") !== trimmed) {
|
|
24
|
+
throw new Error(`${source} must be exactly ${MASTER_KEY_BYTES} random bytes, ` +
|
|
25
|
+
`base64-encoded (generate one with: node -e "console.log(` +
|
|
26
|
+
`require('crypto').randomBytes(32).toString('base64'))")`);
|
|
27
|
+
}
|
|
28
|
+
return decoded;
|
|
29
|
+
}
|
|
30
|
+
export function loadMasterKey(env = process.env) {
|
|
31
|
+
const direct = env[MASTER_KEY_ENV];
|
|
32
|
+
const file = env[MASTER_KEY_FILE_ENV];
|
|
33
|
+
if (direct !== undefined && file !== undefined) {
|
|
34
|
+
throw new Error(`set exactly one of ${MASTER_KEY_ENV} or ${MASTER_KEY_FILE_ENV}, not both`);
|
|
35
|
+
}
|
|
36
|
+
if (direct !== undefined) {
|
|
37
|
+
return parseMasterKey(direct, MASTER_KEY_ENV);
|
|
38
|
+
}
|
|
39
|
+
if (file !== undefined) {
|
|
40
|
+
return parseMasterKey(readFileSync(file, "utf8"), `${MASTER_KEY_FILE_ENV} (${file})`);
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`secrets require a master key: set ${MASTER_KEY_ENV} (base64) or ` +
|
|
43
|
+
`${MASTER_KEY_FILE_ENV} (path to a file holding the base64 value)`);
|
|
44
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { parseJsonBody } from "../http.js";
|
|
3
|
+
import { workspaceIdFrom } from "../workspace.js";
|
|
4
|
+
// OMA-specific surface (plan 0117e-2): POST upserts { name, value } and
|
|
5
|
+
// returns metadata only — no route ever returns a secret value.
|
|
6
|
+
export function secretsRoutes(service) {
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
app.post("/", async (c) => {
|
|
9
|
+
const body = await parseJsonBody(c.req);
|
|
10
|
+
return c.json(service.create(workspaceIdFrom(c), body), 201);
|
|
11
|
+
});
|
|
12
|
+
app.get("/", (c) => {
|
|
13
|
+
return c.json(service.list(workspaceIdFrom(c)), 200);
|
|
14
|
+
});
|
|
15
|
+
app.delete("/:name", (c) => {
|
|
16
|
+
service.delete(workspaceIdFrom(c), c.req.param("name"));
|
|
17
|
+
return c.body(null, 204);
|
|
18
|
+
});
|
|
19
|
+
return app;
|
|
20
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Minimal workspace-scoped secrets management surface (plan 0117e-2, ADR
|
|
2
|
+
// 0016 §4). Values are WRITE-ONLY at this layer: no method ever returns a
|
|
3
|
+
// secret's plaintext — `reveal` stays exclusive to the egress injection
|
|
4
|
+
// boundary (0117c). Authorization is the single workspace tier every other
|
|
5
|
+
// managed-agents route uses (plan 0117e §"Authorization model"): the
|
|
6
|
+
// sandboxed agent never holds the workspace key, so it structurally cannot
|
|
7
|
+
// reach this service.
|
|
8
|
+
import { invalidRequest, notFound } from "../errors.js";
|
|
9
|
+
import { VAULT_SECRET_PREFIX } from "../vaults/store.js";
|
|
10
|
+
export const MAX_SECRET_NAME_LENGTH = 256;
|
|
11
|
+
export class DefaultSecretsService {
|
|
12
|
+
store;
|
|
13
|
+
// undefined = no master key configured; every route then fails with a
|
|
14
|
+
// clear 400 instead of pretending secrets exist.
|
|
15
|
+
constructor(store) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
}
|
|
18
|
+
create(workspaceId, input) {
|
|
19
|
+
const store = this.requireStore();
|
|
20
|
+
const { name, value } = parseCreateSecret(input);
|
|
21
|
+
if (name.startsWith(VAULT_SECRET_PREFIX)) {
|
|
22
|
+
throw invalidRequest(`Secret names starting with ${JSON.stringify(VAULT_SECRET_PREFIX)} are reserved`);
|
|
23
|
+
}
|
|
24
|
+
return toManagedSecret(store.put(workspaceId, name, value));
|
|
25
|
+
}
|
|
26
|
+
list(workspaceId) {
|
|
27
|
+
return this.requireStore()
|
|
28
|
+
.list(workspaceId)
|
|
29
|
+
.filter((row) => !row.name.startsWith(VAULT_SECRET_PREFIX))
|
|
30
|
+
.map(toManagedSecret);
|
|
31
|
+
}
|
|
32
|
+
delete(workspaceId, name) {
|
|
33
|
+
if (name.startsWith(VAULT_SECRET_PREFIX)) {
|
|
34
|
+
throw invalidRequest(`Secret names starting with ${JSON.stringify(VAULT_SECRET_PREFIX)} are reserved`);
|
|
35
|
+
}
|
|
36
|
+
if (!this.requireStore().delete(workspaceId, name)) {
|
|
37
|
+
throw notFound(`Secret ${name} not found`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
requireStore() {
|
|
41
|
+
if (this.store === undefined) {
|
|
42
|
+
throw invalidRequest("Secrets require a master key: set OMA_MASTER_KEY or OMA_MASTER_KEY_FILE on the deployment");
|
|
43
|
+
}
|
|
44
|
+
return this.store;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function parseCreateSecret(input) {
|
|
48
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
49
|
+
throw invalidRequest("Request body must be a JSON object");
|
|
50
|
+
}
|
|
51
|
+
const { name, value } = input;
|
|
52
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
53
|
+
throw invalidRequest("`name` must be a non-empty string");
|
|
54
|
+
}
|
|
55
|
+
if (name.length > MAX_SECRET_NAME_LENGTH) {
|
|
56
|
+
throw invalidRequest(`\`name\` must be at most ${MAX_SECRET_NAME_LENGTH} characters`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
59
|
+
throw invalidRequest("`value` must be a non-empty string");
|
|
60
|
+
}
|
|
61
|
+
return { name, value };
|
|
62
|
+
}
|
|
63
|
+
function toManagedSecret(row) {
|
|
64
|
+
return {
|
|
65
|
+
id: row.id,
|
|
66
|
+
type: "secret",
|
|
67
|
+
name: row.name,
|
|
68
|
+
created_at: row.created_at,
|
|
69
|
+
updated_at: row.updated_at,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { withSqliteTransaction } from "../sqlite-transaction.js";
|
|
3
|
+
import { newSecretId } from "../ids.js";
|
|
4
|
+
import { assertMasterKey, kekIdFor, open, rewrap, seal, } from "./envelope.js";
|
|
5
|
+
const SCHEMA = `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS secrets_config (
|
|
7
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
8
|
+
current_kek_id TEXT NOT NULL
|
|
9
|
+
);
|
|
10
|
+
CREATE TABLE IF NOT EXISTS secrets (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
workspace_id TEXT NOT NULL,
|
|
13
|
+
name TEXT NOT NULL,
|
|
14
|
+
version TEXT NOT NULL,
|
|
15
|
+
kek_id TEXT NOT NULL,
|
|
16
|
+
wrap_iv BLOB NOT NULL,
|
|
17
|
+
wrap_tag BLOB NOT NULL,
|
|
18
|
+
wrapped_dek BLOB NOT NULL,
|
|
19
|
+
ct_iv BLOB NOT NULL,
|
|
20
|
+
ct_tag BLOB NOT NULL,
|
|
21
|
+
ct BLOB NOT NULL,
|
|
22
|
+
created_at TEXT NOT NULL,
|
|
23
|
+
updated_at TEXT NOT NULL,
|
|
24
|
+
UNIQUE (workspace_id, name)
|
|
25
|
+
);
|
|
26
|
+
`;
|
|
27
|
+
export class SqliteSecretsStore {
|
|
28
|
+
db;
|
|
29
|
+
masterKey;
|
|
30
|
+
kekId;
|
|
31
|
+
configGetStmt;
|
|
32
|
+
configSetStmt;
|
|
33
|
+
insertStmt;
|
|
34
|
+
resealStmt;
|
|
35
|
+
selectStmt;
|
|
36
|
+
listStmt;
|
|
37
|
+
deleteStmt;
|
|
38
|
+
selectAllStmt;
|
|
39
|
+
rewrapStmt;
|
|
40
|
+
constructor(db, masterKey) {
|
|
41
|
+
assertMasterKey(masterKey);
|
|
42
|
+
this.db = db;
|
|
43
|
+
// Copy: the caller's buffer must not be able to mutate the store's key,
|
|
44
|
+
// and rotateMasterKey scrubs the old one.
|
|
45
|
+
this.masterKey = Buffer.from(masterKey);
|
|
46
|
+
this.kekId = kekIdFor(this.masterKey);
|
|
47
|
+
this.db.exec(SCHEMA);
|
|
48
|
+
this.configGetStmt = this.db.prepare(`SELECT current_kek_id FROM secrets_config WHERE id = 1`);
|
|
49
|
+
this.configSetStmt = this.db.prepare(`INSERT INTO secrets_config (id, current_kek_id) VALUES (1, ?)
|
|
50
|
+
ON CONFLICT (id) DO UPDATE SET current_kek_id = excluded.current_kek_id`);
|
|
51
|
+
// Claim or validate the database-wide key epoch (adversarial-review
|
|
52
|
+
// finding): the first store to open an empty DB records its key
|
|
53
|
+
// fingerprint; any later store must present the SAME key, so a process
|
|
54
|
+
// holding a retired key fails fast here instead of writing rows nobody
|
|
55
|
+
// can read.
|
|
56
|
+
withSqliteTransaction(this.db, () => {
|
|
57
|
+
const current = this.currentConfigKekId();
|
|
58
|
+
if (current === undefined) {
|
|
59
|
+
this.configSetStmt.run(this.kekId);
|
|
60
|
+
}
|
|
61
|
+
else if (current !== this.kekId) {
|
|
62
|
+
throw new Error(`secrets database is keyed to a different master key (current ` +
|
|
63
|
+
`kekId ${current}, supplied key is ${this.kekId}) — supply the ` +
|
|
64
|
+
`active key, or rotate from it`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
this.insertStmt = this.db.prepare(`INSERT INTO secrets (
|
|
68
|
+
id, workspace_id, name, version, kek_id,
|
|
69
|
+
wrap_iv, wrap_tag, wrapped_dek, ct_iv, ct_tag, ct,
|
|
70
|
+
created_at, updated_at
|
|
71
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
72
|
+
this.resealStmt = this.db.prepare(`UPDATE secrets SET
|
|
73
|
+
version = ?, kek_id = ?,
|
|
74
|
+
wrap_iv = ?, wrap_tag = ?, wrapped_dek = ?, ct_iv = ?, ct_tag = ?, ct = ?,
|
|
75
|
+
updated_at = ?
|
|
76
|
+
WHERE id = ?`);
|
|
77
|
+
this.selectStmt = this.db.prepare(`SELECT * FROM secrets WHERE workspace_id = ? AND name = ?`);
|
|
78
|
+
this.listStmt = this.db.prepare(`SELECT id, workspace_id, name, created_at, updated_at FROM secrets
|
|
79
|
+
WHERE workspace_id = ? ORDER BY name ASC`);
|
|
80
|
+
this.deleteStmt = this.db.prepare(`DELETE FROM secrets WHERE workspace_id = ? AND name = ?`);
|
|
81
|
+
this.selectAllStmt = this.db.prepare(`SELECT * FROM secrets`);
|
|
82
|
+
this.rewrapStmt = this.db.prepare(`UPDATE secrets SET kek_id = ?, wrap_iv = ?, wrap_tag = ?, wrapped_dek = ?
|
|
83
|
+
WHERE id = ?`);
|
|
84
|
+
}
|
|
85
|
+
static open(path, masterKey) {
|
|
86
|
+
return new SqliteSecretsStore(new DatabaseSync(path), masterKey);
|
|
87
|
+
}
|
|
88
|
+
put(workspaceId, name, value) {
|
|
89
|
+
if (workspaceId === "" || name === "") {
|
|
90
|
+
throw new Error("workspaceId and name must be non-empty");
|
|
91
|
+
}
|
|
92
|
+
const now = new Date().toISOString();
|
|
93
|
+
// The write transaction re-checks the key epoch: a store constructed
|
|
94
|
+
// before a rotation must not seal new rows under the retired key — the
|
|
95
|
+
// rotated store could never read them (adversarial-review finding).
|
|
96
|
+
return withSqliteTransaction(this.db, () => {
|
|
97
|
+
this.assertActiveKey();
|
|
98
|
+
const existing = this.selectStmt.get(workspaceId, name);
|
|
99
|
+
// An upsert keeps the row id and re-seals under a fresh DEK.
|
|
100
|
+
const id = existing?.id ?? newSecretId();
|
|
101
|
+
const sealed = seal(this.masterKey, recordBinding(id, workspaceId, name), Buffer.from(value, "utf8"));
|
|
102
|
+
if (existing) {
|
|
103
|
+
this.resealStmt.run(sealed.version, sealed.kekId, sealed.wrapIv, sealed.wrapTag, sealed.wrappedDek, sealed.ctIv, sealed.ctTag, sealed.ct, now, id);
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
workspace_id: workspaceId,
|
|
107
|
+
name,
|
|
108
|
+
created_at: existing.created_at,
|
|
109
|
+
updated_at: now,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
this.insertStmt.run(id, workspaceId, name, sealed.version, sealed.kekId, sealed.wrapIv, sealed.wrapTag, sealed.wrappedDek, sealed.ctIv, sealed.ctTag, sealed.ct, now, now);
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
workspace_id: workspaceId,
|
|
116
|
+
name,
|
|
117
|
+
created_at: now,
|
|
118
|
+
updated_at: now,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
reveal(workspaceId, name) {
|
|
123
|
+
const row = this.selectStmt.get(workspaceId, name);
|
|
124
|
+
if (!row)
|
|
125
|
+
return undefined;
|
|
126
|
+
const plaintext = open(this.masterKey, recordBinding(row.id, workspaceId, name), sealedFromRow(row));
|
|
127
|
+
const value = plaintext.toString("utf8");
|
|
128
|
+
plaintext.fill(0);
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
list(workspaceId) {
|
|
132
|
+
const rows = this.listStmt.all(workspaceId);
|
|
133
|
+
return rows.map((row) => ({
|
|
134
|
+
id: row.id,
|
|
135
|
+
workspace_id: row.workspace_id,
|
|
136
|
+
name: row.name,
|
|
137
|
+
created_at: row.created_at,
|
|
138
|
+
updated_at: row.updated_at,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
delete(workspaceId, name) {
|
|
142
|
+
return this.deleteStmt.run(workspaceId, name).changes > 0;
|
|
143
|
+
}
|
|
144
|
+
rotateMasterKey(newMasterKey) {
|
|
145
|
+
assertMasterKey(newMasterKey);
|
|
146
|
+
const oldKey = this.masterKey;
|
|
147
|
+
const next = Buffer.from(newMasterKey);
|
|
148
|
+
const nextKekId = kekIdFor(next);
|
|
149
|
+
const count = withSqliteTransaction(this.db, () => {
|
|
150
|
+
// Only the store holding the ACTIVE key may rotate; the epoch flips in
|
|
151
|
+
// the same transaction as the rewraps, so writers serialize against it.
|
|
152
|
+
this.assertActiveKey();
|
|
153
|
+
const rows = this.selectAllStmt.all();
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
const rotated = rewrap(oldKey, next, recordBinding(row.id, row.workspace_id, row.name), sealedFromRow(row));
|
|
156
|
+
this.rewrapStmt.run(rotated.kekId, rotated.wrapIv, rotated.wrapTag, rotated.wrappedDek, row.id);
|
|
157
|
+
}
|
|
158
|
+
this.configSetStmt.run(nextKekId);
|
|
159
|
+
return rows.length;
|
|
160
|
+
});
|
|
161
|
+
this.masterKey = next;
|
|
162
|
+
this.kekId = nextKekId;
|
|
163
|
+
oldKey.fill(0);
|
|
164
|
+
return count;
|
|
165
|
+
}
|
|
166
|
+
currentConfigKekId() {
|
|
167
|
+
const row = this.configGetStmt.get();
|
|
168
|
+
return row?.current_kek_id;
|
|
169
|
+
}
|
|
170
|
+
assertActiveKey() {
|
|
171
|
+
const current = this.currentConfigKekId();
|
|
172
|
+
if (current !== this.kekId) {
|
|
173
|
+
throw new Error(`this store holds a retired master key (kekId ${this.kekId}, the ` +
|
|
174
|
+
`database is keyed to ${current}) — reopen with the active key`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Fingerprint of the store's current master key (for diagnostics/tests). */
|
|
178
|
+
currentKekId() {
|
|
179
|
+
return kekIdFor(this.masterKey);
|
|
180
|
+
}
|
|
181
|
+
// Zero the in-process master key without closing the db — for durable
|
|
182
|
+
// deployments where the db is shared and closed once elsewhere, so close()
|
|
183
|
+
// is skipped but the key must still be scrubbed on shutdown.
|
|
184
|
+
scrubMasterKey() {
|
|
185
|
+
this.masterKey.fill(0);
|
|
186
|
+
}
|
|
187
|
+
close() {
|
|
188
|
+
this.scrubMasterKey();
|
|
189
|
+
this.db.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// The AAD record binding covers the immutable row id AND the addressing
|
|
193
|
+
// metadata (workspace_id, name): a DB-write attacker can neither copy a
|
|
194
|
+
// ciphertext onto another row (id differs) nor relabel a row into another
|
|
195
|
+
// workspace/name slot (metadata differs) — either way `open` fails. JSON
|
|
196
|
+
// encoding keeps the three components unambiguous regardless of their
|
|
197
|
+
// contents. There is no rename API, so the binding never needs to move.
|
|
198
|
+
function recordBinding(id, workspaceId, name) {
|
|
199
|
+
return JSON.stringify([id, workspaceId, name]);
|
|
200
|
+
}
|
|
201
|
+
function sealedFromRow(row) {
|
|
202
|
+
return {
|
|
203
|
+
version: row.version,
|
|
204
|
+
kekId: row.kek_id,
|
|
205
|
+
wrapIv: Buffer.from(row.wrap_iv),
|
|
206
|
+
wrapTag: Buffer.from(row.wrap_tag),
|
|
207
|
+
wrappedDek: Buffer.from(row.wrapped_dek),
|
|
208
|
+
ctIv: Buffer.from(row.ct_iv),
|
|
209
|
+
ctTag: Buffer.from(row.ct_tag),
|
|
210
|
+
ct: Buffer.from(row.ct),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
const DEFAULT_CUSTOM_TOOL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
3
|
+
export class PiCustomToolBridge {
|
|
4
|
+
opts;
|
|
5
|
+
pending = new Map();
|
|
6
|
+
timeoutMs;
|
|
7
|
+
constructor(opts = {}) {
|
|
8
|
+
this.opts = opts;
|
|
9
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CUSTOM_TOOL_TIMEOUT_MS;
|
|
10
|
+
}
|
|
11
|
+
customToolNames(workspaceId, sessionId, context) {
|
|
12
|
+
return new Set((this.opts.customTools?.(workspaceId, sessionId, context) ?? []).map((tool) => tool.name));
|
|
13
|
+
}
|
|
14
|
+
createTools(workspaceId, sessionId, getEmitter, context) {
|
|
15
|
+
return (this.opts.customTools?.(workspaceId, sessionId, context) ?? []).map((tool) => defineTool({
|
|
16
|
+
name: tool.name,
|
|
17
|
+
label: tool.name,
|
|
18
|
+
description: tool.description ?? tool.name,
|
|
19
|
+
parameters: tool.input_schema,
|
|
20
|
+
execute: async (piToolCallId, params, signal) => this.awaitResult(workspaceId, sessionId, tool.name, piToolCallId, params, signal, getEmitter),
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
claimResult(workspaceId, sessionId, event) {
|
|
24
|
+
const pending = this.pending.get(event.custom_tool_use_id);
|
|
25
|
+
if (!pending)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (pending.workspaceId !== workspaceId || pending.sessionId !== sessionId) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return () => {
|
|
31
|
+
this.pending.delete(event.custom_tool_use_id);
|
|
32
|
+
pending.cleanup();
|
|
33
|
+
pending.resolve({
|
|
34
|
+
content: event.content,
|
|
35
|
+
is_error: event.is_error,
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
rejectSession(sessionId, error) {
|
|
40
|
+
for (const [id, pending] of this.pending) {
|
|
41
|
+
if (pending.sessionId !== sessionId)
|
|
42
|
+
continue;
|
|
43
|
+
this.pending.delete(id);
|
|
44
|
+
pending.cleanup();
|
|
45
|
+
pending.reject(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async awaitResult(workspaceId, sessionId, name, piToolCallId, params, signal, getEmitter) {
|
|
49
|
+
const emit = getEmitter();
|
|
50
|
+
if (!emit) {
|
|
51
|
+
throw new Error(`No active runtime consumer for custom tool ${name}`);
|
|
52
|
+
}
|
|
53
|
+
let customToolUseId;
|
|
54
|
+
const result = await new Promise((resolve, reject) => {
|
|
55
|
+
let released = false;
|
|
56
|
+
let releaseCustomToolUseId;
|
|
57
|
+
const cleanup = (reason) => {
|
|
58
|
+
if (released)
|
|
59
|
+
return;
|
|
60
|
+
released = true;
|
|
61
|
+
signal?.removeEventListener("abort", onAbort);
|
|
62
|
+
if (timer)
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
releaseCustomToolUseId?.(reason);
|
|
65
|
+
};
|
|
66
|
+
const onAbort = () => {
|
|
67
|
+
if (customToolUseId)
|
|
68
|
+
this.pending.delete(customToolUseId);
|
|
69
|
+
cleanup("interrupted");
|
|
70
|
+
reject(new Error(`Custom tool ${name} aborted`));
|
|
71
|
+
};
|
|
72
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
const timer = this.timeoutMs > 0
|
|
74
|
+
? setTimeout(() => {
|
|
75
|
+
if (customToolUseId)
|
|
76
|
+
this.pending.delete(customToolUseId);
|
|
77
|
+
cleanup("timeout");
|
|
78
|
+
reject(new Error(`Custom tool ${name} timed out`));
|
|
79
|
+
}, this.timeoutMs)
|
|
80
|
+
: undefined;
|
|
81
|
+
try {
|
|
82
|
+
emit({
|
|
83
|
+
type: "oma.custom_tool_use",
|
|
84
|
+
piToolCallId,
|
|
85
|
+
name,
|
|
86
|
+
input: isRecord(params) ? params : {},
|
|
87
|
+
bindCustomToolUseId: (id, release) => {
|
|
88
|
+
customToolUseId = id;
|
|
89
|
+
releaseCustomToolUseId = release;
|
|
90
|
+
this.pending.set(id, {
|
|
91
|
+
workspaceId,
|
|
92
|
+
sessionId,
|
|
93
|
+
resolve,
|
|
94
|
+
reject,
|
|
95
|
+
cleanup,
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
rejectCustomToolUse: (error) => {
|
|
99
|
+
if (customToolUseId)
|
|
100
|
+
this.pending.delete(customToolUseId);
|
|
101
|
+
cleanup();
|
|
102
|
+
reject(error);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (customToolUseId)
|
|
108
|
+
this.pending.delete(customToolUseId);
|
|
109
|
+
cleanup();
|
|
110
|
+
reject(toError(error));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (result.is_error === true) {
|
|
114
|
+
throw new Error(toolErrorMessage(name, result.content));
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
content: toPiToolContent(result.content),
|
|
118
|
+
details: {},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function isRecord(value) {
|
|
123
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
124
|
+
}
|
|
125
|
+
function toPiToolContent(content) {
|
|
126
|
+
if (!content || content.length === 0)
|
|
127
|
+
return [];
|
|
128
|
+
return content.map((block) => {
|
|
129
|
+
if (block.type === "text" &&
|
|
130
|
+
"text" in block &&
|
|
131
|
+
typeof block.text === "string") {
|
|
132
|
+
return { type: "text", text: block.text };
|
|
133
|
+
}
|
|
134
|
+
return { type: "text", text: JSON.stringify(block) };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function toolErrorMessage(name, content) {
|
|
138
|
+
const text = toPiToolContent(content)
|
|
139
|
+
.map((block) => block.text)
|
|
140
|
+
.join("\n")
|
|
141
|
+
.trim();
|
|
142
|
+
return text.length > 0 ? text : `Custom tool ${name} returned an error`;
|
|
143
|
+
}
|
|
144
|
+
function toError(error) {
|
|
145
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
146
|
+
}
|