@tealbrick/portal 0.1.2 → 0.2.5
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/MANAGEMENT.md +147 -0
- package/dist/client.js +30 -1
- package/dist/knowledge-native.d.ts +19 -0
- package/dist/knowledge-native.js +36 -0
- package/dist/management-eve.d.ts +7 -0
- package/dist/management-eve.js +31 -0
- package/dist/management.d.ts +18 -0
- package/dist/management.js +215 -0
- package/dist/routine-delivery.d.ts +10 -0
- package/dist/routine-delivery.js +16 -0
- package/dist/runtime.d.ts +75 -0
- package/dist/runtime.js +284 -0
- package/dist/wake.d.ts +17 -0
- package/dist/wake.js +1 -0
- package/package.json +16 -3
package/MANAGEMENT.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Routine activation (Eve 0.58.1)
|
|
2
|
+
|
|
3
|
+
Explicitly mount `@tealbrick/portal/management/eve` in the agent. Installation
|
|
4
|
+
alone exposes nothing. Only a Portal user with a signed `manage` grant for the
|
|
5
|
+
configured organization and agent may invoke these routes; `use` and agent-to-agent
|
|
6
|
+
grants cannot manage processes.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// agent/channels/management.ts
|
|
10
|
+
import { managementChannel } from '@tealbrick/portal/management/eve';
|
|
11
|
+
export default managementChannel({
|
|
12
|
+
auth: {
|
|
13
|
+
issuer: 'https://portal.tealbrick.com',
|
|
14
|
+
org: process.env.TEALBRICK_ORG!,
|
|
15
|
+
agent: process.env.TEALBRICK_AGENT!,
|
|
16
|
+
},
|
|
17
|
+
appRoot: '/srv/eve',
|
|
18
|
+
buildCommand: ['/usr/bin/npm', 'run', 'build'],
|
|
19
|
+
restartCommand: ['/usr/bin/systemctl', '--user', 'restart', 'eve.service'],
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Paths, service name and executable locations are deployment configuration, not
|
|
24
|
+
portable defaults. The service must run the production `eve start` output, with
|
|
25
|
+
persistent workflow storage. Do not use `eve dev` or an HTTP-only runner for cron.
|
|
26
|
+
The agent service account must be allowed to restart only its own service. For
|
|
27
|
+
system services use a narrowly scoped host supervisor helper instead of granting
|
|
28
|
+
arbitrary sudo. The desktop never sends executable names, arguments or SSH keys.
|
|
29
|
+
|
|
30
|
+
For AVMM, configure a supervisor that restarts the Eve service in the current
|
|
31
|
+
worker and preserves its authored files. This is not a worker-disk rebuild or a
|
|
32
|
+
VM stop/start: catalog activation can replace runtime files. No AVMM CLI runs on
|
|
33
|
+
Puma. Configure and verify the adapter on the deployment host.
|
|
34
|
+
|
|
35
|
+
GET `/eve/v1/tealbrick/management` reports supported operations and fails closed
|
|
36
|
+
if build/restart configuration is missing. POST to the same route accepts:
|
|
37
|
+
|
|
38
|
+
- `{operation: 'upsert', name, cron, instruction, requestId}` creates a new
|
|
39
|
+
root-authored TypeScript schedule; existing names are rejected (not overwritten).
|
|
40
|
+
- `{operation: 'delete', name, requestId}` moves a root-authored `.ts` schedule
|
|
41
|
+
to receipt storage before rebuilding. Extension and Markdown definitions are
|
|
42
|
+
not deleted by this API.
|
|
43
|
+
- `{operation: 'restart', requestId}` rebuilds and restarts without editing files.
|
|
44
|
+
|
|
45
|
+
`requestId` must be unique per intended action (UUID recommended). Persisted
|
|
46
|
+
receipts in `.tealbrick/activation/` prevent replaying the same action after a
|
|
47
|
+
lost response. Reusing an ID with different content or identity is rejected.
|
|
48
|
+
Do not automatically retry an uncertain action with a fresh ID.
|
|
49
|
+
|
|
50
|
+
A failed build leaves the authored change saved and never restarts. Repair the
|
|
51
|
+
source and invoke `restart` with a new ID. Deletion retains a source copy under
|
|
52
|
+
the request ID. Restore it explicitly if rollback is needed. Preserve receipts
|
|
53
|
+
and workflow state across service restarts. An interrupted activation may leave
|
|
54
|
+
an `activation/lock` directory: inspect its receipt and running build before
|
|
55
|
+
removing that lock during operator recovery.
|
|
56
|
+
|
|
57
|
+
`202 / restart_queued` means build completed and the trusted command was queued,
|
|
58
|
+
not that the new process started or a cron occurrence ran. The delayed command's
|
|
59
|
+
completion/failure is recorded when possible; a service restart can terminate its
|
|
60
|
+
reporter. Desktop observes the exact schedule in production `/eve/v1/info` and
|
|
61
|
+
still does not claim execution or delivery. `hasRun` is handler presence only.
|
|
62
|
+
|
|
63
|
+
Validation: authenticated disposable tests exercise actual build/restart child
|
|
64
|
+
processes, failed builds, replay, grant denial, path rejection, missing config,
|
|
65
|
+
and preserved deletion. They are not live AVMM or systemd acceptance. For each
|
|
66
|
+
deployment require production discovery, a real cron tick and expected delivery.
|
|
67
|
+
|
|
68
|
+
## Optional host wake
|
|
69
|
+
|
|
70
|
+
Normal Eve scheduling remains the default. Supply `wake: HostWakeAdapter` in
|
|
71
|
+
management configuration to offer host wake. GET reports support, managed routine
|
|
72
|
+
names, requested wake names and `pending` reconciliation state. POST creation
|
|
73
|
+
accepts `wake: true`; omitted/false creates no host registration. For a routine
|
|
74
|
+
created by this management API, `{operation:'wake', name, wake:boolean, requestId}`
|
|
75
|
+
changes wake registration without rebuilding or restarting Eve. Removing a routine
|
|
76
|
+
also removes its registration. Existing source/extension routines are not inferred
|
|
77
|
+
or imported into the management catalog.
|
|
78
|
+
|
|
79
|
+
The AVMM implementation is exported from `@tealbrick/avm/wake`:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { createAvmmWakeAdapter } from '@tealbrick/avm/wake';
|
|
83
|
+
const wake = createAvmmWakeAdapter({
|
|
84
|
+
transport: hostOwnedTransport, // trusted host service, bound to one host
|
|
85
|
+
worker: 'my-durable-worker',
|
|
86
|
+
ownerKey: 'organization-id:logical-agent-id', // stable, unique server config
|
|
87
|
+
allowed: true, // explicit host operator authorization; never from browser input
|
|
88
|
+
timezone: 'UTC', // verify production Eve cron uses UTC before opting in
|
|
89
|
+
harness: 'eve',
|
|
90
|
+
leadSeconds: 60,
|
|
91
|
+
});
|
|
92
|
+
// Pass `wake` to managementChannel alongside the existing activation settings.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Keep host authority in a trusted host service. Do not put root/operator SSH keys
|
|
96
|
+
in an Eve worker or the desktop. If management runs inside the worker, implement
|
|
97
|
+
a narrow authenticated HostWakeAdapter RPC to that host service, fixed to that
|
|
98
|
+
worker and logical agent. This package does not provision that deployment boundary.
|
|
99
|
+
The AVMM CLI transport is host-owned; do not execute it locally on Puma.
|
|
100
|
+
|
|
101
|
+
Capability checks read scheduler availability, durable-worker mode, harness grant
|
|
102
|
+
and an idle timeout longer than the wake lead. Actual host writes remain subject
|
|
103
|
+
to host authorization and may fail. Failure leaves explicit pending intent; an
|
|
104
|
+
operator can reconcile it using Restart agent after repairing host access. This
|
|
105
|
+
can delay activation of an explicitly opted-in routine; ordinary opt-out creation
|
|
106
|
+
has no host dependency when there is no earlier pending wake change.
|
|
107
|
+
|
|
108
|
+
AVMM registrations wake the worker ahead of the cron tick and run `/usr/bin/true`
|
|
109
|
+
with `--keep-running`. They never invoke Eve or the routine payload. Existing host
|
|
110
|
+
idle policy still applies. Supported timings are UTC minute/hour intervals that
|
|
111
|
+
divide the hour/day, daily, weekly and weekdays. Monthly and other expressions
|
|
112
|
+
are rejected when wake is requested. Late wakes, slow boot and missed ticks are
|
|
113
|
+
not replayed. Wake registration is not proof of execution or delivery. Confirm
|
|
114
|
+
boot time, idle behavior, a real tick and delivery on each deployment.
|
|
115
|
+
|
|
116
|
+
exe.dev and other providers have no built-in wake adapter yet; they report
|
|
117
|
+
unsupported unless the operator supplies a verified implementation. No provider
|
|
118
|
+
is selected from an agent URL. Stable registration IDs are scoped by ownerKey and
|
|
119
|
+
worker; reconciliation touches only that namespace. Preserve ownerKey and wake
|
|
120
|
+
state across upgrades; deliberate ownership migration needs cleanup of old IDs.
|
|
121
|
+
|
|
122
|
+
## Chat delivery and rotating AVMM credentials
|
|
123
|
+
|
|
124
|
+
The native Eve management channel authors handler schedules that send through
|
|
125
|
+
that same channel with Eve's application auth. `deliveryChannelImport` defaults
|
|
126
|
+
to `../channels/management.js`; set it explicitly if the mounted file has another
|
|
127
|
+
name. The signed managing user's subject owns the delivery, not browser input.
|
|
128
|
+
GET management returns only that subject's completed text deliveries (last 100),
|
|
129
|
+
plus authored instruction metadata. Durable files live in
|
|
130
|
+
`.tealbrick/routine-delivery`; repeated event IDs replace the same file. Preserve
|
|
131
|
+
this directory with workflow state. Retention beyond the returned 100 is an
|
|
132
|
+
operator concern. The desktop merges these events by ID into the agent chat.
|
|
133
|
+
|
|
134
|
+
AVMM assignments can rotate shared Codex broker leases. An OpenAI client that
|
|
135
|
+
captures the startup lease will fail after the first wake assignment. Configure
|
|
136
|
+
`createAvmmBrokerFetch` from `@tealbrick/provider-transport/avmm` as the provider's
|
|
137
|
+
fetch implementation, with the trusted broker baseURL and
|
|
138
|
+
`/run/agentvm/auth/codex.env`. It reads the current lease for every request and
|
|
139
|
+
refuses other destinations. Only then set the host adapter's
|
|
140
|
+
`credentialsRefreshOnRequest: true`. The adapter requires and requests the
|
|
141
|
+
worker's existing `model:codex-subscription` capability for shared Codex auth.
|
|
142
|
+
|
|
143
|
+
`examples/avmm-routine-wake/server.mjs` is a fixed-worker host RPC example. Its
|
|
144
|
+
configuration must be root-owned, its bearer shared only with that worker, and
|
|
145
|
+
both firewall layers must allow only the intended worker address/MAC to the
|
|
146
|
+
host bridge port. The example does not provision firewall rules or systemd units.
|
|
147
|
+
Recheck these rules after AVMM upgrades; generated nft configuration may change.
|
package/dist/client.js
CHANGED
|
@@ -10,6 +10,35 @@ export class PortalHttpError extends Error {
|
|
|
10
10
|
this.name = "PortalHttpError";
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/** Bound untrusted control-plane responses, including error bodies. No payload logging. */
|
|
14
|
+
async function boundedResponse(response) {
|
|
15
|
+
const reader = response.body?.getReader();
|
|
16
|
+
if (!reader)
|
|
17
|
+
return null;
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let size = 0;
|
|
20
|
+
try {
|
|
21
|
+
while (true) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done)
|
|
24
|
+
break;
|
|
25
|
+
size += value.byteLength;
|
|
26
|
+
if (size > 2097152)
|
|
27
|
+
throw Error('portal_response_too_large');
|
|
28
|
+
chunks.push(value);
|
|
29
|
+
}
|
|
30
|
+
const bytes = new Uint8Array(size);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
for (const part of chunks) {
|
|
33
|
+
bytes.set(part, offset);
|
|
34
|
+
offset += part.byteLength;
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await reader.cancel().catch(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
/** No credential storage or logging; instantiate in a trusted runtime for agent credentials. */
|
|
14
43
|
export class PortalClient {
|
|
15
44
|
options;
|
|
@@ -46,7 +75,7 @@ export class PortalClient {
|
|
|
46
75
|
])
|
|
47
76
|
: AbortSignal.timeout(this.options.timeoutMs ?? 15000),
|
|
48
77
|
});
|
|
49
|
-
const data = await response
|
|
78
|
+
const data = await boundedResponse(response).catch(() => null);
|
|
50
79
|
if (!response.ok)
|
|
51
80
|
throw new PortalHttpError(response.status, typeof data?.error === "string" && /^[a-z_]+$/.test(data.error)
|
|
52
81
|
? data.error
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Versioned public Knowledge memory verbs, not upstream host administration. */
|
|
2
|
+
export declare const nativeMemoryReads: readonly ["recall", "entity", "synthesize", "context_pack", "delta", "query", "search", "think", "get_page", "list_pages", "get_chunks", "resolve_slugs", "get_links", "get_backlinks", "traverse_graph", "get_timeline", "find_trajectory", "takes_list", "takes_search"];
|
|
3
|
+
export declare const nativeMemoryOperations: readonly ["recall", "entity", "synthesize", "context_pack", "delta", "query", "search", "think", "get_page", "list_pages", "get_chunks", "resolve_slugs", "get_links", "get_backlinks", "traverse_graph", "get_timeline", "find_trajectory", "takes_list", "takes_search", "remember", "forget"];
|
|
4
|
+
export type NativeRuntimeOperation = 'memory_tools' | `memory_native_${typeof nativeMemoryOperations[number]}`;
|
|
5
|
+
export declare function nativeRequiredActions(operation: string): readonly ('create' | 'read' | 'update' | 'delete')[];
|
|
6
|
+
export declare function nativeRequest(operation: string, input: Record<string, unknown>, partition: string): {
|
|
7
|
+
path: string;
|
|
8
|
+
method: string;
|
|
9
|
+
body: Record<string, unknown> | undefined;
|
|
10
|
+
key: string | undefined;
|
|
11
|
+
} | {
|
|
12
|
+
path: string;
|
|
13
|
+
method: string;
|
|
14
|
+
body: {
|
|
15
|
+
partitionKey: string;
|
|
16
|
+
arguments: object;
|
|
17
|
+
};
|
|
18
|
+
key: string | undefined;
|
|
19
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Versioned public Knowledge memory verbs, not upstream host administration. */
|
|
2
|
+
export const nativeMemoryReads = ['recall', 'entity', 'synthesize', 'context_pack', 'delta', 'query', 'search', 'think', 'get_page', 'list_pages', 'get_chunks', 'resolve_slugs', 'get_links', 'get_backlinks', 'traverse_graph', 'get_timeline', 'find_trajectory', 'takes_list', 'takes_search'];
|
|
3
|
+
export const nativeMemoryOperations = [...nativeMemoryReads, 'remember', 'forget'];
|
|
4
|
+
export function nativeRequiredActions(operation) {
|
|
5
|
+
if (operation === 'memory_tools')
|
|
6
|
+
return ['read'];
|
|
7
|
+
if (operation === 'memory_native_remember')
|
|
8
|
+
return ['create', 'update'];
|
|
9
|
+
if (operation === 'memory_native_forget')
|
|
10
|
+
return ['delete'];
|
|
11
|
+
if (nativeMemoryReads.some(name => operation === `memory_native_${name}`))
|
|
12
|
+
return ['read'];
|
|
13
|
+
throw Error('runtime_action_denied');
|
|
14
|
+
}
|
|
15
|
+
export function nativeRequest(operation, input, partition) {
|
|
16
|
+
if (operation === 'memory_tools') {
|
|
17
|
+
if (Object.keys(input).length)
|
|
18
|
+
throw Error('runtime_invalid_input');
|
|
19
|
+
return { path: '/api/brain/native/tools?' + new URLSearchParams({ partitionKey: partition }), method: 'GET', body: undefined, key: undefined };
|
|
20
|
+
}
|
|
21
|
+
nativeRequiredActions(operation);
|
|
22
|
+
if (Object.keys(input).some(k => !['arguments', 'idempotencyKey'].includes(k)) || !input.arguments || typeof input.arguments !== 'object' || Array.isArray(input.arguments))
|
|
23
|
+
throw Error('runtime_invalid_input');
|
|
24
|
+
for (const reserved of ['source_id', 'source_ids', 'partitionKey', 'principalId', 'agentId', 'actorId', 'token', 'authorization'])
|
|
25
|
+
if (Object.hasOwn(input.arguments, reserved))
|
|
26
|
+
throw Error('runtime_invalid_input');
|
|
27
|
+
const name = operation.slice('memory_native_'.length), key = input.idempotencyKey;
|
|
28
|
+
if ((name === 'remember' || name === 'forget') && !key)
|
|
29
|
+
throw Error('runtime_idempotency_key_required');
|
|
30
|
+
if (key !== undefined && (typeof key !== 'string' || !/^[A-Za-z0-9._:-]{1,200}$/.test(key)))
|
|
31
|
+
throw Error('runtime_invalid_input');
|
|
32
|
+
const body = { partitionKey: partition, arguments: input.arguments };
|
|
33
|
+
if (Buffer.byteLength(JSON.stringify(body)) > 1024 * 1024)
|
|
34
|
+
throw Error('runtime_invalid_input');
|
|
35
|
+
return { path: '/api/brain/native/' + name, method: 'POST', body, key: key };
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ManagementOptions } from './management.js';
|
|
2
|
+
/** Explicit mount; importing the package starts no processes and registers no routes. */
|
|
3
|
+
export declare function managementChannel(options: ManagementOptions): import("eve/channels").Channel<{
|
|
4
|
+
owner: string;
|
|
5
|
+
name: string;
|
|
6
|
+
occurrence: string;
|
|
7
|
+
} | undefined, Record<string, unknown>, Record<string, unknown>>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { saveRoutineDelivery } from './routine-delivery.js';
|
|
2
|
+
import { defineChannel, GET, POST } from 'eve/channels';
|
|
3
|
+
import { createManagementHandler } from './management.js';
|
|
4
|
+
/** Explicit mount; importing the package starts no processes and registers no routes. */
|
|
5
|
+
export function managementChannel(options) {
|
|
6
|
+
const handle = createManagementHandler({ ...options, deliveryChannelImport: options.deliveryChannelImport ?? "../channels/management.js" });
|
|
7
|
+
return defineChannel({
|
|
8
|
+
state: { owner: '', name: '', occurrence: '' },
|
|
9
|
+
context: state => ({ state }),
|
|
10
|
+
receive(input, { from }) {
|
|
11
|
+
const { owner, name } = input.target;
|
|
12
|
+
const occurrence = typeof input.target.occurrence === 'string' ? input.target.occurrence : '';
|
|
13
|
+
if (typeof owner !== 'string' || typeof name !== 'string')
|
|
14
|
+
throw Error('invalid_routine_target');
|
|
15
|
+
return from(JSON.stringify([owner, name, occurrence])).send(input.message, { auth: input.auth, state: { owner, name, occurrence }, title: `Routine: ${name}` });
|
|
16
|
+
},
|
|
17
|
+
events: {
|
|
18
|
+
async 'message.completed'(event, channel, ctx) {
|
|
19
|
+
if (!event.message || event.finishReason === 'tool-calls')
|
|
20
|
+
return;
|
|
21
|
+
await saveRoutineDelivery(options.appRoot, { id: `${ctx.session.id}:${event.turnId}:${event.sequence}`, owner: channel.state.owner, name: channel.state.name, text: event.message, at: Date.now(), sessionId: ctx.session.id });
|
|
22
|
+
if (channel.state.occurrence && options.routines)
|
|
23
|
+
await options.routines.call('complete', { id: channel.state.occurrence });
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
routes: [
|
|
27
|
+
GET('/eve/v1/tealbrick/management', request => handle('status', request)),
|
|
28
|
+
POST('/eve/v1/tealbrick/management', request => handle('apply', request)),
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HostWakeAdapter } from './wake.js';
|
|
2
|
+
import { type AuthOptions } from './auth.js';
|
|
3
|
+
export interface ManagementOptions {
|
|
4
|
+
auth: AuthOptions;
|
|
5
|
+
wake?: HostWakeAdapter;
|
|
6
|
+
/** Scoped host routine bridge; operator-configured, never browser supplied. */
|
|
7
|
+
routines?: {
|
|
8
|
+
owner: string;
|
|
9
|
+
call(action: string, input?: Record<string, unknown>): Promise<any>;
|
|
10
|
+
};
|
|
11
|
+
/** Trusted authored channel import, enabled by managementChannel. */
|
|
12
|
+
deliveryChannelImport?: string;
|
|
13
|
+
appRoot: string;
|
|
14
|
+
/** Trusted operator configuration only. No shell expansion or request-supplied commands. */
|
|
15
|
+
buildCommand: readonly [string, ...string[]];
|
|
16
|
+
restartCommand: readonly [string, ...string[]];
|
|
17
|
+
}
|
|
18
|
+
export declare function createManagementHandler(options: ManagementOptions): (action: "status" | "apply", request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readRoutineDeliveries } from './routine-delivery.js';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, lstat, rm } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createPortalVerifier } from './auth.js';
|
|
7
|
+
const json = (data, status = 200) => Response.json(data, { status });
|
|
8
|
+
export function createManagementHandler(options) {
|
|
9
|
+
const verifier = createPortalVerifier(options.auth);
|
|
10
|
+
const root = resolve(options.appRoot);
|
|
11
|
+
const state = join(root, '.tealbrick', 'activation');
|
|
12
|
+
return async (action, request) => {
|
|
13
|
+
const identity = await verifier.authenticate(request);
|
|
14
|
+
if (!identity.ok)
|
|
15
|
+
return json({ ok: false, error: identity.code }, identity.status);
|
|
16
|
+
if (identity.principal.type !== 'user' || identity.principal.level !== 'manage')
|
|
17
|
+
return json({ ok: false, error: 'manage_grant_required' }, 403);
|
|
18
|
+
if (options.routines && identity.principal.id !== options.routines.owner)
|
|
19
|
+
return json({ ok: false, error: 'routine_owner_not_granted' }, 403);
|
|
20
|
+
if (options.routines && action === 'status') {
|
|
21
|
+
try {
|
|
22
|
+
const snapshot = await options.routines.call('status');
|
|
23
|
+
return json({ ok: true, protocol: 2, activation: 'dynamic', dynamic: true, delivery: true, ...snapshot,
|
|
24
|
+
definitions: Object.fromEntries(snapshot.routines.map((r) => [r.name, { cron: r.cron, markdown: r.instruction }])),
|
|
25
|
+
deliveries: await readRoutineDeliveries(root, identity.principal.id), operations: ['upsert', 'update', 'delete', 'restart', 'wake'] });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return json({ ok: false, error: 'routine_host_unavailable' }, 503);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (options.routines && action === 'apply') {
|
|
32
|
+
try {
|
|
33
|
+
const text = await request.text();
|
|
34
|
+
if (text.length > 20000)
|
|
35
|
+
return json({ ok: false, error: 'request_too_large' }, 413);
|
|
36
|
+
const input = JSON.parse(text);
|
|
37
|
+
if (input.operation !== 'restart')
|
|
38
|
+
return json(await options.routines.call('apply', input));
|
|
39
|
+
// Restart remains a separate explicit lifecycle action.
|
|
40
|
+
request = new Request(request.url, { method: 'POST', headers: request.headers, body: text });
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : '';
|
|
44
|
+
const allowed = ['revision_conflict', 'schedule_exists', 'schedule_not_found', 'wake_unavailable', 'invalid_cron', 'unsupported_wake_cron', 'request_id_conflict'];
|
|
45
|
+
return json({ ok: false, error: allowed.includes(message) ? message : 'routine_change_failed' }, 409);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!options.buildCommand?.length || !options.restartCommand?.length)
|
|
49
|
+
return json({ ok: false, error: 'host_activation_not_configured' }, 503);
|
|
50
|
+
if (action === 'status') {
|
|
51
|
+
const wake = options.wake ? await options.wake.status().catch(() => ({ supported: false, reason: 'Host wake unavailable.' })) : { supported: false, reason: 'This host has no wake adapter configured.' };
|
|
52
|
+
const wakeState = JSON.parse(await readFile(join(state, 'wake.json'), 'utf8').catch(() => ' {"routines":[],"catalog":{}}'));
|
|
53
|
+
return json({ ok: true, protocol: 1, activation: 'build-restart', delivery: Boolean(options.deliveryChannelImport), deliveries: await readRoutineDeliveries(root, identity.principal.id), definitions: wakeState.definitions ?? {}, operations: ['upsert', 'delete', 'restart', 'wake'], wake: { ...wake, pending: Boolean(wakeState.dirty), managed: Object.keys(wakeState.catalog ?? {}), routines: wakeState.routines.map((r) => r.name) } });
|
|
54
|
+
}
|
|
55
|
+
let input;
|
|
56
|
+
try {
|
|
57
|
+
const text = await request.text();
|
|
58
|
+
if (text.length > 20000)
|
|
59
|
+
return json({ ok: false, error: 'request_too_large' }, 413);
|
|
60
|
+
input = JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return json({ ok: false, error: 'invalid_request' }, 400);
|
|
64
|
+
}
|
|
65
|
+
if (!input || typeof input !== 'object')
|
|
66
|
+
return json({ ok: false, error: 'invalid_request' }, 400);
|
|
67
|
+
const { operation, name, cron, instruction, requestId, wake = false } = input;
|
|
68
|
+
if (typeof wake !== "boolean")
|
|
69
|
+
return json({ ok: false, error: "invalid_wake_setting" }, 400);
|
|
70
|
+
if (wake && operation !== "upsert" && operation !== "wake")
|
|
71
|
+
return json({ ok: false, error: "invalid_wake_operation" }, 400);
|
|
72
|
+
if (!['upsert', 'delete', 'restart', 'wake'].includes(String(operation)) || typeof requestId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(requestId))
|
|
73
|
+
return json({ ok: false, error: 'invalid_operation' }, 400);
|
|
74
|
+
if (operation !== 'restart' && (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,47}$/.test(name)))
|
|
75
|
+
return json({ ok: false, error: 'invalid_schedule_name' }, 400);
|
|
76
|
+
if (operation === 'upsert' && (typeof cron !== 'string' || cron.length > 120 || cron.trim().split(/\s+/).length !== 5 || typeof instruction !== 'string' || !instruction.trim() || instruction.length > 12000))
|
|
77
|
+
return json({ ok: false, error: 'invalid_schedule' }, 400);
|
|
78
|
+
if (wake) {
|
|
79
|
+
if (!options.wake || !(await options.wake.status().catch(() => ({ supported: false }))).supported)
|
|
80
|
+
return json({ ok: false, error: 'host_wake_unavailable' }, 409);
|
|
81
|
+
try {
|
|
82
|
+
if (operation === 'upsert')
|
|
83
|
+
options.wake.validate([{ name: String(name), cron: String(cron) }]);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return json({ ok: false, error: 'unsupported_wake_cron' }, 400);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const digest = createHash('sha256').update(JSON.stringify([identity.principal.id, operation, name, cron, instruction, wake])).digest('hex');
|
|
90
|
+
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
91
|
+
const receipt = join(state, `${requestId}.json`);
|
|
92
|
+
const existing = await readFile(receipt, 'utf8').catch(() => null);
|
|
93
|
+
if (existing) {
|
|
94
|
+
const previous = JSON.parse(existing);
|
|
95
|
+
return previous.digest === digest ? json(previous, previous.ok ? 200 : 409) : json({ ok: false, error: 'request_id_conflict' }, 409);
|
|
96
|
+
}
|
|
97
|
+
const lock = join(state, 'lock');
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(lock);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return json({ ok: false, error: 'activation_busy' }, 409);
|
|
103
|
+
}
|
|
104
|
+
let saved = false;
|
|
105
|
+
let queued = false;
|
|
106
|
+
const record = async (value) => {
|
|
107
|
+
const body = { requestId, digest, ...value };
|
|
108
|
+
const temp = `${receipt}.${randomUUID()}.tmp`;
|
|
109
|
+
await writeFile(temp, JSON.stringify(body), { mode: 0o600 });
|
|
110
|
+
await rename(temp, receipt);
|
|
111
|
+
return body;
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
// Recheck after acquiring the inter-process lock. Never repeat an ambiguous operation.
|
|
115
|
+
const raced = await readFile(receipt, 'utf8').catch(() => null);
|
|
116
|
+
if (raced) {
|
|
117
|
+
const prior = JSON.parse(raced);
|
|
118
|
+
return prior.digest === digest ? json(prior, prior.ok ? 200 : 409) : json({ ok: false, error: 'request_id_conflict' }, 409);
|
|
119
|
+
}
|
|
120
|
+
const wakeFile = join(state, 'wake.json');
|
|
121
|
+
const wakeState = JSON.parse(await readFile(wakeFile, 'utf8').catch(() => ' {"dirty":false,"routines":[]}'));
|
|
122
|
+
const saveWake = async () => { const tmp = wakeFile + '.tmp'; await writeFile(tmp, JSON.stringify(wakeState), { mode: 0o600 }); await rename(tmp, wakeFile); };
|
|
123
|
+
wakeState.catalog ??= {};
|
|
124
|
+
wakeState.definitions ??= {};
|
|
125
|
+
if (operation === 'wake') {
|
|
126
|
+
const selectedCron = wakeState.catalog[String(name)];
|
|
127
|
+
if (!selectedCron)
|
|
128
|
+
return json({ ok: false, error: 'routine_not_managed' }, 409);
|
|
129
|
+
if (wake)
|
|
130
|
+
options.wake.validate([{ name: String(name), cron: selectedCron }]);
|
|
131
|
+
wakeState.routines = wakeState.routines.filter(r => r.name !== name);
|
|
132
|
+
if (wake)
|
|
133
|
+
wakeState.routines.push({ name: String(name), cron: selectedCron });
|
|
134
|
+
wakeState.dirty = true;
|
|
135
|
+
}
|
|
136
|
+
if ((wake && operation === 'upsert') || (operation === 'delete' && wakeState.routines.some(r => r.name === name))) {
|
|
137
|
+
wakeState.routines = wakeState.routines.filter(r => r.name !== name);
|
|
138
|
+
if (wake)
|
|
139
|
+
wakeState.routines.push({ name: String(name), cron: String(cron) });
|
|
140
|
+
wakeState.dirty = true;
|
|
141
|
+
}
|
|
142
|
+
await record({ ok: false, state: 'applying' });
|
|
143
|
+
if (operation === 'upsert' || operation === 'delete') {
|
|
144
|
+
const dir = join(root, 'agent', 'schedules');
|
|
145
|
+
await mkdir(dir, { recursive: true });
|
|
146
|
+
for (const path of [join(root, 'agent'), dir])
|
|
147
|
+
if ((await lstat(path)).isSymbolicLink())
|
|
148
|
+
throw Error('unsafe_schedule_path');
|
|
149
|
+
const file = join(dir, `${name}.ts`);
|
|
150
|
+
const stat = await lstat(file).catch(() => null);
|
|
151
|
+
if (stat?.isSymbolicLink())
|
|
152
|
+
throw Error('unsafe_schedule_path');
|
|
153
|
+
if (operation === 'upsert') {
|
|
154
|
+
if (stat)
|
|
155
|
+
throw Error('schedule_exists');
|
|
156
|
+
const source = options.deliveryChannelImport
|
|
157
|
+
? `import { defineSchedule } from "eve/schedules";\nimport delivery from ${JSON.stringify(options.deliveryChannelImport)};\nexport default defineSchedule({cron:${JSON.stringify(cron)},async run({to,waitUntil,appAuth}) { waitUntil(to(delivery,${JSON.stringify({ name, owner: identity.principal.id })}).send(${JSON.stringify(instruction)},{auth:appAuth})); }});\n`
|
|
158
|
+
: `import { defineSchedule } from "eve/schedules";\nexport default defineSchedule(${JSON.stringify({ cron, markdown: instruction })});\n`;
|
|
159
|
+
await writeFile(file, source, { flag: 'wx', mode: 0o600 });
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
if (!stat)
|
|
163
|
+
throw Error('schedule_not_found');
|
|
164
|
+
await rename(file, join(state, `${requestId}.schedule.ts`));
|
|
165
|
+
}
|
|
166
|
+
saved = true;
|
|
167
|
+
if (operation === 'upsert') {
|
|
168
|
+
wakeState.catalog[String(name)] = String(cron);
|
|
169
|
+
wakeState.definitions[String(name)] = { cron: String(cron), markdown: String(instruction) };
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
delete wakeState.catalog[String(name)];
|
|
173
|
+
delete wakeState.definitions[String(name)];
|
|
174
|
+
}
|
|
175
|
+
await saveWake();
|
|
176
|
+
}
|
|
177
|
+
if (wakeState.dirty)
|
|
178
|
+
await saveWake();
|
|
179
|
+
if (operation !== 'wake')
|
|
180
|
+
await runBuild(options.buildCommand, root);
|
|
181
|
+
if (wakeState.dirty) {
|
|
182
|
+
if (!options.wake)
|
|
183
|
+
throw Error('wake_adapter_missing');
|
|
184
|
+
await options.wake.reconcile(wakeState.routines);
|
|
185
|
+
wakeState.dirty = false;
|
|
186
|
+
await saveWake();
|
|
187
|
+
}
|
|
188
|
+
if (operation === 'wake')
|
|
189
|
+
return json(await record({ ok: true, state: 'wake_updated', saved: true }));
|
|
190
|
+
// Persist intent before spawning. A lost response must not repeat a restart.
|
|
191
|
+
await record({ ok: false, state: 'restart_pending', saved });
|
|
192
|
+
const script = `const {spawn}=require('node:child_process'); const fs=require('node:fs'); const [file,cwd,command,args,lock]=process.argv.slice(1); setTimeout(()=>{const p=spawn(command,JSON.parse(args),{cwd,stdio:'ignore',shell:false}); const unlock=()=>{try{fs.rmSync(lock,{recursive:true,force:true});}catch{}};p.on('spawn',unlock); const finish=(state)=>{unlock();try{const r=JSON.parse(fs.readFileSync(file,'utf8'));r.restartState=state;fs.writeFileSync(file,JSON.stringify(r),{mode:384});}catch{}};p.on('error',()=>finish('failed'));p.on('exit',code=>finish(code===0?'command_completed':'failed'));},1500);`;
|
|
193
|
+
const worker = spawn(process.execPath, ['-e', script, receipt, root, options.restartCommand[0], JSON.stringify(options.restartCommand.slice(1)), lock], { cwd: root, detached: true, stdio: 'ignore', shell: false });
|
|
194
|
+
await new Promise((yes, no) => { worker.once('spawn', yes); worker.once('error', no); });
|
|
195
|
+
worker.unref();
|
|
196
|
+
queued = true;
|
|
197
|
+
return json(await record({ ok: true, state: 'restart_queued', saved, name, restarted: true }), 202);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const code = error instanceof Error && ['schedule_exists', 'schedule_not_found', 'unsafe_schedule_path'].includes(error.message) ? error.message : 'activation_failed';
|
|
201
|
+
return json(await record({ ok: false, state: 'failed', saved, error: code }), 409);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
if (!queued)
|
|
205
|
+
await rm(lock, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function runBuild(command, cwd) {
|
|
210
|
+
await new Promise((resolve, reject) => {
|
|
211
|
+
const child = spawn(command[0], command.slice(1), { cwd, stdio: 'ignore', shell: false, timeout: 300000 });
|
|
212
|
+
child.once('error', reject);
|
|
213
|
+
child.once('exit', code => code === 0 ? resolve() : reject(Error('build_failed')));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RoutineDelivery {
|
|
2
|
+
id: string;
|
|
3
|
+
owner: string;
|
|
4
|
+
name: string;
|
|
5
|
+
text: string;
|
|
6
|
+
at: number;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function saveRoutineDelivery(root: string, row: RoutineDelivery): Promise<void>;
|
|
10
|
+
export declare function readRoutineDeliveries(root: string, owner: string): Promise<RoutineDelivery[]>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile, rename } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export async function saveRoutineDelivery(root, row) {
|
|
5
|
+
const dir = join(root, '.tealbrick', 'routine-delivery');
|
|
6
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
7
|
+
const file = join(dir, createHash('sha256').update(row.id).digest('hex') + '.json');
|
|
8
|
+
await writeFile(file + '.tmp', JSON.stringify(row), { mode: 0o600 });
|
|
9
|
+
await rename(file + '.tmp', file);
|
|
10
|
+
}
|
|
11
|
+
export async function readRoutineDeliveries(root, owner) {
|
|
12
|
+
const dir = join(root, '.tealbrick', 'routine-delivery');
|
|
13
|
+
const files = await readdir(dir).catch(() => []);
|
|
14
|
+
const rows = await Promise.all(files.filter(f => /^[a-f0-9]{64}\.json$/.test(f)).map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));
|
|
15
|
+
return rows.filter(r => r.owner === owner).sort((a, b) => a.at - b.at).slice(-100);
|
|
16
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from 'jose';
|
|
2
|
+
import { type NativeRuntimeOperation } from './knowledge-native.js';
|
|
3
|
+
export type CrudAction = 'create' | 'read' | 'update' | 'delete';
|
|
4
|
+
export interface RuntimeApp {
|
|
5
|
+
registrationId: string;
|
|
6
|
+
appId: 'knowledge';
|
|
7
|
+
major: 1;
|
|
8
|
+
bindingRef: string;
|
|
9
|
+
instanceId: string;
|
|
10
|
+
companyId: string;
|
|
11
|
+
actions: CrudAction[];
|
|
12
|
+
entitlement: {
|
|
13
|
+
licenseId: string;
|
|
14
|
+
major: 1;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface RuntimeIdentity {
|
|
18
|
+
issuer: string;
|
|
19
|
+
org: string;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
agentId: string;
|
|
22
|
+
connectionId: string;
|
|
23
|
+
}
|
|
24
|
+
export interface LocalBinding {
|
|
25
|
+
endpoint: string;
|
|
26
|
+
instanceId: string;
|
|
27
|
+
companyId: string;
|
|
28
|
+
credentialRef: string;
|
|
29
|
+
}
|
|
30
|
+
export interface RuntimeOptions extends RuntimeIdentity {
|
|
31
|
+
credential: () => string | Promise<string>;
|
|
32
|
+
bindings: Record<string, LocalBinding>;
|
|
33
|
+
resolveCredential: (reference: string) => string | Promise<string>;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
keys?: JWTVerifyGetKey;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
audit?: (event: {
|
|
38
|
+
connectionId: string;
|
|
39
|
+
registrationId: string;
|
|
40
|
+
operation: string;
|
|
41
|
+
outcome: 'success' | 'denied' | 'failed';
|
|
42
|
+
revision?: string;
|
|
43
|
+
}) => void;
|
|
44
|
+
}
|
|
45
|
+
type LegacyKnowledgeOperation = 'list_collections' | 'create_collection' | 'create_document' | 'get_document' | 'update_document' | 'delete_document' | 'search' | 'memory_recall' | 'memory_context' | 'memory_entities';
|
|
46
|
+
export type KnowledgeOperation = LegacyKnowledgeOperation | NativeRuntimeOperation;
|
|
47
|
+
export declare const knowledgeOperations: readonly KnowledgeOperation[];
|
|
48
|
+
/** Customer-owned execution boundary. Portal receives configuration acknowledgments only. */
|
|
49
|
+
export declare class RuntimeConnector {
|
|
50
|
+
private readonly options;
|
|
51
|
+
private readonly issuer;
|
|
52
|
+
private readonly fetcher;
|
|
53
|
+
private readonly keys;
|
|
54
|
+
private cached?;
|
|
55
|
+
private refreshing?;
|
|
56
|
+
constructor(options: RuntimeOptions);
|
|
57
|
+
private now;
|
|
58
|
+
private portal;
|
|
59
|
+
private refresh;
|
|
60
|
+
private matchBinding;
|
|
61
|
+
private current;
|
|
62
|
+
capabilities(force?: boolean): Promise<{
|
|
63
|
+
revision: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
apps: {
|
|
66
|
+
registrationId: string;
|
|
67
|
+
appId: "knowledge";
|
|
68
|
+
actions: CrudAction[];
|
|
69
|
+
available: boolean;
|
|
70
|
+
operations: KnowledgeOperation[];
|
|
71
|
+
}[];
|
|
72
|
+
}>;
|
|
73
|
+
call(registrationId: string, operation: KnowledgeOperation, input?: Record<string, unknown>, signal?: AbortSignal): Promise<any>;
|
|
74
|
+
}
|
|
75
|
+
export {};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
|
|
2
|
+
import { nativeMemoryOperations, nativeRequiredActions, nativeRequest } from './knowledge-native.js';
|
|
3
|
+
const actions = { list_collections: 'read', create_collection: 'create', create_document: 'create', get_document: 'read', update_document: 'update', delete_document: 'delete', search: 'read', memory_recall: 'read', memory_context: 'read', memory_entities: 'read' };
|
|
4
|
+
const allOperations = [...Object.keys(actions), 'memory_tools', ...nativeMemoryOperations.map(name => `memory_native_${name}`)];
|
|
5
|
+
export const knowledgeOperations = Object.freeze([...allOperations]);
|
|
6
|
+
function requiredActions(operation) { return Object.hasOwn(actions, operation) ? [actions[operation]] : nativeRequiredActions(operation); }
|
|
7
|
+
const text = (v) => typeof v === 'string' && v.length > 0 && v.length <= 4096;
|
|
8
|
+
const component = (v) => { if (!text(v) || !/^[a-zA-Z0-9_-]+$/.test(v))
|
|
9
|
+
throw Error('runtime_invalid_identifier'); return encodeURIComponent(v); };
|
|
10
|
+
function origin(value) { const u = new URL(value); if (u.protocol !== 'https:' || u.username || u.password || u.pathname !== '/' || u.search || u.hash)
|
|
11
|
+
throw Error('runtime_invalid_issuer'); return u.origin; }
|
|
12
|
+
function endpoint(value) { const u = new URL(value); if ((u.protocol !== 'https:' && !(u.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(u.hostname))) || u.username || u.password || u.search || u.hash || u.pathname !== '/')
|
|
13
|
+
throw Error('runtime_invalid_binding_endpoint'); return u.origin; }
|
|
14
|
+
async function boundedJson(response, maxBytes) {
|
|
15
|
+
const reader = response.body?.getReader();
|
|
16
|
+
if (!reader)
|
|
17
|
+
throw Error('runtime_invalid_response');
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let size = 0;
|
|
20
|
+
try {
|
|
21
|
+
while (true) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done)
|
|
24
|
+
break;
|
|
25
|
+
size += value.byteLength;
|
|
26
|
+
if (size > maxBytes)
|
|
27
|
+
throw Error('runtime_response_too_large');
|
|
28
|
+
chunks.push(value);
|
|
29
|
+
}
|
|
30
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await reader.cancel().catch(() => undefined);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function validateApps(value) {
|
|
37
|
+
if (!Array.isArray(value) || value.length > 100)
|
|
38
|
+
throw Error('runtime_invalid_config');
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
for (const a of value) {
|
|
41
|
+
if (!a || a.appId !== 'knowledge' || a.major !== 1 || !['registrationId', 'bindingRef', 'instanceId', 'companyId'].every(k => text(a[k])) || ids.has(a.registrationId) || !Array.isArray(a.actions) || a.actions.some((v) => !['create', 'read', 'update', 'delete'].includes(String(v))) || new Set(a.actions).size !== a.actions.length || !text(a.entitlement?.licenseId) || a.entitlement.major !== 1)
|
|
42
|
+
throw Error('runtime_invalid_config');
|
|
43
|
+
ids.add(a.registrationId);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
/** Customer-owned execution boundary. Portal receives configuration acknowledgments only. */
|
|
48
|
+
export class RuntimeConnector {
|
|
49
|
+
options;
|
|
50
|
+
issuer;
|
|
51
|
+
fetcher;
|
|
52
|
+
keys;
|
|
53
|
+
cached;
|
|
54
|
+
refreshing;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.options = options;
|
|
57
|
+
this.issuer = origin(options.issuer);
|
|
58
|
+
this.fetcher = options.fetch ?? fetch;
|
|
59
|
+
for (const v of Object.values(options.bindings)) {
|
|
60
|
+
endpoint(v.endpoint);
|
|
61
|
+
if (!text(v.instanceId) || !text(v.companyId) || !/^TEALBRICK_[A-Z0-9_]+$/.test(v.credentialRef))
|
|
62
|
+
throw Error('runtime_invalid_binding');
|
|
63
|
+
}
|
|
64
|
+
this.keys = options.keys ?? createRemoteJWKSet(new URL('/.well-known/jwks.json', this.issuer), { [customFetch]: (url, init) => this.fetcher(url, { ...init, redirect: 'error' }) });
|
|
65
|
+
}
|
|
66
|
+
now() { return this.options.now?.() ?? Date.now(); }
|
|
67
|
+
async portal(path, body) {
|
|
68
|
+
return this.fetcher(new URL(path, this.issuer), { method: body === undefined ? 'GET' : 'POST', headers: { authorization: `Bearer ${await this.options.credential()}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
69
|
+
}
|
|
70
|
+
async refresh() {
|
|
71
|
+
let response;
|
|
72
|
+
try {
|
|
73
|
+
response = await this.portal('/api/runtime/config');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw Error('runtime_portal_unavailable');
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
if (response.status === 401 || response.status === 403)
|
|
80
|
+
this.cached = undefined;
|
|
81
|
+
throw Error('runtime_config_unavailable');
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const envelope = await boundedJson(response, 262144);
|
|
85
|
+
const { payload } = await jwtVerify(envelope.config, this.keys, { issuer: this.issuer, audience: 'tealbrick-runtime', algorithms: ['ES256'], currentDate: new Date(this.now()), requiredClaims: ['iat', 'exp', 'sub'] });
|
|
86
|
+
const now = this.now() / 1000;
|
|
87
|
+
if (payload.typ !== 'tealbrick-runtime-config' || payload.version !== 1 || payload.sub !== this.options.agentId || payload.org !== this.options.org || payload.workspaceId !== this.options.workspaceId || payload.connectionId !== this.options.connectionId || !Number.isInteger(payload.iat) || !Number.isInteger(payload.exp) || payload.exp <= now || payload.iat > now + 5 || payload.exp - payload.iat > 300 || payload.exp - payload.iat <= 0 || typeof payload.revision !== 'string' || !/^[a-f0-9]{64}$/.test(payload.revision) || payload.revision !== envelope.revision)
|
|
88
|
+
throw Error('runtime_invalid_config');
|
|
89
|
+
const apps = validateApps(payload.apps);
|
|
90
|
+
this.cached = { apps, revision: payload.revision, expires: payload.exp * 1000, checked: this.now() };
|
|
91
|
+
const missing = apps.some(a => !this.matchBinding(a));
|
|
92
|
+
// An acknowledgement failure must not extend or replace the signed authorization lease.
|
|
93
|
+
await this.portal('/api/runtime/ack', { revision: payload.revision, status: missing ? 'unavailable' : 'effective', ...(missing ? { errorCode: 'binding_missing' } : {}) }).catch(() => undefined);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.cached = undefined;
|
|
97
|
+
throw Error('runtime_configuration_rejected');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
matchBinding(app) { const b = this.options.bindings[app.bindingRef]; return b && b.instanceId === app.instanceId && b.companyId === app.companyId ? b : undefined; }
|
|
101
|
+
async current(force = false) {
|
|
102
|
+
if (force || !this.cached || this.now() - this.cached.checked >= 30000) {
|
|
103
|
+
this.refreshing ??= this.refresh().finally(() => { this.refreshing = undefined; });
|
|
104
|
+
try {
|
|
105
|
+
await this.refreshing;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (force || !this.cached || this.now() >= this.cached.expires)
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!this.cached || this.now() >= this.cached.expires)
|
|
113
|
+
throw Error('runtime_authorization_expired');
|
|
114
|
+
return this.cached;
|
|
115
|
+
}
|
|
116
|
+
async capabilities(force = false) { const c = await this.current(force); return { revision: c.revision, expiresAt: c.expires, apps: c.apps.map(a => ({ registrationId: a.registrationId, appId: a.appId, actions: [...a.actions], available: !!this.matchBinding(a), operations: allOperations.filter(op => requiredActions(op).every(action => a.actions.includes(action))) })) }; }
|
|
117
|
+
async call(registrationId, operation, input = {}, signal) {
|
|
118
|
+
let revision;
|
|
119
|
+
let outcome = 'denied';
|
|
120
|
+
try {
|
|
121
|
+
const c = await this.current();
|
|
122
|
+
revision = c.revision;
|
|
123
|
+
const app = c.apps.find(a => a.registrationId === registrationId);
|
|
124
|
+
if (!app || !allOperations.includes(operation) || !requiredActions(operation).every(action => app.actions.includes(action)))
|
|
125
|
+
throw Error('runtime_action_denied');
|
|
126
|
+
const b = this.matchBinding(app);
|
|
127
|
+
if (!b)
|
|
128
|
+
throw Error('runtime_binding_missing');
|
|
129
|
+
let path, method = 'GET', body, idempotencyKey;
|
|
130
|
+
const company = component(app.companyId);
|
|
131
|
+
switch (operation) {
|
|
132
|
+
default: {
|
|
133
|
+
const native = nativeRequest(operation, input, app.companyId);
|
|
134
|
+
path = native.path;
|
|
135
|
+
method = native.method;
|
|
136
|
+
body = native.body;
|
|
137
|
+
idempotencyKey = native.key;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case 'list_collections':
|
|
141
|
+
path = `/api/companies/${company}/knowledge/collections`;
|
|
142
|
+
break;
|
|
143
|
+
case 'create_collection':
|
|
144
|
+
if (!text(input.name))
|
|
145
|
+
throw Error('runtime_invalid_input');
|
|
146
|
+
path = `/api/companies/${company}/knowledge/collections`;
|
|
147
|
+
method = 'POST';
|
|
148
|
+
body = { name: input.name };
|
|
149
|
+
break;
|
|
150
|
+
case 'create_document':
|
|
151
|
+
if (!text(input.title) || typeof input.body !== 'string' || input.body.length > 100000)
|
|
152
|
+
throw Error('runtime_invalid_input');
|
|
153
|
+
path = `/api/knowledge/collections/${component(input.collectionId)}/documents`;
|
|
154
|
+
method = 'POST';
|
|
155
|
+
body = { title: input.title, body: input.body, bodyFormat: 'markdown' };
|
|
156
|
+
break;
|
|
157
|
+
case 'get_document':
|
|
158
|
+
path = `/api/knowledge/documents/${component(input.documentId)}`;
|
|
159
|
+
break;
|
|
160
|
+
case 'update_document':
|
|
161
|
+
path = `/api/knowledge/documents/${component(input.documentId)}`;
|
|
162
|
+
method = 'PATCH';
|
|
163
|
+
body = {};
|
|
164
|
+
if (input.title !== undefined) {
|
|
165
|
+
if (!text(input.title))
|
|
166
|
+
throw Error('runtime_invalid_input');
|
|
167
|
+
body.title = input.title;
|
|
168
|
+
}
|
|
169
|
+
if (input.body !== undefined) {
|
|
170
|
+
if (typeof input.body !== 'string' || input.body.length > 100000)
|
|
171
|
+
throw Error('runtime_invalid_input');
|
|
172
|
+
body.body = input.body;
|
|
173
|
+
}
|
|
174
|
+
if (!Object.keys(body).length)
|
|
175
|
+
throw Error('runtime_invalid_input');
|
|
176
|
+
break;
|
|
177
|
+
case 'delete_document':
|
|
178
|
+
path = `/api/knowledge/documents/${component(input.documentId)}`;
|
|
179
|
+
method = 'DELETE';
|
|
180
|
+
break;
|
|
181
|
+
case 'search':
|
|
182
|
+
if (!text(input.query))
|
|
183
|
+
throw Error('runtime_invalid_input');
|
|
184
|
+
path = `/api/companies/${company}/knowledge/search?q=${encodeURIComponent(input.query)}`;
|
|
185
|
+
break;
|
|
186
|
+
case 'memory_recall':
|
|
187
|
+
case 'memory_context': {
|
|
188
|
+
if (operation === 'memory_context' && !text(input.query))
|
|
189
|
+
throw Error('runtime_invalid_input');
|
|
190
|
+
body = { partitionKey: app.companyId, scopeRef: app.companyId };
|
|
191
|
+
const strings = operation === 'memory_context' ? ['query'] : ['query', 'entity', 'grep', 'sessionId', 'since'];
|
|
192
|
+
for (const key of strings)
|
|
193
|
+
if (input[key] !== undefined) {
|
|
194
|
+
if (!text(input[key]))
|
|
195
|
+
throw Error('runtime_invalid_input');
|
|
196
|
+
body[key] = input[key];
|
|
197
|
+
}
|
|
198
|
+
for (const key of operation === 'memory_context' ? ['expand'] : ['includeExpired', 'includePending', 'supersessions'])
|
|
199
|
+
if (input[key] !== undefined) {
|
|
200
|
+
if (typeof input[key] !== 'boolean')
|
|
201
|
+
throw Error('runtime_invalid_input');
|
|
202
|
+
body[key] = input[key];
|
|
203
|
+
}
|
|
204
|
+
if (input.limit !== undefined) {
|
|
205
|
+
if (!Number.isInteger(input.limit) || Number(input.limit) < 1 || Number(input.limit) > 100)
|
|
206
|
+
throw Error('runtime_invalid_input');
|
|
207
|
+
body.limit = input.limit;
|
|
208
|
+
}
|
|
209
|
+
if (operation === 'memory_context' && input.detail !== undefined) {
|
|
210
|
+
if (!['low', 'medium', 'high'].includes(String(input.detail)))
|
|
211
|
+
throw Error('runtime_invalid_input');
|
|
212
|
+
body.detail = input.detail;
|
|
213
|
+
}
|
|
214
|
+
if (operation === 'memory_recall' && input.budgetTokens !== undefined) {
|
|
215
|
+
if (!Number.isInteger(input.budgetTokens) || Number(input.budgetTokens) < 256 || Number(input.budgetTokens) > 32000)
|
|
216
|
+
throw Error('runtime_invalid_input');
|
|
217
|
+
body.budgetTokens = input.budgetTokens;
|
|
218
|
+
}
|
|
219
|
+
path = operation === 'memory_recall' ? '/api/brain/recall' : '/api/brain/context';
|
|
220
|
+
method = 'POST';
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'memory_entities': {
|
|
224
|
+
const query = new URLSearchParams({ partitionKey: app.companyId });
|
|
225
|
+
if (input.slug !== undefined) {
|
|
226
|
+
if (!text(input.slug))
|
|
227
|
+
throw Error('runtime_invalid_input');
|
|
228
|
+
query.set('slug', input.slug);
|
|
229
|
+
}
|
|
230
|
+
if (input.limit !== undefined) {
|
|
231
|
+
if (!Number.isInteger(input.limit) || Number(input.limit) < 1 || Number(input.limit) > 100)
|
|
232
|
+
throw Error('runtime_invalid_input');
|
|
233
|
+
query.set('limit', String(input.limit));
|
|
234
|
+
}
|
|
235
|
+
path = `/api/brain/entities?${query}`;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Object IDs must remain within the signed registration partition even if the local
|
|
240
|
+
// app principal was configured with a broader ceiling. Knowledge checks resolved scope.
|
|
241
|
+
if (path.startsWith('/api/knowledge/'))
|
|
242
|
+
path += `?companyId=${company}`;
|
|
243
|
+
// Recheck after asynchronous credential resolution; no call may start with an expired lease.
|
|
244
|
+
const token = await this.options.resolveCredential(b.credentialRef);
|
|
245
|
+
if (!text(token) || /[\r\n]/.test(token))
|
|
246
|
+
throw Error('runtime_credential_unavailable');
|
|
247
|
+
if (!this.cached || this.cached.revision !== c.revision)
|
|
248
|
+
throw Error('runtime_configuration_changed');
|
|
249
|
+
if (this.now() >= c.expires)
|
|
250
|
+
throw Error('runtime_authorization_expired');
|
|
251
|
+
outcome = 'failed';
|
|
252
|
+
const deadline = AbortSignal.timeout(operation.startsWith('memory_native_') ? 610000 : operation.startsWith('memory_') ? 120000 : 30000);
|
|
253
|
+
const response = await this.fetcher(new URL(path, endpoint(b.endpoint)), { method, headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}), ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
|
|
254
|
+
if (operation === 'memory_tools' || operation.startsWith('memory_native_')) {
|
|
255
|
+
const result = await boundedJson(response, 8388608);
|
|
256
|
+
if (!result || typeof result.ok !== 'boolean' || (!response.ok && result.ok))
|
|
257
|
+
throw Error('runtime_app_request_failed');
|
|
258
|
+
if (operation === 'memory_tools' && result.ok) {
|
|
259
|
+
if (!Array.isArray(result.data?.tools))
|
|
260
|
+
throw Error('runtime_app_request_failed');
|
|
261
|
+
result.data.tools = result.data.tools.filter((tool) => nativeMemoryOperations.includes(tool.name) && nativeRequiredActions(`memory_native_${tool.name}`).every(action => app.actions.includes(action)));
|
|
262
|
+
}
|
|
263
|
+
outcome = result.ok ? 'success' : 'denied';
|
|
264
|
+
return JSON.parse(JSON.stringify(result).replaceAll(token, '[REDACTED]'));
|
|
265
|
+
}
|
|
266
|
+
if (!response.ok)
|
|
267
|
+
throw Error(response.status === 401 || response.status === 403 ? 'runtime_app_denied' : 'runtime_app_request_failed');
|
|
268
|
+
const result = response.status === 204 ? { ok: true } : await boundedJson(response, 2097152);
|
|
269
|
+
outcome = 'success';
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error instanceof Error && /^runtime_[a-z_]+$/.test(error.message))
|
|
274
|
+
throw error;
|
|
275
|
+
throw Error('runtime_app_unavailable');
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
try {
|
|
279
|
+
this.options.audit?.({ connectionId: this.options.connectionId, registrationId, operation, outcome, revision });
|
|
280
|
+
}
|
|
281
|
+
catch { /* Logging must not change execution outcome. */ }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
package/dist/wake.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Optional host adapter. No provider is assumed from an agent's hostname. */
|
|
2
|
+
export interface HostWakeAdapter {
|
|
3
|
+
status(): Promise<{
|
|
4
|
+
supported: boolean;
|
|
5
|
+
provider?: string;
|
|
6
|
+
timezone?: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}>;
|
|
9
|
+
validate(routines: {
|
|
10
|
+
name: string;
|
|
11
|
+
cron: string;
|
|
12
|
+
}[]): void;
|
|
13
|
+
reconcile(routines: {
|
|
14
|
+
name: string;
|
|
15
|
+
cron: string;
|
|
16
|
+
}[]): Promise<void>;
|
|
17
|
+
}
|
package/dist/wake.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tealbrick/portal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
8
|
"README.md",
|
|
9
9
|
"LICENSE",
|
|
10
|
-
"NOTICE"
|
|
10
|
+
"NOTICE",
|
|
11
|
+
"MANAGEMENT.md"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=24"
|
|
14
15
|
},
|
|
15
16
|
"exports": {
|
|
17
|
+
"./runtime": {
|
|
18
|
+
"types": "./dist/runtime.d.ts",
|
|
19
|
+
"import": "./dist/runtime.js"
|
|
20
|
+
},
|
|
16
21
|
".": {
|
|
17
22
|
"types": "./dist/index.d.ts",
|
|
18
23
|
"import": "./dist/index.js"
|
|
@@ -20,6 +25,14 @@
|
|
|
20
25
|
"./eve": {
|
|
21
26
|
"types": "./dist/eve.d.ts",
|
|
22
27
|
"import": "./dist/eve.js"
|
|
28
|
+
},
|
|
29
|
+
"./management": {
|
|
30
|
+
"types": "./dist/management.d.ts",
|
|
31
|
+
"import": "./dist/management.js"
|
|
32
|
+
},
|
|
33
|
+
"./management/eve": {
|
|
34
|
+
"types": "./dist/management-eve.d.ts",
|
|
35
|
+
"import": "./dist/management-eve.js"
|
|
23
36
|
}
|
|
24
37
|
},
|
|
25
38
|
"scripts": {
|
|
@@ -31,7 +44,7 @@
|
|
|
31
44
|
"jose": "^6.1.0"
|
|
32
45
|
},
|
|
33
46
|
"peerDependencies": {
|
|
34
|
-
"eve": ">=0.
|
|
47
|
+
"eve": ">=0.64.1 <0.65.0"
|
|
35
48
|
},
|
|
36
49
|
"peerDependenciesMeta": {
|
|
37
50
|
"eve": {
|